consumer模块完成度95%,优化安卓端界面和小程序测试3
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
<view class="cart-page">
|
||||
<!-- 智能顶部导航栏 - 与消息页保持一致 -->
|
||||
<view class="smart-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="nav-container">
|
||||
<view class="nav-container" :style="{ paddingRight: navBarRight + 'px' }">
|
||||
<text class="nav-title">购物车</text>
|
||||
<view class="nav-actions">
|
||||
<view class="action-btn" @click="toggleManageMode">
|
||||
@@ -245,6 +245,20 @@ const statusBarHeight = ref(0)
|
||||
const isManageMode = ref(false)
|
||||
const updatingItems = ref<Set<string>>(new Set()) // Track items being updated to prevent race conditions
|
||||
|
||||
// 小程序胶囊按钮信息类型
|
||||
type CapsuleButtonInfo = {
|
||||
left: number,
|
||||
top: number,
|
||||
right: number,
|
||||
bottom: number,
|
||||
width: number,
|
||||
height: number
|
||||
}
|
||||
|
||||
// 小程序胶囊按钮信息
|
||||
const capsuleButtonInfo = ref<CapsuleButtonInfo | null>(null)
|
||||
const navBarRight = ref(0) // 导航栏右侧预留空间
|
||||
|
||||
// 计算属性
|
||||
const cartGroups = computed<CartGroup[]>(() => {
|
||||
console.log('[cartGroups] 计算购物车分组, cartItems count:', cartItems.value.length)
|
||||
@@ -326,6 +340,27 @@ const toggleManageMode = () => {
|
||||
const initPage = () => {
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
statusBarHeight.value = systemInfo.statusBarHeight ?? 0
|
||||
|
||||
// 获取小程序胶囊按钮信息
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
const menuButton = uni.getMenuButtonBoundingClientRect()
|
||||
if (menuButton != null) {
|
||||
capsuleButtonInfo.value = {
|
||||
left: menuButton.left,
|
||||
top: menuButton.top,
|
||||
right: menuButton.right,
|
||||
bottom: menuButton.bottom,
|
||||
width: menuButton.width,
|
||||
height: menuButton.height
|
||||
}
|
||||
navBarRight.value = (systemInfo.screenWidth - menuButton.left) + 10
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('获取胶囊按钮信息失败', e)
|
||||
navBarRight.value = 90
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
@@ -863,7 +898,7 @@ const navigateToProduct = (product: any) => {
|
||||
const name = productJson.getString('name') ?? ''
|
||||
paramsArr.push('name=' + encodeURIComponent(name))
|
||||
|
||||
const image = productJson.getString('image') ?? '/static/product1.jpg'
|
||||
const image = productJson.getString('image') ?? '/static/images/default-product.png'
|
||||
paramsArr.push('image=' + encodeURIComponent(image))
|
||||
|
||||
const url = `/pages/mall/consumer/product-detail?${paramsArr.join('&')}`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<view class="category-page">
|
||||
<!-- 顶部搜索栏 -->
|
||||
<view class="search-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="search-container">
|
||||
<view class="search-container" :style="{ paddingRight: navBarRight + 'px' }">
|
||||
<view class="search-box" @click="navigateToSearch" :style="{ height: '30px' }">
|
||||
<!-- 模拟输入框 -->
|
||||
<text class="search-placeholder">请输入商品名称、店铺</text>
|
||||
@@ -139,6 +139,21 @@ type LocalCategory = {
|
||||
// 响应式数据
|
||||
const statusBarHeight = ref(0)
|
||||
const headerHeight = ref(44)
|
||||
|
||||
// 小程序胶囊按钮信息类型
|
||||
type CapsuleButtonInfo = {
|
||||
left: number,
|
||||
top: number,
|
||||
right: number,
|
||||
bottom: number,
|
||||
width: number,
|
||||
height: number
|
||||
}
|
||||
|
||||
// 小程序胶囊按钮信息
|
||||
const capsuleButtonInfo = ref<CapsuleButtonInfo | null>(null)
|
||||
const navBarRight = ref(0) // 导航栏右侧预留空间
|
||||
|
||||
const primaryCategories = ref<LocalCategory[]>([])
|
||||
const subCategories = ref<LocalCategory[]>([]) // 二级分类列表
|
||||
const productList = ref<Product[]>([])
|
||||
@@ -547,6 +562,28 @@ onShow(() => {
|
||||
onLoad((options: any) => {
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
statusBarHeight.value = systemInfo.statusBarHeight
|
||||
|
||||
// 获取小程序胶囊按钮信息
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
const menuButton = uni.getMenuButtonBoundingClientRect()
|
||||
if (menuButton != null) {
|
||||
capsuleButtonInfo.value = {
|
||||
left: menuButton.left,
|
||||
top: menuButton.top,
|
||||
right: menuButton.right,
|
||||
bottom: menuButton.bottom,
|
||||
width: menuButton.width,
|
||||
height: menuButton.height
|
||||
}
|
||||
navBarRight.value = (systemInfo.screenWidth - menuButton.left) + 10
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('获取胶囊按钮信息失败', e)
|
||||
navBarRight.value = 90
|
||||
}
|
||||
// #endif
|
||||
|
||||
console.log('=== category页面onLoad被调用 ===')
|
||||
|
||||
let categoryId = ''
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
transform: showNavbar ? 'translateY(0)' : 'translateY(-100%)'
|
||||
}"
|
||||
>
|
||||
<view class="search-container">
|
||||
<view class="search-container" :style="{ paddingRight: navBarRight + 'px' }">
|
||||
<view class="search-box" @click="navigateToSearch" :style="{ height: '30px' }">
|
||||
<!-- 模拟输入框 -->
|
||||
<text class="search-placeholder">请输入商品名称、店铺</text>
|
||||
@@ -287,6 +287,20 @@ const activeFilter = ref('recommend')
|
||||
const currentPage = ref(1)
|
||||
const priceAscending = ref(true) // 价格排序方向:true=升序,false=降序
|
||||
|
||||
// 小程序胶囊按钮信息类型
|
||||
type CapsuleButtonInfo = {
|
||||
left: number,
|
||||
top: number,
|
||||
right: number,
|
||||
bottom: number,
|
||||
width: number,
|
||||
height: number
|
||||
}
|
||||
|
||||
// 小程序胶囊按钮信息
|
||||
const capsuleButtonInfo = ref<CapsuleButtonInfo | null>(null)
|
||||
const navBarRight = ref(0) // 导航栏右侧预留空间
|
||||
|
||||
// 数据源
|
||||
const hotProducts = ref<Product[]>([])
|
||||
const recommendedProducts = ref<Product[]>([])
|
||||
@@ -629,6 +643,24 @@ const initPage = () => {
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
statusBarHeight.value = systemInfo.statusBarHeight
|
||||
|
||||
// 获取小程序胶囊按钮信息
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
capsuleButtonInfo.value = uni.getMenuButtonBoundingClientRect()
|
||||
if (capsuleButtonInfo.value != null) {
|
||||
// 计算导航栏右侧需要预留的空间(胶囊按钮宽度 + 左右边距)
|
||||
navBarRight.value = (systemInfo.screenWidth - capsuleButtonInfo.value.left) + 10
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('获取胶囊按钮信息失败', e)
|
||||
navBarRight.value = 90 // 默认预留空间
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
navBarRight.value = 0 // 非小程序不需要预留空间
|
||||
// #endif
|
||||
|
||||
// 计算滚动区域高度 - 不再需要手动计算,使用 Flex 布局自动撑开
|
||||
// scrollHeight.value = windowHeight - 50
|
||||
|
||||
@@ -985,7 +1017,7 @@ const navigateToProduct = (product: any) => {
|
||||
const productId = prodObj.getString('productId') ?? prodObj.getString('id') ?? ''
|
||||
const name = prodObj.getString('name') ?? ''
|
||||
// 使用 main_image_url
|
||||
const image = prodObj.getString('main_image_url') ?? prodObj.getString('image') ?? '/static/product1.jpg'
|
||||
const image = prodObj.getString('main_image_url') ?? prodObj.getString('image') ?? '/static/images/default-product.png'
|
||||
const price = (prodObj.getNumber('base_price') ?? prodObj.getNumber('price') ?? 0).toString()
|
||||
const marketPrice = prodObj.getNumber('market_price') ?? prodObj.getNumber('original_price') ?? (parseFloat(price) * 1.2)
|
||||
const originalPrice = marketPrice.toString()
|
||||
|
||||
@@ -413,7 +413,7 @@ const loadMessages = async () => {
|
||||
time: formatTime(room.last_message_at ?? ''),
|
||||
read: room.unread_count === 0,
|
||||
type: 'service',
|
||||
avatar: room.shop_logo ?? '/static/icons/shop-default.png',
|
||||
avatar: room.shop_logo ?? '/static/icons/customer-service.png',
|
||||
online: true,
|
||||
unreadCount: room.unread_count,
|
||||
tags: [] as string[],
|
||||
@@ -442,7 +442,7 @@ const loadMessages = async () => {
|
||||
time: '刚刚',
|
||||
read: true,
|
||||
type: 'service',
|
||||
avatar: '/static/icons/service-avatar.png',
|
||||
avatar: '/static/icons/customer-service.png',
|
||||
online: true,
|
||||
unreadCount: 0,
|
||||
tags: ['自动回复'],
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
<!-- 基础用户信息:头像和昵称 -->
|
||||
<view class="nav-user-basic" @click="editProfile">
|
||||
<image
|
||||
:src="userInfo.avatar_url != '' ? userInfo.avatar_url : '/static/default-avatar.png'"
|
||||
:src="userInfo.avatar_url != '' ? userInfo.avatar_url : '/static/images/default-product.png'"
|
||||
class="nav-avatar"
|
||||
/>
|
||||
<text class="nav-user-name">{{ userInfo.nickname != '' ? userInfo.nickname : userInfo.phone }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 用户资产横向排列 (积分、余额、优惠券) -->
|
||||
<view class="nav-user-stats">
|
||||
<view class="nav-user-stats" :style="{ marginRight: navBarRight + 'px' }">
|
||||
<view class="nav-stat-item" @click="goToPoints">
|
||||
<text class="nav-stat-label">积分</text>
|
||||
<text class="nav-stat-value">{{ userStats.points }}</text>
|
||||
@@ -371,6 +371,7 @@ export default {
|
||||
save_amount: 0
|
||||
} as ConsumptionStatsType,
|
||||
statusBarHeight: 0,
|
||||
navBarRight: 0, // 导航栏右侧预留空间(小程序胶囊按钮)
|
||||
currentOrderTab: 'all' as string,
|
||||
allOrders: [] as Array<OrderItemType>
|
||||
}
|
||||
@@ -529,6 +530,19 @@ export default {
|
||||
initPage() {
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
this.statusBarHeight = systemInfo.statusBarHeight ?? 0
|
||||
|
||||
// 获取小程序胶囊按钮信息
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
const menuButton = uni.getMenuButtonBoundingClientRect()
|
||||
if (menuButton != null) {
|
||||
this.navBarRight = (systemInfo.screenWidth - menuButton.left) + 10
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('获取胶囊按钮信息失败', e)
|
||||
this.navBarRight = 90
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
async loadUserProfile() {
|
||||
try {
|
||||
@@ -570,7 +584,7 @@ export default {
|
||||
phone: uPhone,
|
||||
email: uEmail,
|
||||
nickname: uNickname != '' ? uNickname : '微信用户',
|
||||
avatar_url: uAvatar != '' ? uAvatar : '/static/default-avatar.png',
|
||||
avatar_url: uAvatar != '' ? uAvatar : '/static/images/default-product.png',
|
||||
gender: uGender,
|
||||
user_type: 1,
|
||||
status: 1,
|
||||
@@ -929,20 +943,20 @@ export default {
|
||||
|
||||
getOrderMainImage(order: OrderItemType): string {
|
||||
const itemsRaw = order.ml_order_items
|
||||
if (itemsRaw == null) return '/static/product1.jpg'
|
||||
if (itemsRaw == null) return '/static/images/default-product.png'
|
||||
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) return '/static/product1.jpg'
|
||||
if (itemParsed == null) return '/static/images/default-product.png'
|
||||
const itemObj = itemParsed as UTSJSONObject
|
||||
const imgUrl = itemObj.getString('image_url')
|
||||
const prodImg = itemObj.getString('product_image')
|
||||
const img = (imgUrl != null && imgUrl !== '') ? imgUrl : prodImg
|
||||
if (img != null && img !== '') return img
|
||||
}
|
||||
return '/static/product1.jpg'
|
||||
return '/static/images/default-product.png'
|
||||
},
|
||||
|
||||
getOrderTitle(order: OrderItemType): string {
|
||||
|
||||
@@ -1,463 +0,0 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<scroll-view class="address-edit-page" direction="vertical">
|
||||
<view class="form-group">
|
||||
<view class="form-item">
|
||||
<text class="label">鏀惰揣浜?/text>
|
||||
<input class="input" v-model="formData.name" placeholder="璇峰~鍐欐敹璐т汉濮撳悕" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">鎵嬫満鍙风爜</text>
|
||||
<input class="input" v-model="formData.phone" type="number" maxlength="11" placeholder="璇峰~鍐欐墜鏈哄彿鐮? />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">鎵?鍦ㄥ湴鍖?/text>
|
||||
<input class="input" v-model="regionString" placeholder="鐪佸競鍖哄幙銆佷埂闀囩瓑" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">璇︾粏鍦板潃</text>
|
||||
<input class="input" v-model="formData.detail" placeholder="琛楅亾銆佹ゼ鐗屽彿绛? />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="form-item">
|
||||
<text class="label">鏅鸿兘濉啓</text>
|
||||
<textarea class="smart-textarea" v-model="smartInput" placeholder="绮樿创濮撳悕+鐢佃瘽+鍦板潃锛岃嚜鍔ㄨ瘑鍒~鍏? @input="parseSmartInput" maxlength="200"></textarea>
|
||||
<text class="smart-tip">绀轰緥锛氬紶涓?13800138000 鍖椾含甯傛湞闃冲尯涓夐噷灞疭OHO A搴?/text>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">鏍囩</text>
|
||||
<view class="tags-container">
|
||||
<text
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
class="tag-item"
|
||||
:class="{ active: formData.label === tag }"
|
||||
@click="selectTag(tag)"
|
||||
>{{ tag }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-item switch-item">
|
||||
<text class="label">璁句负榛樿鏀惰揣鍦板潃</text>
|
||||
<switch :checked="formData.isDefault" color="#ff5000" @change="onSwitchChange" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer-btn">
|
||||
<button class="save-btn" @click="saveAddress">淇濆瓨</button>
|
||||
<button v-if="isEdit" class="delete-btn" @click="deleteAddress">鍒犻櫎鏀惰揣鍦板潃</button>
|
||||
</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 {
|
||||
// 浠嶴upabase鍔犺浇鍦板潃璇︽儏
|
||||
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('瑙f瀽鏈湴鍦板潃鏁版嵁澶辫触', 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] ?? ''
|
||||
}
|
||||
|
||||
// 鎻愬彇濮撳悕锛堝彇绗竴涓?-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()
|
||||
|
||||
// 瑙f瀽鐪佸競鍖?
|
||||
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('瑙f瀽鏈湴鍦板潃鏁版嵁澶辫触', e)
|
||||
}
|
||||
}
|
||||
|
||||
uni.showToast({
|
||||
title: '鍒犻櫎鎴愬姛',
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
}, 1500)
|
||||
} else {
|
||||
console.error('鍒犻櫎鍦板潃澶辫触')
|
||||
uni.showToast({
|
||||
title: '鍒犻櫎澶辫触',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.page-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.address-edit-page {
|
||||
flex: 1; /* Replace min-height: 100vh */
|
||||
}
|
||||
|
||||
.form-group {
|
||||
background-color: white;
|
||||
margin-bottom: 15px;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
.form-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
width: 80px;
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.switch-item {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
border: 1px solid #ddd;
|
||||
padding: 4px 12px;
|
||||
border-radius: 15px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.tag-item.active {
|
||||
background-color: #ff5000;
|
||||
color: white;
|
||||
border-color: #ff5000;
|
||||
}
|
||||
|
||||
.footer-btn {
|
||||
margin-top: 30px;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background-color: #ff5000;
|
||||
color: white;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
border: none;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background-color: white;
|
||||
color: #333;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
<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 } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
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)
|
||||
let openerEventChannel: any | null = null
|
||||
|
||||
onShow(() => {
|
||||
loadAddresses()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
const ec = uni.getOpenerEventChannel()
|
||||
openerEventChannel = ec
|
||||
ec?.on('setSelectMode', (data: any) => {
|
||||
if (data && typeof data.selectMode === 'boolean') {
|
||||
selectionMode.value = data.selectMode
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
const loadAddresses = () => {
|
||||
const storedAddresses = uni.getStorageSync('addresses')
|
||||
if (storedAddresses) {
|
||||
try {
|
||||
addresses.value = JSON.parse(storedAddresses as string) as Address[]
|
||||
} catch (e) {
|
||||
console.error('Failed to parse addresses', e)
|
||||
addresses.value = []
|
||||
}
|
||||
} else {
|
||||
// 鍒濆Mock鏁版嵁
|
||||
addresses.value = [
|
||||
{
|
||||
id: 'addr_001',
|
||||
name: '寮犱笁',
|
||||
phone: '13800138000',
|
||||
province: '鍖椾含甯?,
|
||||
city: '鍖椾含甯?,
|
||||
district: '鏈濋槼鍖?,
|
||||
detail: '涓夐噷灞疭OHO A搴?,
|
||||
isDefault: true,
|
||||
label: '鍏徃'
|
||||
}
|
||||
]
|
||||
uni.setStorageSync('addresses', JSON.stringify(addresses.value))
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const editAddress = (id: string) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/address-edit?id=${id}`
|
||||
})
|
||||
}
|
||||
|
||||
const selectAddress = (item: Address) => {
|
||||
if (selectionMode.value && openerEventChannel) {
|
||||
openerEventChannel.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>
|
||||
.item-actions {
|
||||
padding: 10px;
|
||||
border-left: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
flex-direction: column; /* 绔栧悜鎺掑垪鍥炬爣 */
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.footer-btn {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: white;
|
||||
padding: 10px 15px;
|
||||
padding-bottom: calc(10px + env(safe-area-inset-bottom));
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
|
||||
display: flex;
|
||||
justify-content: center; /* 灞呬腑鏄剧ず */
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background-color: #ff5000;
|
||||
color: white;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
border: none;
|
||||
width: 100%; /* 榛樿鍗犳弧 */
|
||||
max-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>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
||||
<!-- pages/mall/consumer/cart.uvue -->
|
||||
<template>
|
||||
<view class="cart-page">
|
||||
<!-- 鏅鸿兘椤堕儴瀵艰埅鏍?- 涓庢秷鎭〉淇濇寔涓€鑷?-->
|
||||
<view class="smart-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="nav-container">
|
||||
<text class="nav-title">璐墿杞?/text>
|
||||
<view class="nav-actions">
|
||||
<view class="action-btn" @click="toggleManageMode">
|
||||
<text class="action-icon">{{ isManageMode ? '鉁? : '鈿欙笍' }}</text>
|
||||
<text class="action-text">{{ isManageMode ? '瀹屾垚' : '绠$悊' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 瀵艰埅鏍忓崰浣嶇 - 闇€瑕佸寘鍚玸tatusBarHeight + 瀵艰埅鏍忛珮搴?4px -->
|
||||
<view class="navbar-placeholder" :style="{ height: (statusBarHeight + 44) + 'px' }"></view>
|
||||
|
||||
<!-- 璐墿杞﹀唴瀹?-->
|
||||
<scroll-view
|
||||
:scroll-y="true"
|
||||
class="cart-content"
|
||||
:show-scrollbar="false"
|
||||
:enhanced="true"
|
||||
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
@@ -1,794 +0,0 @@
|
||||
<!-- 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">
|
||||
<text class="chat-title">{{ headerTitle }}</text>
|
||||
<text class="chat-status">在线</text>
|
||||
</view>
|
||||
<view class="header-actions">
|
||||
<text class="action-icon" @click="showMoreActions">⋯</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 聊天内容 -->
|
||||
<scroll-view
|
||||
scroll-y="true"
|
||||
class="chat-content"
|
||||
:scroll-into-view="scrollToView"
|
||||
:scroll-with-animation="false"
|
||||
: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">
|
||||
<text 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">
|
||||
<text class="message-text">{{ message.content }}</text>
|
||||
<text class="message-time">{{ message.time }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<image
|
||||
class="avatar me"
|
||||
src="/static/default-avatar.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
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
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 = 'msg-' + lastMsg.id
|
||||
|
||||
// 关键点:在 UVue 安卓端,直接连续赋值可能被合并。
|
||||
// 我们先清空 ID,然后在下一帧赋值,确保 scroll-view 监听到变化。
|
||||
scrollToView.value = ''
|
||||
|
||||
// 增加多次尝试,确保在 DOM 彻底完成渲染(包含由于高度计算引起的多次排版)后定位。
|
||||
setTimeout(() => {
|
||||
scrollToView.value = targetId
|
||||
console.log('[scrollToBottom] 发起第一次滚动定位', targetId)
|
||||
|
||||
// 二次校准:针对长消息或图片导致的高度变化
|
||||
setTimeout(() => {
|
||||
scrollToView.value = ''
|
||||
setTimeout(() => {
|
||||
scrollToView.value = targetId
|
||||
console.log('[scrollToBottom] 二次校准完成:', targetId)
|
||||
}, 50)
|
||||
}, 100)
|
||||
}, 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}:
|
||||
}
|
||||
|
||||
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') ?? ''
|
||||
|
||||
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)
|
||||
|
||||
// 检查消息是否已经在列表中(避免重复)
|
||||
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')}:
|
||||
|
||||
// 生成安全从 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
|
||||
}
|
||||
|
||||
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')}:
|
||||
|
||||
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
|
||||
}
|
||||
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 = ''
|
||||
|
||||
// 发送到 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
|
||||
inputFocus.value = true
|
||||
}
|
||||
|
||||
// 显示表情选择器
|
||||
function showEmojiPicker(): void {
|
||||
showEmoji.value = !showEmoji.value
|
||||
}
|
||||
|
||||
// 显示图片选择器
|
||||
function showImagePicker(): void {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
success: (res) => {
|
||||
console.log('选择图片:', res.tempFilePaths)
|
||||
// 这里可以处理图片上传
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 显示更多工具
|
||||
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%;
|
||||
height: 100vh;
|
||||
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: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
text-align: 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;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 聊天内容区 */
|
||||
.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 {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.system-text {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
background-color: #f0f0f0;
|
||||
padding: 5px 15px;
|
||||
border-radius: 15px;
|
||||
|
||||
}
|
||||
|
||||
/* 时间分割线 */
|
||||
.time-divider {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
background-color: #f0f0f0;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 消息项 */
|
||||
.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 {
|
||||
max-width: 70%;
|
||||
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);
|
||||
align-self: flex-start; /* 关键:根据内容宽度自适应,不撑满 */
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.message-bubble.me {
|
||||
background-color: #95ec69;
|
||||
align-self: flex-end; /* 关键:靠右对齐且宽度自适应 */
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
|
||||
.message-bubble:not(.me) {
|
||||
border-top-left-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;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 5px;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
</view>
|
||||
<image
|
||||
class="avatar me"
|
||||
src="/static/default-avatar.png"
|
||||
src="/static/images/default-product.png"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
</view>
|
||||
<image
|
||||
class="avatar me"
|
||||
:src="userAvatar || '/static/default-avatar.png'"
|
||||
:src="userAvatar || '/static/images/default-product.png'"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
# 基于 Supabase 实现消费者-商家聊天购物闭环方案
|
||||
|
||||
> **结论:** 是的,仅依赖 Supabase (配合前端逻辑) 完全可以实现“聊天购物闭环”的核心业务流程。 Supabase 提供的 Authentication(身份验证)、Database(数据库)、Realtime(实时订阅)、Storage(存储)以及 Edge Functions(边缘函数)覆盖了即时通讯和订单状态流转所需的所有基础设施。
|
||||
>
|
||||
> *注:实际资金的支付(扣款)通常需要对接微信支付/支付宝/Stripe等第三方支付网关,但 Supabase 可以完美托管支付前后的数据流、状态流和 Webhook 处理。*
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心架构概览
|
||||
|
||||
在不引入额外后端服务(如 Node.js/Java 服务端)的情况下,架构如下:
|
||||
|
||||
* **客户端 (Uni-app / Vue)**: 消费者端消费者 App,商家端管理后台。直接通过 `supabase-js` SDK 与 Supabase 交互。
|
||||
* **身份验证 (Auth)**: 区分消费者(Consumer)和商家(Merchant/Admin)。利用 RLS (Row Level Security) 确保数据隔离。
|
||||
* **实时通讯 (Realtime)**: 监听 `messages` 和 `orders` 表的变动,实现毫秒级消息推送和订单状态更新。
|
||||
* **业务逻辑 (Database + Edge Functions)**: 使用 Postgres 函数处理复杂的原子操作(如创建订单),使用 Edge Functions 处理支付回调。
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据库设计 (Schema)
|
||||
|
||||
为了支持“边聊边买”,我们需要设计能关联聊天与订单的数据结构。
|
||||
|
||||
### 2.1 核心表结构
|
||||
|
||||
**1. 聊天室表 (`chat_rooms`)**
|
||||
| 字段 | 类型 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| `id` | uuid | 主键 |
|
||||
| `consumer_id` | uuid | 关联 `auth.users` (消费者) |
|
||||
| `merchant_id` | uuid | 关联 `auth.users` (商家) |
|
||||
| `last_message` | jsonb | 最后一条消息快照(用于列表展示) |
|
||||
| `updated_at` | timestamp | 排序用 |
|
||||
|
||||
**2. 消息表 (`messages`)**
|
||||
| 字段 | 类型 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| `id` | uuid | 主键 |
|
||||
| `room_id` | uuid | 外键关联 `chat_rooms` |
|
||||
| `sender_id` | uuid | 发送者 ID |
|
||||
| `type` | text | 消息类型: `text`, `image`, `product`, `order`, `system` |
|
||||
| `payload` | jsonb | 消息内容。如果是 `product`,存商品快照;如果是 `order`,存订单摘要 |
|
||||
| `is_read` | boolean | 已读状态 |
|
||||
| `created_at` | timestamp | 发送时间 |
|
||||
|
||||
**3. 订单表 (`orders`)**
|
||||
| 字段 | 类型 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| `id` | uuid | 主键 |
|
||||
| `room_id` | uuid | **关键关联**:该订单属于哪个聊天上下文 |
|
||||
| `consumer_id` | uuid | 买家 |
|
||||
| `merchant_id` | uuid | 卖家 |
|
||||
| `items` | jsonb | 商品列表 |
|
||||
| `total_amount` | numeric | 总金额 |
|
||||
| `status` | text | `pending`, `paid`, `shipped`, `completed`, `cancelled` |
|
||||
| `payment_status`| text | `unpaid`, `success` |
|
||||
|
||||
---
|
||||
|
||||
## 3. “聊天购物”闭环流程详解
|
||||
|
||||
### 场景一:商品咨询与卡片发送
|
||||
1. **场景**: 消费者在商品详情页点击“联系商家”。
|
||||
2. **动作**:
|
||||
* 前端检查 `chat_rooms` 是否存在该(消费者, 商家)的记录,没有则插入(`upsert`)。
|
||||
* 跳转至聊天页。
|
||||
* **特色功能**: 自动发送一条 `type: product` 的消息,包含当前浏览的商品卡片 (`payload: { id, title, price, image }`)。
|
||||
3. **实现 (Supabase)**:
|
||||
* 直接写入 `messages` 表。
|
||||
* 商家端通过 `supabase.channel('messages').on(...)` 实时收到商品卡片,知道用户对什么感兴趣。
|
||||
|
||||
### 场景二:商家发起收款(创建订单)
|
||||
1. **场景**: 双方沟通确认购买意向后,商家点击“发起订单”或“直接改价”。
|
||||
2. **动作**:
|
||||
* 商家选择商品,填写金额,生成预订单。
|
||||
* 向 `orders` 表插入一条状态为 `pending` 的记录。
|
||||
* 向 `messages` 表插入一条 `type: order` 的消息,`payload` 包含 `order_id` 和摘要。
|
||||
3. **表现**: 消费者在聊天流中看到一个“待支付订单卡片”。
|
||||
|
||||
### 场景三:聊天中支付
|
||||
1. **场景**: 消费者点击聊天气泡中的“立即支付”按钮。
|
||||
2. **动作**:
|
||||
* APP 唤起支付(微信/支付宝)。
|
||||
* 支付成功后,支付平台回调 Supabase Edge Function (或通过前端验证)。
|
||||
* 更新 `orders` 表 `status` 为 `paid`。
|
||||
3. **闭环**:
|
||||
* Supabase 监听到 `orders` 表 `status` 变为 `paid`。
|
||||
* **触发器 (Trigger)** 或客户端逻辑自动插入一条 `type: system` 的消息:“订单已支付,等待发货”。
|
||||
* 商家端聊天界面实时更新订单状态为“已支付”。
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键技术实现点
|
||||
|
||||
### 4.1 RLS (行级安全策略)
|
||||
必须配置严格的 RLS,防止用户偷看他人聊天。
|
||||
|
||||
```sql
|
||||
-- 示例:只能查看属于自己的聊天室
|
||||
create policy "Users can view their own rooms"
|
||||
on chat_rooms for select
|
||||
using (auth.uid() = consumer_id or auth.uid() = merchant_id);
|
||||
```
|
||||
|
||||
### 4.2 实时订阅 (Realtime)
|
||||
|
||||
前端代码示例 (Prolog/Vue):
|
||||
|
||||
```typescript
|
||||
// 订阅消息
|
||||
const messageChannel = supabase
|
||||
.channel('chat-room-123')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}` },
|
||||
(payload) => {
|
||||
messages.value.push(payload.new)
|
||||
scrollToBottom()
|
||||
}
|
||||
)
|
||||
.subscribe()
|
||||
|
||||
// 订阅订单状态变更 (实现卡片状态自动刷新)
|
||||
const orderChannel = supabase
|
||||
.channel('order-updates')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'UPDATE', schema: 'public', table: 'orders', filter: `room_id=eq.${roomId}` },
|
||||
(payload) => {
|
||||
updateOrderCardStatus(payload.new.id, payload.new.status)
|
||||
}
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
### 4.3 边缘函数 (Edge Functions) 处理 Webhook
|
||||
由于前端不能直接处理支付回调(不安全),需要使用 Supabase Edge Functions。
|
||||
|
||||
* `functions/payment-webhook/index.ts`: 接收微信支付回调,验证签名,然后使用 Service Role Key 更新 `orders` 表。
|
||||
|
||||
---
|
||||
|
||||
## 5. 总结
|
||||
|
||||
完全依赖 Supabase 实现聊天购物闭环是**可行且高效**的方案。
|
||||
|
||||
* **开发快**: 免去后端 CRUD 接口开发,直接操作 DB。
|
||||
* **实时性**: Realtime 功能天然契合聊天场景。
|
||||
* **成本低**: 无需维护长连接服务器 (WebSocket)。
|
||||
* **闭环体验**: 订单与消息在同一数据流中,用户体验流畅。
|
||||
|
||||
**下一步建议:**
|
||||
1. 在 Supabase 中创建 `chat_rooms`, `messages`, `orders` 表。
|
||||
2. 配置 RLS。
|
||||
3. 开发消息列表 UI,支持多类型消息渲染(特别是商品卡片和订单卡片)。
|
||||
@@ -1,321 +0,0 @@
|
||||
# 消费者端前端数据库文档 (Consumer App DB Schema)
|
||||
|
||||
本文档基于现有消费者前端 (`mall/pages/mall/consumer`) 和 Supabase 服务层 (`mall/utils/supabaseService.uts`) 的调用逻辑生成。旨在协助商家端前端开发进行数据库对接。
|
||||
|
||||
## 1. 核心业务表 (Core Business Tables)
|
||||
|
||||
### 1.1 商品分类表 (`ml_categories`)
|
||||
用于展示商品的一级/二级分类。
|
||||
|
||||
| 字段名 | 类型 | 描述 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | UUID | 主键 | |
|
||||
| `name` | Text | 分类名称 | |
|
||||
| `icon_url` | Text | 图标 URL | 前端可能回退到 Emoji |
|
||||
| `description` | Text | 描述 | |
|
||||
| `parent_id` | UUID | 父分类 ID | 用于树形结构 |
|
||||
| `sort_order` | Integer | 排序权重 | |
|
||||
| `is_active` | Boolean | 是否启用 | |
|
||||
|
||||
### 1.2 品牌表 (`ml_brands`)
|
||||
商品所属品牌信息。
|
||||
|
||||
| 字段名 | 类型 | 描述 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | UUID | 主键 | |
|
||||
| `name` | Text | 品牌名称 | |
|
||||
| `logo_url` | Text | 品牌 Logo URL | |
|
||||
| `description` | Text | 品牌描述 | |
|
||||
| `country` | Text | 所属国家 | 可选 |
|
||||
| `is_active` | Boolean | 是否启用 | |
|
||||
|
||||
### 1.3 商家/店铺表 (`ml_shops`)
|
||||
商家端主要管理的店铺信息实体。
|
||||
|
||||
| 字段名 | 类型 | 描述 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | UUID | 主键 | |
|
||||
| `merchant_id` | UUID | 关联的商户账号 ID | 对应 auth.users 或 merchants 表 |
|
||||
| `shop_name` | Text | 店铺名称 | |
|
||||
| `shop_logo` | Text | 店铺 Logo | |
|
||||
| `shop_banner` | Text | 店铺背景图 | |
|
||||
| `description` | Text | 店铺简介 | |
|
||||
| `contact_name` | Text | 联系人 | |
|
||||
| `contact_phone` | Text | 联系电话 | |
|
||||
| `rating_avg` | Numeric | 平均评分 | |
|
||||
| `total_sales` | Integer | 总销量 | |
|
||||
| `status` | Integer | 状态 | 1: 正常, 0: 停用 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 商品系统 (Product System)
|
||||
|
||||
### 2.1 商品主表 (`ml_products`)
|
||||
商家发布的核心商品数据。
|
||||
|
||||
| 字段名 | 类型 | 描述 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | UUID | 主键 | |
|
||||
| `merchant_id` | UUID | 所属商家 ID | |
|
||||
| `category_id` | UUID | 所属分类 ID | |
|
||||
| `brand_id` | UUID | 所属品牌 ID | |
|
||||
| `name` | Text | 商品名称 | |
|
||||
| `subtitle` | Text | 副标题 | 简短描述 |
|
||||
| `description` | Text | 商品详情 | HTML 或 Markdown |
|
||||
| `main_image_url` | Text | 主图 URL | |
|
||||
| `image_urls` | JSON/Array | 轮播图列表 | `['url1', 'url2']` |
|
||||
| `video_urls` | JSON/Array | 视频列表 | |
|
||||
| `base_price` | Numeric | 基础售价 | 列表页展示价格 |
|
||||
| `market_price` | Numeric | 市场价/划线价 | |
|
||||
| `cost_price` | Numeric | 成本价 | 敏感字段,仅商家可见 |
|
||||
| `total_stock` | Integer | 总库存 | |
|
||||
| `status` | Integer | 状态 | 1: 上架, 0: 下架, 2: 审核中 |
|
||||
| `is_hot` | Boolean | 是否热销 | |
|
||||
| `is_new` | Boolean | 是否新品 | |
|
||||
| `is_featured` | Boolean | 是否推荐 | |
|
||||
| `attributes` | JSONB | 商品属性 | `{ "材质": "纯棉", "季节": "夏季" }` |
|
||||
| `tags` | Text[] | 标签 | |
|
||||
| `sale_count` | Integer | 销量 | 统计字段 |
|
||||
|
||||
### 2.2 商品 SKU 表 (`ml_product_skus`)
|
||||
商品的多规格定义(如颜色、尺寸)。
|
||||
|
||||
| 字段名 | 类型 | 描述 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | UUID | 主键 | |
|
||||
| `product_id` | UUID | 关联商品 ID | |
|
||||
| `sku_code` | Text | SKU 编码 | 商家自定义编码 |
|
||||
| `specifications` | JSONB | 规格键值对 | `{ "颜色": "红", "尺寸": "L" }` |
|
||||
| `price` | Numeric | SKU 售价 | 特殊规格价格 |
|
||||
| `market_price` | Numeric | SKU 市场价 | |
|
||||
| `stock` | Integer | 当前库存 | |
|
||||
| `image_url` | Text | 规格对应图片 | 如红色款对应红色的图 |
|
||||
| `status` | Integer | 状态 | 1: 启用, 0: 禁用 |
|
||||
|
||||
### 2.3 商品详情视图 (`ml_products_detail_view`)
|
||||
**重要**: 消费者端主要通过此视图查询商品,商家在维护数据时应确保这些关联字段能正确生成。
|
||||
* 该视图通常 `JOIN` 了 `ml_shops` (获取 `shop_name`), `ml_brands` (获取 `brand_name`), `ml_categories` (获取 `category_name`)。
|
||||
* **商家端操作**: 不需要直接操作视图,只需维护上述基础表。
|
||||
|
||||
---
|
||||
|
||||
## 3. 交易系统 (Transaction System)
|
||||
|
||||
### 3.1 购物车 (`ml_shopping_cart`)
|
||||
|
||||
| 字段名 | 类型 | 描述 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | UUID | 主键 | |
|
||||
| `user_id` | UUID | 用户 ID | |
|
||||
| `product_id` | UUID | 商品 ID | |
|
||||
| `sku_id` | UUID | SKU ID | 可空(若商品无多规格) |
|
||||
| `quantity` | Integer |数量 | |
|
||||
| `selected` | Boolean | 是否勾选 | 购物车状态 |
|
||||
| `created_at` | Timestamp | 创建时间 | |
|
||||
|
||||
### 3.2 订单主表 (`ml_orders`)
|
||||
商家端处理订单的核心表。
|
||||
|
||||
| 字段名 | 类型 | 描述 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | UUID | 主键 | |
|
||||
| `order_no` | VARCHAR | 订单号 | 唯一业务单号 |
|
||||
| `user_id` | UUID | 用户 ID | |
|
||||
| `merchant_id` | UUID | 商家 ID | |
|
||||
| `product_amount` | NUMERIC | 商品金额 | 默认0 |
|
||||
| `discount_amount` | NUMERIC | 优惠金额 | |
|
||||
| `shipping_fee` | NUMERIC | 运费 | |
|
||||
| `total_amount` | NUMERIC | 订单总金额 | |
|
||||
| `paid_amount` | NUMERIC | 实付金额 | |
|
||||
| `shipping_address` | JSONB | 收货地址 | |
|
||||
| `order_status` | INTEGER | 订单状态 | 1:待付款, 2:待发货, 3:待收货, 4:已完成, 5:已取消, 0:退款中, -1:已取消 |
|
||||
| `payment_status` | INTEGER | 支付状态 | 默认1 |
|
||||
| `shipping_status` | INTEGER | 发货状态 | 默认1 |
|
||||
| `paid_at` | TIMESTAMP | 支付时间 | |
|
||||
| `shipped_at` | TIMESTAMP | 发货时间 | |
|
||||
| `delivered_at` | TIMESTAMP | 收货时间 | |
|
||||
| `completed_at` | TIMESTAMP | 完成时间 | |
|
||||
| `remark` | TEXT | 订单备注 | |
|
||||
| `merchant_memo` | TEXT | 商家备注 | |
|
||||
| `cancel_reason` | TEXT | 取消原因 | |
|
||||
| `created_at` | TIMESTAMP | 创建时间 | 默认now() |
|
||||
| `updated_at` | TIMESTAMP | 更新时间 | |
|
||||
| `cid` | INTEGER | 序号 | |
|
||||
| `payment_method` | VARCHAR | 支付方式 | |
|
||||
| `payment_time` | TIMESTAMP | 支付时间 | |
|
||||
| `shipping_company` | VARCHAR | 物流公司 | 商家端发货填写 |
|
||||
| `tracking_number` | VARCHAR | 物流单号 | 商家端发货填写 |
|
||||
|
||||
### 3.3 订单项表 (`ml_order_items`)
|
||||
|
||||
| 字段名 | 类型 | 描述 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | UUID | 主键 | |
|
||||
| `order_id` | UUID | 订单 ID | |
|
||||
| `product_id` | UUID | 商品 ID | |
|
||||
| `sku_id` | UUID | SKU ID | 可空 |
|
||||
| `product_name` | VARCHAR | 商品名称快照 | |
|
||||
| `sku_name` | VARCHAR | SKU名称 | 可空 |
|
||||
| `specifications` | JSONB | 规格信息 | 默认{} |
|
||||
| `image_url` | TEXT | 商品图片 | 可空 |
|
||||
| `price` | NUMERIC | 成交单价 | |
|
||||
| `quantity` | INTEGER | 购买数量 | |
|
||||
| `total_amount` | NUMERIC | 小计金额 | |
|
||||
| `created_at` | TIMESTAMP | 创建时间 | |
|
||||
| `sku_snapshot` | JSONB | SKU快照 | 默认{} |
|
||||
|
||||
### 3.4 订单详情视图 (`ml_orders_detail_view`)
|
||||
订单联合查询视图。
|
||||
|
||||
| 字段名 | 类型 | 描述 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | UUID | 主键 | |
|
||||
| `order_no` | VARCHAR | 订单号 | |
|
||||
| `user_id` | UUID | 用户 ID | |
|
||||
| `merchant_id` | UUID | 商家 ID | |
|
||||
| `product_amount` | NUMERIC | 商品金额 | |
|
||||
| `discount_amount` | NUMERIC | 优惠金额 | |
|
||||
| `shipping_fee` | NUMERIC | 运费 | |
|
||||
| `total_amount` | NUMERIC | 订单总金额 | |
|
||||
| `paid_amount` | NUMERIC | 实付金额 | |
|
||||
| `shipping_address` | JSONB | 收货地址 | |
|
||||
| `order_status` | INTEGER | 订单状态 | |
|
||||
| `payment_status` | INTEGER | 支付状态 | |
|
||||
| `shipping_status` | INTEGER | 发货状态 | |
|
||||
| `paid_at` | TIMESTAMP | 支付时间 | |
|
||||
| `shipped_at` | TIMESTAMP | 发货时间 | |
|
||||
| `delivered_at` | TIMESTAMP | 收货时间 | |
|
||||
| `completed_at` | TIMESTAMP | 完成时间 | |
|
||||
| `remark` | TEXT | 订单备注 | |
|
||||
| `merchant_memo` | TEXT | 商家备注 | |
|
||||
| `cancel_reason` | TEXT | 取消原因 | |
|
||||
| `created_at` | TIMESTAMP | 创建时间 | |
|
||||
| `updated_at` | TIMESTAMP | 更新时间 | |
|
||||
| `customer_name` | VARCHAR | 客户姓名 | |
|
||||
| `customer_phone` | TEXT | 客户电话 | |
|
||||
| `merchant_name` | VARCHAR | 商家名称 | |
|
||||
| `shop_name` | VARCHAR | 店铺名称 | |
|
||||
| `order_status_name` | TEXT | 订单状态名称 | |
|
||||
| `payment_status_name` | TEXT | 支付状态名称 | |
|
||||
|
||||
### 3.5 支付订单表 (`pay_order`)
|
||||
支付网关订单表。
|
||||
|
||||
| 字段名 | 类型 | 描述 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | BIGINT | 主键 | |
|
||||
| `merchant_id` | BIGINT | 商户ID | |
|
||||
| `app_id` | BIGINT | 应用ID | |
|
||||
| `channel_id` | BIGINT | 渠道ID | 可空 |
|
||||
| `channel_code` | VARCHAR | 渠道编码 | 可空 |
|
||||
| `merchant_order_id` | VARCHAR | 商户订单号 | |
|
||||
| `subject` | VARCHAR | 订单标题 | |
|
||||
| `body` | VARCHAR | 订单描述 | |
|
||||
| `notify_url` | VARCHAR | 通知地址 | |
|
||||
| `notify_status` | SMALLINT | 通知状态 | |
|
||||
| `amount` | BIGINT | 金额 | |
|
||||
| `channel_fee_rate` | DOUBLE | 渠道费率 | 可空 |
|
||||
| `channel_fee_amount` | BIGINT | 渠道手续费 | 可空 |
|
||||
| `status` | SMALLINT | 状态 | |
|
||||
| `user_ip` | VARCHAR | 用户IP | |
|
||||
| `expire_time` | TIMESTAMP | 过期时间 | |
|
||||
| `success_time` | TIMESTAMP | 成功时间 | 可空 |
|
||||
| `notify_time` | TIMESTAMP | 通知时间 | 可空 |
|
||||
| `success_extension_id` | BIGINT | 成功扩展ID | 可空 |
|
||||
| `refund_status` | SMALLINT | 退款状态 | |
|
||||
| `refund_times` | SMALLINT | 退款次数 | |
|
||||
| `refund_amount` | BIGINT | 退款金额 | |
|
||||
| `channel_user_id` | VARCHAR | 渠道用户ID | 可空 |
|
||||
| `channel_order_no` | VARCHAR | 渠道订单号 | 可空 |
|
||||
| `creator` | VARCHAR | 创建人 | 可空 |
|
||||
| `create_time` | TIMESTAMP | 创建时间 | |
|
||||
| `updater` | VARCHAR | 更新人 | 可空 |
|
||||
| `update_time` | TIMESTAMP | 更新时间 | |
|
||||
| `deleted` | SMALLINT | 删除标记 | 默认0 |
|
||||
| `tenant_id` | BIGINT | 租户ID | 默认0 |
|
||||
|
||||
### 3.6 支付订单扩展表 (`pay_order_extension`)
|
||||
支付订单扩展信息表。
|
||||
|
||||
| 字段名 | 类型 | 描述 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | BIGINT | 主键 | |
|
||||
| `no` | VARCHAR | 支付流水号 | |
|
||||
| `order_id` | BIGINT | 支付订单ID | |
|
||||
| `channel_id` | BIGINT | 渠道ID | |
|
||||
| `channel_code` | VARCHAR | 渠道编码 | |
|
||||
| `user_ip` | VARCHAR | 用户IP | |
|
||||
| `status` | SMALLINT | 状态 | |
|
||||
| `channel_extras` | VARCHAR | 渠道额外信息 | 可空 |
|
||||
| `channel_notify_data` | VARCHAR | 渠道通知数据 | 可空 |
|
||||
| `creator` | VARCHAR | 创建人 | 可空 |
|
||||
| `create_time` | TIMESTAMP | 创建时间 | |
|
||||
| `updater` | VARCHAR | 更新人 | 可空 |
|
||||
| `update_time` | TIMESTAMP | 更新时间 | |
|
||||
| `deleted` | SMALLINT | 删除标记 | 默认0 |
|
||||
| `tenant_id` | BIGINT | 租户ID | 默认0 |
|
||||
|
||||
### 3.7 库存订单表 (`stock_orders`)
|
||||
库存相关订单表。
|
||||
|
||||
| 字段名 | 类型 | 描述 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | UUID | 主键 | |
|
||||
| `simulation_id` | UUID | 模拟ID | 可空 |
|
||||
| `symbol` | TEXT | 交易标的 | |
|
||||
| `side` | TEXT | 交易方向 | |
|
||||
| `qty` | NUMERIC | 数量 | |
|
||||
| `price` | NUMERIC | 价格 | 可空 |
|
||||
| `status` | TEXT | 状态 | 默认created |
|
||||
| `placed_at` | TIMESTAMP | 下单时间 | |
|
||||
| `executed_at` | TIMESTAMP | 执行时间 | 可空 |
|
||||
| `fee` | NUMERIC | 手续费 | 默认0 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 用户相关 (User Relations)
|
||||
|
||||
### 4.1 用户地址 (`ml_user_addresses`)
|
||||
|
||||
| 字段名 | 类型 | 描述 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | UUID | 主键 | |
|
||||
| `user_id` | UUID | 用户 ID | |
|
||||
| `recipient_name` | Text | 收货人姓名 | |
|
||||
| `phone` | Text | 手机号 | |
|
||||
| `province` | Text | 省 | |
|
||||
| `city` | Text | 市 | |
|
||||
| `district` | Text | 区 | |
|
||||
| `detail_address` | Text | 详细地址 | |
|
||||
| `is_default` | Boolean | 是否默认地址 | |
|
||||
|
||||
### 4.2 聊天消息 (`ml_chat_messages`)
|
||||
用于客服系统。
|
||||
|
||||
| 字段名 | 类型 | 描述 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | UUID | 主键 | |
|
||||
| `session_id` | UUID | 会话 ID | 可选,或通过收发人聚合 |
|
||||
| `sender_id` | UUID | 发送者 ID | |
|
||||
| `receiver_id` | UUID | 接收者 ID | |
|
||||
| `content` | Text | 消息内容 | |
|
||||
| `msg_type` | Text | 消息类型 | 'text', 'image', 'product' |
|
||||
| `is_read` | Boolean | 是否已读 | |
|
||||
| `created_at` | Timestamp | 发送时间 | |
|
||||
|
||||
## 5. 对接建议 (Integration Tips for Merchant Frontend)
|
||||
|
||||
1. **商品管理**:
|
||||
* 在创建商品时,必须先选择 `category_id` 和 `merchant_id`。
|
||||
* 如果有 `specifications` (多规格),请同时向 `ml_product_skus` 插入数据。
|
||||
* 更新库存时,请优先更新 `ml_product_skus` 中的 `stock`,并同步总库存到 `ml_products`.`total_stock`。
|
||||
|
||||
2. **图片处理**:
|
||||
* 消费者端支持 `main_image_url` (字符串) 和 `image_urls` (JSON 数组) 两种格式,请确保都正确填充。
|
||||
|
||||
3. **状态管理**:
|
||||
* 上架商品请将 `status` 设为 `1`。
|
||||
* 如需在首页 "推荐/热销" 板块显示,请设置 `is_featured` 或 `is_hot` 为 `true`。
|
||||
|
||||
4. **Supabase 安全策略 (RLS)**:
|
||||
* 请确保商家端账号有权限写入 `ml_products` 和 `ml_shops` 表,但只能修改 `merchant_id` 等于自己账号的数据。
|
||||
@@ -1,62 +0,0 @@
|
||||
# 功能与页面状态详解
|
||||
|
||||
本文档详细记录了 Consumer App 各个功能模块的实现状态与业务逻辑。
|
||||
|
||||
> **状态图例**: ✅ 正常 (已对接真实 DB) | 🚧 开发中 | ❌ 未开始
|
||||
|
||||
## 🛍️ 核心购物流程
|
||||
|
||||
| 功能模块 | 页面路径 | 状态 | 备注 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **首页** | `pages/mall/consumer/index.uvue` | ✅ 正常 | 金刚区、Banner、推荐商品流。 |
|
||||
| **分类** | `pages/mall/consumer/category.uvue` | ✅ 正常 | 一级/二级分类联动,跳转搜索结果。 |
|
||||
| **商品详情** | `pages/mall/consumer/product-detail.uvue` | ✅ 正常 | SKU 选择、加入购物车、立即购买。 |
|
||||
| **购物车** | `pages/mall/consumer/cart.uvue` | ✅ 正常 | 数量增减、勾选计算、结算校验。 |
|
||||
| **结算页** | `pages/mall/consumer/checkout.uvue` | ✅ 正常 | 选择地址、运费计算、创建订单。 |
|
||||
| **收银台** | `pages/mall/consumer/payment.uvue` | ✅ 正常 | 模拟支付流程,更新订单为“待发货”。 |
|
||||
|
||||
## 👤 个人中心 (Profile)
|
||||
|
||||
**文件**: `pages/mall/consumer/profile.uvue`
|
||||
|
||||
* **资产看板**: 实时加载积分、余额、优惠券数量。
|
||||
* **订单看板**: 待支付、待发货、待收货、退款/售后(跳转至 `refund.uvue`)。
|
||||
* **服务矩阵**:
|
||||
* **地址管理**: `address-list.uvue` (CRUD 正常)
|
||||
* **我的收藏**: `favorites.uvue` (商品/店铺收藏 正常)
|
||||
* **浏览足迹**: `footprint.uvue` (按日期分组 正常)
|
||||
* **在线客服**: 跳转至 `chat.uvue`
|
||||
* **消息通知**: 跳转至 `messages.uvue`
|
||||
|
||||
## 📦 订单管理体系
|
||||
|
||||
**列表页**: `pages/mall/consumer/orders.uvue`
|
||||
* **状态筛选**: 全部 / 待支付 / 待发货 / 待收货 / 已完成。
|
||||
* **核心操作**:
|
||||
* **去支付**: 跳转收银台。
|
||||
* **确认收货**: 变更状态为已完成。
|
||||
* **申请售后**: 跳转 `apply-refund.uvue` (带入订单信息)。
|
||||
|
||||
**详情页**: `pages/mall/consumer/order-detail.uvue`
|
||||
* **信息展示**: 完整的地址、商品规格、金额明细、时间线。
|
||||
* **业务状态**: 根据 `order_status` 动态展示可操作按钮。
|
||||
|
||||
## 🔄 售后服务体系 (Refunds)
|
||||
|
||||
**申请页**: `pages/mall/consumer/apply-refund.uvue`
|
||||
* **功能**: 支持仅退款/退货退款。
|
||||
* **逻辑**: 自动获取订单最大可退金额,防止超额申请。
|
||||
* **提交**: 数据写入 `ml_refunds` 表。
|
||||
|
||||
**记录页**: `pages/mall/consumer/refund.uvue`
|
||||
* **列表**: 展示所有历史售后申请及其当前状态。
|
||||
* **进度**: 可视化展示审核进度 (目前模拟进度条)。
|
||||
|
||||
## 💬 社交与互动
|
||||
|
||||
| 功能模块 | 页面路径 | 状态 | 说明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **在线客服** | `pages/mall/consumer/chat.uvue` | ✅ 正常 | 支持文本/表情发送,历史记录持久化。 |
|
||||
| **消息中心** | `pages/mall/consumer/messages.uvue` | ✅ 正常 | 聚合系统通知订单消息与客服消息。 |
|
||||
| **商品评价** | `pages/mall/consumer/review.uvue` | ✅ 正常 | 支持星级评分与文本评价。 |
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# 测试数据生成指南 (Mock Data Guide)
|
||||
|
||||
为了有效测试消费者端前端功能,我们编写了 SQL 脚本来向数据库填充真实的模拟数据。
|
||||
|
||||
## 📂 脚本位置
|
||||
|
||||
所有脚本均位于 `doc_mall/consumer/sql/` 目录下。
|
||||
|
||||
## 🛠 使用说明
|
||||
|
||||
### 1. 修复现有数据问题 (优先级最高)
|
||||
**脚本**: `fix_order_items_data.sql`
|
||||
|
||||
* **适用场景**: 如果您的订单列表中,商品显示为空白图片或缺失名称。
|
||||
* **功能**:
|
||||
* 从 `ml_product_skus` 或 `ml_products` 表自动回填缺失的 `image_url`。
|
||||
* 修正占位符形式的 `product_name`。
|
||||
* 补充缺失的 `specifications` (如:规格参数)。
|
||||
|
||||
### 2. 生成新的测试订单
|
||||
**脚本**: `add_mock_orders_corrected.sql`
|
||||
|
||||
* **适用场景**: 为测试用户 (`test@mall.com`) 创建一批全新的订单数据。
|
||||
* **生成数据包含**:
|
||||
* 1x **待支付** 订单
|
||||
* 1x **待发货** 订单 (已支付)
|
||||
* 1x **待收货** 订单 (已发货)
|
||||
* 1x **已完成** 订单
|
||||
* **注意**: 该脚本会随机选取数据库中现有的真实商品,确保数据关联正确无误。
|
||||
|
||||
### 3. 生成评价测试数据
|
||||
**脚本**: `add_mock_reviews_for_test_user.sql`
|
||||
|
||||
* **适用场景**: 测试“我的评价”列表或商品详情页的评价展示。
|
||||
* **功能**: 创建已完成的订单,并自动为其添加一条带图片的 5 星好评。
|
||||
|
||||
## 🧪 建议测试流程
|
||||
|
||||
1. **运行** `add_mock_orders_corrected.sql`。
|
||||
2. **打开 App** > 个人中心 (Profile) > 我的订单。
|
||||
3. **验证**:
|
||||
* 各状态标签页下是否有对应的订单。
|
||||
* 商品图片和名称是否显示正常。
|
||||
* 在“待收货”或“已完成”订单上点击**申请售后**,验证是否跳转正确。
|
||||
@@ -1,85 +0,0 @@
|
||||
# 多端用户注册与身份识别实现指南 (Multi-Terminal Registration Guide)
|
||||
|
||||
本档说明了如何在当前的 Supabase 架构下实现“消费者端”、“商家端”及“管理端”的统一注册逻辑,并确保用户身份(Role)在入库时能够自动、准确地被识别。
|
||||
|
||||
## 1. 核心架构原理
|
||||
|
||||
系统采用 **“前端声明意图 + 后端自动触发”** 的模式:
|
||||
1. **前端 App**:在调用接口注册时,通过 `raw_user_meta_data` 声明用户的目标角色(如 `consumer` 或 `merchant`)。
|
||||
2. **Supabase Auth**:接收并存储这些元数据。
|
||||
3. **数据库触发器 (Trigger)**:在 `auth.users` 产生新记录的一瞬间,由数据库自动读取元数据,并将用户信息连带其正确的角色属性同步到业务表 `ak_users` 中。
|
||||
|
||||
---
|
||||
|
||||
## 2. 前端实现步骤 (代码参考)
|
||||
|
||||
在各端 App 的注册逻辑中,需在调用 `signUp` 接口时传递 `options.data`。
|
||||
|
||||
### 消费者端 (Consumer App)
|
||||
在 `pages/user/register.uvue` 中:
|
||||
```typescript
|
||||
const options = new UTSJSONObject()
|
||||
const metaData = new UTSJSONObject()
|
||||
metaData.set('user_role', 'consumer') // 核心:声明为消费者
|
||||
options.set('data', metaData)
|
||||
|
||||
const result = await supa.signUp(email, password, options)
|
||||
```
|
||||
|
||||
### 商家端 (Merchant App)
|
||||
在商家端的注册页面中,只需修改 `user_role` 的值:
|
||||
```typescript
|
||||
metaData.set('user_role', 'merchant') // 核心:声明为商家
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据库实现步骤 (SQL 设置)
|
||||
|
||||
为了让数据库能够“看碟下菜”,必须在 Supabase SQL Editor 中运行以下脚本,安装/更新智能触发器:
|
||||
|
||||
```sql
|
||||
-- 1. 创建或更新处理函数
|
||||
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
-- 向业务表插入数据,并智能识别角色
|
||||
INSERT INTO public.ak_users (id, auth_id, email, role, nickname, status)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
NEW.id, -- 统一使用 Auth ID
|
||||
NEW.email,
|
||||
-- 核心逻辑:读取 metadata 中的 user_role,如果没有传则默认为 'consumer'
|
||||
COALESCE(NEW.raw_user_meta_data->>'user_role', 'consumer'),
|
||||
-- 默认昵称取邮箱前缀
|
||||
split_part(NEW.email, '@', 1),
|
||||
1 -- 默认激活状态
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- 2. 绑定触发器
|
||||
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
|
||||
CREATE TRIGGER on_auth_user_created
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 三端互不干扰的优势
|
||||
|
||||
* **全自动入库**:一旦 SQL 触发器设置完成,前端不再需要手动调用 `ensureUserProfile` 或 `insert` 接口,减少了网络请求和前端报错几率。
|
||||
* **物理隔离与 RLS 安全**:通过 `ak_users` 表的 RLS 策略(`auth.uid() = id`),确保即使用户通过 API 尝试修改他人数据,也会被数据库直接拦截。
|
||||
* **统一维护**:所有端的注册逻辑在数据库层面是统一的,未来若需增加新角色(如 `admin_manager`),只需修改触发器逻辑即可,无需大规模重构代码。
|
||||
|
||||
---
|
||||
|
||||
## 5. 开发建议
|
||||
|
||||
* **强制校验**:在生产环境下,可以在触发器内增加校验逻辑,防止普通用户通过伪造元数据获得 `admin` 角色。
|
||||
* **日志排查**:如果新用户注册后 `ak_users` 表没有数据,请检查 Supabase 控制台的 `Database -> Logs`,查看触发器执行是否有报错(通常是唯一索引冲突导致)。
|
||||
|
||||
---
|
||||
*最后更新时间:2026-03-10*
|
||||
@@ -1,119 +0,0 @@
|
||||
# 电商平台生产环境上线准备清单 (Production Readiness Checklist)
|
||||
|
||||
本指南旨在指导开发者在将“消费者端”和“商家端”推向实际生产环境时,所需准备的各项核心服务申请、资质认定及技术对接点。
|
||||
|
||||
---
|
||||
|
||||
## 1. 身份认证与登录系统 (Identity & Login)
|
||||
|
||||
为了提供丝滑的登录体验,需申请以下第三方服务:
|
||||
|
||||
### A. 微信登录 (WeChat Login)
|
||||
* **主体要求**:必须是企业或个体工商户(个人主体无法开通部分权限)。
|
||||
* **准备工作**:
|
||||
* 在 [微信开放平台](https://open.weixin.qq.com/) 注册账号并完成 **开发者资质认证** (300元/年)。
|
||||
* 创建一个“移动应用”获取 `AppID` 和 `AppSecret`。
|
||||
* 在 [Supabase 控制台](https://supabase.com/dashboard/project/_/auth/providers) 启用 **WeChat Provider** 并填入上述秘钥。
|
||||
* **注意**:如果是微信小程序环境,需在 [微信公众平台](https://mp.weixin.qq.com/) 另外申请小程序账号。
|
||||
|
||||
### B. 手机短信验证码 (SMS Authentication)
|
||||
* **推荐方案**:阿里云短信、腾讯云短信 或 Twilio。
|
||||
* **准备工作**:
|
||||
* **签名申请**:如“【XX商城】”,需提交营业执照审核。
|
||||
* **模板申请**:如“验证码${code},您正在进行登录操作,5分钟内有效。”
|
||||
* **Supabase 对接**:在 Auth -> Providers -> Phone 开启,并配置短信服务商提供的 API 秘钥。
|
||||
|
||||
---
|
||||
|
||||
## 2. 支付与结算系统 (Payments & Settlement)
|
||||
|
||||
支付是电商的命脉,涉及非常严格的合规性审核。
|
||||
|
||||
### A. 消费者端:支付方式与钱包提醒
|
||||
* **资质准备**:
|
||||
* **微信支付**:在 [微信支付商户平台](https://pay.weixin.qq.com/) 申请“商户号”,需关联上述微信 AppID。
|
||||
* **支付宝**:在 [支付宝开放平台](https://open.alipay.com/) 申请“APP支付”或“小程序支付”能力。
|
||||
* **钱包逻辑实现**:
|
||||
* **余额系统**:在数据库中建立 `user_wallets` 表,记录 `balance`(余额)和 `points`(积分)。
|
||||
* **支付拦截器**:用户下单时,需通过后端逻辑(Edge Functions)校验余额是否足够,不足时弹出“余额不足,请充值”或“引导去微信支付”。
|
||||
* **变动提醒**:每当发生消费或退款,需通过 **微信模板消息** 或 **App 推送** 发送钱包变动实时通知。
|
||||
|
||||
### B. 商家端:收款账号与提现
|
||||
* **资金流控制**:生产环境下,资金通常先进入平台大账户(二清合规性建议)。
|
||||
* **提现准备**:
|
||||
* **实名认证**:商家入驻时必须上传 **身份证**、**营业执照**、**开户许可证**。
|
||||
* **打款接口**:需要申请“微信支付-企业付款到零钱”或“支付宝-单笔转账到账户”能力。
|
||||
* **手续费逻辑**:需确定平台抽成比例(佣金),在商家点击提现时自动计算并扣除。
|
||||
|
||||
---
|
||||
|
||||
## 3. 服务器与合规性 (Infrastructure & Compliance)
|
||||
|
||||
* **域名备案**:在中国境内运营,必须完成 **ICP 备案**。
|
||||
* **HTTPS 证书**:所有生产接口必须使用 SSL 证书(不能使用 HTTP)。
|
||||
* **隐私政策与用户协议**:必须在 App 登录页面显著位置提供,且符合 GDPR 或国内网络安全法规定。
|
||||
* **CDN 存储**:商品图片和视频应存放在对象存储(如 Supabase Storage + 自定义 CDN 域名),以保证加载速度。
|
||||
|
||||
---
|
||||
|
||||
## 4. 商家入驻所需材料模板
|
||||
|
||||
建议在商家端后台提供以下材料的上传入口:
|
||||
1. **负责人身份证正反面**。
|
||||
2. **统一社会信用代码证**。
|
||||
3. **银行结算账户信息(开户行、支行名称、账号、持卡人姓名)**。
|
||||
4. **行业特许经营许可**(如卖食品需要《食品经营许可证》)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术架构建议 (Production Stack)
|
||||
|
||||
* **数据库保护**:开启 Supabase 的 **RLS (行级安全策略)**,防止数据越权读取。
|
||||
* **日志记录**:接入 **Sentry** 或 **阿里云日志服务**,记录生产环境中的所有前端报错(尤其是支付环节)。
|
||||
* **压力测试**:上线前需进行接口压力测试,确保在促销活动期间数据库连接数不会爆满。
|
||||
|
||||
---
|
||||
|
||||
## 6. 无真机调试指南 (iOS & 鸿蒙调试方案)
|
||||
|
||||
针对您目前没有 iOS 和鸿蒙(HarmonyOS Next)实机的情况,请务必执行以下**模拟调试流程**,以确保上线后 90% 以上的兼容性。
|
||||
|
||||
### A. iOS (苹果) 远程与模拟调试
|
||||
1. **微信开发者工具“模拟器” (最简便)**:
|
||||
* 在工具底部左侧选择 `iPhone 13/14 Pro`。
|
||||
* **重点检查**: 底部 Tabbar 是否被 iPhone 的 Home Indicator (底部黑条) 遮挡。如果是,请在 CSS 中使用 `padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);`。
|
||||
2. **Chrome 浏览器 iOS 仿真 (针对 H5 端)**:
|
||||
* 将项目运行为 H5,开启 Chrome Console (F12)。
|
||||
* 选择 `iPhone` 设备仿真,手动测试 `v-if` 在不同屏幕高度下的布局抖动。
|
||||
3. **免费云真机调试 (推荐)**:
|
||||
* **腾讯云 WeTest / 阿里云移动测试**: 注册后通常有免费试用额度(如 30-60 分钟)。
|
||||
* **操作**: 上传打包好的 APK (Android) 或在微信内测环境下选择 iOS 云真机,远程控制真机屏幕进行滑屏测试。
|
||||
|
||||
### B. 鸿蒙 (HarmonyOS Next) 深度兼容
|
||||
由于鸿蒙系统现在进入了“纯血”阶段,与 Android 差异变大:
|
||||
1. **华为开发者联盟“云调试” (官方推荐)**:
|
||||
* **访问**: [华为远程真机调试](https://developer.huawei.com/consumer/cn/service/jsservicestu/appdebug.html)。
|
||||
* **步骤**:
|
||||
1. 注册并完成实名认证。
|
||||
2. 选择“远程真机”。
|
||||
3. 申请免费使用的 **Mate 60 / Pura 70 (HarmonyOS Next 预览版)**。
|
||||
* **调试重点**: 鸿蒙系统对 Webview 的内核限制较多,务必通过云真机测试“结算页”和“支付弹窗”是否能正常拉起。
|
||||
2. **DevEco Studio 远程模拟器**:
|
||||
* 安装华为官开发的 IDE `DevEco Studio`。
|
||||
* 使用内置的 `Device Manager` 启动 `Remote Emulator`。这比本地模拟器对电脑配置要求低,且渲染效果最接近真机。
|
||||
|
||||
### C. 关键代码兼容性“避坑”建议 (无真机必做)
|
||||
* **日期处理**: 严禁使用 `new Date("2024-03-10")`,在 iOS 上会返回 `Invalid Date`。**必须**统一替换为 `new Date("2024/03/10")` 或 `new Date(2024, 2, 10)`。
|
||||
* **图片显示**: iOS 微信小程序对 WebP 格式支持有限,生产环境生产建议优先使用 **JPG/PNG** 或确保 CDN 开启了自适应转换。
|
||||
* **输入框**: iOS 下 `input` 的 `focus` 自动拉起键盘可能会造成页面整体上移或错位,务必通过云真机确认“下单备注”等输入区域。
|
||||
|
||||
---
|
||||
## 7. 微信小程序上线必改项 (Final Checklist)
|
||||
|
||||
1. **正式 AppID**: 修改 `manifest.json` 中的 `mp-weixin.appid` 为正式 ID。
|
||||
2. **域名白名单**: 在微信后台添加 Supabase API 域名、图片 CDN 域名、物流查询域名。
|
||||
3. **用户隐私政策**: 获取头像、位置等接口前,必须在微信后台配置隐私协议,且在 App 内部提供可见入口。
|
||||
4. **打包优化**: 检查 `unpackage/dist/build/mp-weixin` 的大小,如超过 2MB,必须启用“分包加载”。
|
||||
|
||||
---
|
||||
*生成日期:2026-03-10*
|
||||
@@ -1,62 +0,0 @@
|
||||
# 商城消费者端 (Consumer) 开发文档
|
||||
|
||||
本文档包含了商城消费者前端模块的详细开发指南和状态说明。
|
||||
|
||||
## 📂 项目结构
|
||||
|
||||
消费者端模块位于 `pages/mall/consumer/` 目录下,基于 UniApp x (UTS/UVUE) 开发。
|
||||
|
||||
### 核心页面清单
|
||||
|
||||
| 页面文件 | 描述 | 当前状态 |
|
||||
|-----------|-------------|--------|
|
||||
| `index.uvue` | 首页 (商城门面) | ✅ 正常 |
|
||||
| `category.uvue` | 商品分类浏览 | ✅ 正常 |
|
||||
| `cart.uvue` | 购物车 | ✅ 正常 |
|
||||
| `profile.uvue` | 个人中心 (用户主页) | ✅ 正常 |
|
||||
| `orders.uvue` | 订单列表管理 | ✅ 正常 |
|
||||
| `order-detail.uvue` | 订单详情页 | ✅ 正常 |
|
||||
| `apply-refund.uvue` | 申请售后 (退款/退货) | ✅ 正常 |
|
||||
| `refund.uvue` | 售后记录列表 | ✅ 正常 |
|
||||
| `chat.uvue` | 在线客服 | ✅ 正常 |
|
||||
| `messages.uvue` | 消息通知中心 | ✅ 正常 |
|
||||
|
||||
### 关键功能实现
|
||||
|
||||
1. **用户个人中心**:
|
||||
* 可视化展示用户状态(积分、余额)。
|
||||
* 订单状态快捷入口(待支付、待发货、待收货等)。
|
||||
* **我的服务**: 优惠券、地址、收藏夹。
|
||||
* **新功能**: 已集成“评价”入口(跳转至待评价订单)和“退款/售后”入口(跳转至售后记录页)。
|
||||
|
||||
2. **订单管理**:
|
||||
* 多状态标签页切换(全部、待支付、待收货、已完成)。
|
||||
* **操作**: 支付、取消、提醒发货、确认收货、评价、**申请售后**。
|
||||
* 与 `supabaseService` 后端服务实时交互。
|
||||
|
||||
3. **售后系统**:
|
||||
* 独立页面 `apply-refund.uvue`:支持仅退款/退货退款,关联原订单金额。
|
||||
* 售后列表 `refund.uvue`:查看历史退款记录及进度。
|
||||
|
||||
4. **客服与消息**:
|
||||
* 在线客服 `chat.uvue`:支持文本/表情发送,消息持久化存储。
|
||||
* 消息中心 `messages.uvue`:聚合系统通知与客服消息。
|
||||
|
||||
## 🛠 技术栈
|
||||
|
||||
* **框架**: UniApp x (Vue 3 + UTS)
|
||||
* **后端**: Supabase (PostgreSQL)
|
||||
* **语言**: UTS (TypeScript 方言)
|
||||
* **样式**: SCSS / UVUE Styles
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
1. **环境准备**: 确保已安装 HBuilderX 并配置好 UniApp x 插件。
|
||||
2. **数据库**: 运行 `doc_mall/consumer/sql/` 下的 SQL 脚本初始测试数据。
|
||||
3. **运行**: 在 HBuilderX 中打开项目,运行到 Web 浏览器或 App 模拟器。
|
||||
|
||||
## 📚 文档索引
|
||||
|
||||
* [功能与页面状态详解](./FEATURES_&_PAGES.md)
|
||||
* [Supabase 集成与数据库架构](./SUPABASE_INTEGRATION.md)
|
||||
* [测试数据生成指南](./MOCK_DATA_GUIDE.md)
|
||||
@@ -1,235 +0,0 @@
|
||||
# 智能推荐系统文档
|
||||
|
||||
## 一、系统概述
|
||||
|
||||
智能推荐系统基于用户行为数据,为用户提供个性化的商品推荐。系统综合分析用户的搜索历史、浏览历史,结合全站热销商品数据,生成智能推荐列表。
|
||||
|
||||
## 二、推荐逻辑架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 智能推荐系统 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ 用户搜索历史 │ │ 用户浏览历史 │ │ 全站热销商品 │ │
|
||||
│ │ (权重高) │ │ (权重中) │ │ (权重低) │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ 关键词匹配 │ │ 分类匹配 │ │ 销量排序 │ │
|
||||
│ │ 相关商品 │ │ 相似商品 │ │ 热门商品 │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────────┼────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ 去重 & 合并结果 │ │
|
||||
│ └──────────┬──────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ 返回推荐商品列表 │ │
|
||||
│ └─────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 三、权重分配策略
|
||||
|
||||
### 3.1 数据源权重
|
||||
|
||||
| 数据源 | 权重 | 说明 |
|
||||
|--------|------|------|
|
||||
| 用户搜索历史 | 50% | 用户主动搜索的关键词最能反映购买意向 |
|
||||
| 用户浏览历史 | 30% | 用户浏览过的商品分类反映兴趣偏好 |
|
||||
| 热销商品 | 20% | 全站热销商品作为兜底和补充 |
|
||||
|
||||
### 3.2 推荐优先级
|
||||
|
||||
```
|
||||
优先级 1: 用户搜索历史匹配的商品(最多占推荐列表的50%)
|
||||
优先级 2: 用户浏览过的分类下的商品(最多占推荐列表的30%)
|
||||
优先级 3: 全站热销商品(填充剩余位置)
|
||||
```
|
||||
|
||||
## 四、核心算法详解
|
||||
|
||||
### 4.1 智能推荐主流程
|
||||
|
||||
```typescript
|
||||
async getSmartRecommendations(limit: number): Promise<Product[]> {
|
||||
const products: Product[] = []
|
||||
const addedIds = new Set<string>() // 用于去重
|
||||
|
||||
// 步骤1: 根据用户搜索历史推荐(权重最高)
|
||||
const searchHistory = await getUserSearchHistory(5)
|
||||
if (searchHistory.length > 0) {
|
||||
const keywordProducts = await searchProductsByKeywords(searchHistory, limit)
|
||||
// 添加到结果列表,去重
|
||||
}
|
||||
|
||||
// 步骤2: 根据用户浏览历史推荐(权重中)
|
||||
if (products.length < limit) {
|
||||
const browseCategories = await getUserBrowseCategories(3)
|
||||
if (browseCategories.length > 0) {
|
||||
const categoryProducts = await getProductsByCategories(browseCategories, limit - products.length)
|
||||
// 添加到结果列表,去重
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤3: 补充热销商品(权重低)
|
||||
if (products.length < limit) {
|
||||
const hotProducts = await getHotProducts(limit - products.length + 5)
|
||||
// 添加到结果列表,去重
|
||||
}
|
||||
|
||||
return products.slice(0, limit)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 搜索历史匹配算法
|
||||
|
||||
```typescript
|
||||
// 根据用户搜索关键词匹配商品
|
||||
async searchProductsByKeywords(keywords: string[], limit: number): Promise<Product[]> {
|
||||
// 1. 获取商品数据
|
||||
// 2. 遍历商品,检查名称和描述是否包含关键词
|
||||
// 3. 匹配成功的商品加入推荐列表
|
||||
|
||||
for (product in products) {
|
||||
for (keyword in keywords) {
|
||||
if (product.name.contains(keyword) || product.description.contains(keyword)) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 浏览历史分类匹配算法
|
||||
|
||||
```typescript
|
||||
// 根据用户浏览过的商品分类推荐
|
||||
async getProductsByCategories(categoryIds: string[], limit: number): Promise<Product[]> {
|
||||
// 1. 获取用户浏览过的商品分类ID列表
|
||||
// 2. 查询这些分类下的商品
|
||||
// 3. 按销量排序返回
|
||||
}
|
||||
```
|
||||
|
||||
## 五、热搜词系统
|
||||
|
||||
### 5.1 热搜词计算
|
||||
|
||||
```typescript
|
||||
async getHotKeywords(limit: number): Promise<string[]> {
|
||||
// 1. 获取最近100条搜索记录
|
||||
// 2. 统计每个关键词的出现频率
|
||||
// 3. 按频率降序排序
|
||||
// 4. 返回前N个高频关键词
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 热搜词展示规则
|
||||
|
||||
| 排名 | 样式 | 说明 |
|
||||
|------|------|------|
|
||||
| 1-3名 | 红色背景 | 热度最高,突出显示 |
|
||||
| 4-10名 | 灰色背景 | 普通热度 |
|
||||
|
||||
## 六、用户行为记录
|
||||
|
||||
### 6.1 搜索行为记录
|
||||
|
||||
```typescript
|
||||
// 在用户执行搜索时调用
|
||||
async recordSearch(keyword: string, resultCount: number): Promise<void> {
|
||||
// 记录字段:
|
||||
// - user_id: 用户ID(可选,支持匿名)
|
||||
// - keyword: 搜索关键词
|
||||
// - result_count: 搜索结果数量
|
||||
// - created_at: 搜索时间
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 浏览行为记录
|
||||
|
||||
```typescript
|
||||
// 在用户查看商品详情时调用
|
||||
async recordBrowse(productId: string, duration: number): Promise<void> {
|
||||
// 记录字段:
|
||||
// - user_id: 用户ID
|
||||
// - product_id: 商品ID
|
||||
// - browse_duration: 浏览时长(秒)
|
||||
// - created_at/updated_at: 时间戳
|
||||
}
|
||||
```
|
||||
|
||||
## 七、数据表结构
|
||||
|
||||
### 7.1 搜索历史表 (ml_search_history)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| user_id | UUID | 用户ID(可空) |
|
||||
| keyword | VARCHAR(200) | 搜索关键词 |
|
||||
| result_count | INTEGER | 搜索结果数量 |
|
||||
| created_at | TIMESTAMP | 搜索时间 |
|
||||
|
||||
### 7.2 浏览历史表 (ml_browse_history)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| user_id | UUID | 用户ID |
|
||||
| product_id | UUID | 商品ID |
|
||||
| browse_duration | INTEGER | 浏览时长(秒) |
|
||||
| created_at | TIMESTAMP | 首次浏览时间 |
|
||||
| updated_at | TIMESTAMP | 最近浏览时间 |
|
||||
|
||||
## 八、API接口列表
|
||||
|
||||
| API | 方法 | 说明 |
|
||||
|-----|------|------|
|
||||
| getSmartRecommendations | GET | 获取智能推荐商品 |
|
||||
| getHotKeywords | GET | 获取热搜词列表 |
|
||||
| getUserSearchHistory | GET | 获取用户搜索历史 |
|
||||
| getUserBrowseCategories | GET | 获取用户浏览分类 |
|
||||
| recordSearch | POST | 记录搜索行为 |
|
||||
| recordBrowse | POST | 记录浏览行为 |
|
||||
|
||||
## 九、性能优化建议
|
||||
|
||||
### 9.1 缓存策略
|
||||
|
||||
- 热搜词列表:缓存5分钟,减少数据库查询
|
||||
- 用户搜索历史:缓存10分钟
|
||||
- 推荐结果:缓存3分钟
|
||||
|
||||
### 9.2 数据量控制
|
||||
|
||||
- 搜索历史:每个用户最多保留100条
|
||||
- 浏览历史:每个用户最多保留50条
|
||||
- 热搜词统计:只统计最近30天的数据
|
||||
|
||||
## 十、扩展方向
|
||||
|
||||
### 10.1 短期优化
|
||||
|
||||
1. **时间衰减因子**:近期行为权重更高
|
||||
2. **购买行为加权**:已购买商品的相关商品权重提升
|
||||
3. **收藏行为加权**:收藏商品的相关商品权重提升
|
||||
|
||||
### 10.2 长期规划
|
||||
|
||||
1. **协同过滤**:基于相似用户的行为推荐
|
||||
2. **商品相似度**:基于商品属性计算相似度
|
||||
3. **机器学习**:使用推荐算法模型
|
||||
|
||||
---
|
||||
|
||||
*文档版本:1.0*
|
||||
*最后更新:2024年*
|
||||
@@ -1,121 +0,0 @@
|
||||
# Supabase 集成与数据库架构 (Consumer App)
|
||||
|
||||
本文档详细描述了消费者端 (Consumer App) 涉及的所有数据库集成点、核心表结构以及 `supabaseService.uts` 提供的 API 服务。
|
||||
|
||||
> **更新时间**: 2026-02-03
|
||||
> **状态**: 已完成核心业务闭环 (订单、支付、售后、客服、足迹等)
|
||||
|
||||
## 🗄️ 核心数据架构
|
||||
|
||||
消费者端业务依赖以下核心数据库表:
|
||||
|
||||
### 1. 交易与订单 (Orders & Transactions)
|
||||
|
||||
| 表名 | 描述 | 关键字段 |
|
||||
| :--- | :--- | :--- |
|
||||
| `ml_orders` | 订单主表 | `id`, `user_id`, `merchant_id`, `order_status` (1:待付, 2:待发, 3:待收, 4:完成, 5:取消), `total_amount` |
|
||||
| `ml_order_items` | 订单商品明细 | `order_id`, `product_id`, `image_url` (快照), `specifications` (快照) |
|
||||
| `ml_refunds` | **[新增]** 售后/退款申请 | `order_id`, `reason_category`, `refund_amount`, `status` (0:待审, 1:同意, 2:拒绝), `refund_type` (1:仅退款, 2:退货退款) |
|
||||
|
||||
### 2. 互动与消息 (Interaction & Communication)
|
||||
|
||||
| 表名 | 描述 | 关键字段 |
|
||||
| :--- | :--- | :--- |
|
||||
| `ml_chat_messages` | **[新增]** 客服聊天记录 | `session_id`, `sender_id`, `receiver_id`, `content`, `msg_type`, `is_from_user` |
|
||||
| `ml_notifications` | 消息通知 | `type` (system/order/promotion), `title`, `is_read` |
|
||||
| `ml_product_reviews` | 商品评价 | `order_id`, `product_id`, `rating`, `content`, `images` |
|
||||
|
||||
### 3. 用户行为 (User Behavior)
|
||||
|
||||
| 表名 | 描述 | 关键字段 |
|
||||
| :--- | :--- | :--- |
|
||||
| `ml_browsing_history` | 足迹/浏览记录 | `user_id`, `product_id`, `view_time` |
|
||||
| `ml_favorites` | 收藏夹 | `user_id`, `target_id`, `type` (1:商品, 2:店铺) |
|
||||
| `ml_user_addresses` | 收货地址 | `user_id`, `receiver_name`, `phone`, `province`... |
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API 服务层 (`utils/supabaseService.uts`)
|
||||
|
||||
所有后端交互通过单例 `supabaseService` 进行,主要模块如下:
|
||||
|
||||
### 1. 售后/退款服务 (Refunds)
|
||||
> **状态**: ✅ 已集成 (apply-refund.uvue)
|
||||
|
||||
```typescript
|
||||
// 创建退款/售后申请
|
||||
async createRefund(data: {
|
||||
order_id: string,
|
||||
refund_type: number,
|
||||
refund_amount: number,
|
||||
reason_category: string,
|
||||
description: string,
|
||||
images: string[]
|
||||
}): Promise<boolean>
|
||||
```
|
||||
|
||||
### 2. 在线客服/消息服务 (Chat & Messages)
|
||||
> **状态**: ✅ 已集成 (chat.uvue, messages.uvue)
|
||||
|
||||
```typescript
|
||||
// 获取当前用户的聊天记录
|
||||
async getUserChatMessages(): Promise<ChatMessage[]>
|
||||
|
||||
// 发送聊天消息 (持久化到 ml_chat_messages)
|
||||
async sendChatMessage(content: string, type: string = 'text'): Promise<boolean>
|
||||
|
||||
// (测试用) 模拟客服自动回复
|
||||
async simulateServiceReply(content: string): Promise<boolean>
|
||||
```
|
||||
|
||||
### 3. 订单与支付 (Orders & Payment)
|
||||
> **状态**: ✅ 已集成 (checkout.uvue, payment.uvue, orders.uvue)
|
||||
|
||||
```typescript
|
||||
// 创建订单 (由购物车或直接购买触发)
|
||||
async createOrder(orderData: any): Promise<string | null>
|
||||
|
||||
// 获取订单详情 (包含商品明细)
|
||||
async getOrderDetail(orderId: string): Promise<any | null>
|
||||
|
||||
// 支付订单 (模拟支付,更新订单状态 1->2,记录支付时间)
|
||||
async payOrder(orderId: string, paymentMethod: string, amount: number): Promise<boolean>
|
||||
|
||||
// 确认收货 (3->4)
|
||||
async confirmReceipt(orderId: string): Promise<Result>
|
||||
```
|
||||
|
||||
### 4. 商品与搜索 (Products)
|
||||
> **状态**: ✅ 已集成 (search.uvue, product-detail.uvue)
|
||||
|
||||
```typescript
|
||||
// 搜索商品 (支持关键词、分类、价格排序、销量排序)
|
||||
async searchProducts(keyword: string, page: number, pageSize: number, sort: string, asc: boolean): Promise<PaginatedResponse<Product>>
|
||||
|
||||
// 获取足迹
|
||||
async getFootprints(): Promise<any[]>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 页面集成状态一览表
|
||||
|
||||
| 页面模块 | 文件路径 | 数据源状态 | 说明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **首页** | `pages/mall/consumer/index.uvue` | ✅ Real DB | 金刚区、推荐商品已接入 |
|
||||
| **搜索** | `pages/mall/consumer/search.uvue` | ✅ Real DB | 关键词搜索、排序、分页正常 |
|
||||
| **购物车** | `pages/mall/consumer/cart.uvue` | ✅ Real DB | 加减购、结算校验正常 |
|
||||
| **结算台** | `pages/mall/consumer/checkout.uvue` | ✅ Real DB | 地址选择、订单创建正常 |
|
||||
| **收银台** | `pages/mall/consumer/payment.uvue` | ✅ Real DB | 读取待付金额,更新支付状态 |
|
||||
| **订单列表** | `pages/mall/consumer/orders.uvue` | ✅ Real DB | 状态筛选 (全部/待付/待收/退款) 正常 |
|
||||
| **订单详情** | `pages/mall/consumer/order-detail.uvue` | ✅ Real DB | 地址、商品、金额展示正常 |
|
||||
| **申请售后** | `pages/mall/consumer/apply-refund.uvue` | ✅ Real DB | **[本次完成]** 关联订单金额,提交至 `ml_refunds` |
|
||||
| **在线客服** | `pages/mall/consumer/chat.uvue` | ✅ Real DB | **[本次完成]** 消息收发持久化,支持历史记录 |
|
||||
| **消息中心** | `pages/mall/consumer/messages.uvue` | ✅ Real DB | 能够统计未读客服消息数 |
|
||||
| **我的评价** | `pages/mall/consumer/review.uvue` | ✅ Real DB | 提交评价至 `ml_product_reviews` |
|
||||
|
||||
## 🛠️ 下一步维护建议
|
||||
|
||||
1. **异常处理**: 目前部分接口在网络异常时仅打印 `console.error`,建议增加全局统一的 Toasts 提示。
|
||||
2. **图片上传**: 目前退款和评价中的图片上传依赖 Mock 或简单路径,需对接真实的 OSS/Supabase Storage 文件上传。
|
||||
3. **实时消息**: 目前 `chat.uvue` 使用 polling (轮询) 或手动刷新,Supabase 支持 Realtime Subscription,后续可升级为 WebSocket 实时推送。
|
||||
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
@@ -1,296 +0,0 @@
|
||||
# 商城推销模式功能需求文档
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
本文档描述商城消费者端的推销模式功能,**该功能为商家级别功能**,即:
|
||||
|
||||
- **商家可自主开启/关闭**自己店铺的推销模式
|
||||
- 只有**开启了推销模式的商家**,其店铺订单才会显示"分享免单"按钮
|
||||
- **会员等级系统**为全局功能,适用于所有商家
|
||||
- **经销点返利系统**为商家级别功能,由商家自行配置
|
||||
|
||||
### 1.1 核心变更说明
|
||||
|
||||
| 功能模块 | 作用范围 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 分享免单系统 | 商家级别 | 商家开启后,其订单才显示分享免单入口 |
|
||||
| 会员等级系统 | 全局 | 所有用户统一等级体系 |
|
||||
| 经销点返利系统 | 商家级别 | 商家自行创建和管理经销点 |
|
||||
| 用户余额系统 | 商家级别 | 每个商家有独立的用户余额池 |
|
||||
|
||||
---
|
||||
|
||||
## 二、商家推销模式配置
|
||||
|
||||
### 2.1 商家推销设置表 ml_merchant_promotion_config
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| merchant_id | UUID | 商家ID(关联ml_shops.merchant_id) |
|
||||
| promotion_enabled | BOOLEAN | 是否开启推销模式(默认false) |
|
||||
| share_free_enabled | BOOLEAN | 是否开启分享免单(默认false) |
|
||||
| distribution_enabled | BOOLEAN | 是否开启经销点返利(默认false) |
|
||||
| required_count | INT | 分享免单所需购买数(默认4) |
|
||||
| reward_type | VARCHAR(20) | 奖励类型:product_price-商品价格,fixed-固定金额 |
|
||||
| fixed_reward_amount | DECIMAL(10,2) | 固定奖励金额(reward_type=fixed时使用) |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
| updated_at | TIMESTAMPTZ | 更新时间 |
|
||||
|
||||
### 2.2 商家用户余额表 ml_merchant_user_balance
|
||||
|
||||
> 每个商家的用户余额独立计算,用户在不同商家有不同余额
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| merchant_id | UUID | 商家ID |
|
||||
| user_id | UUID | 用户ID |
|
||||
| balance | DECIMAL(10,2) | 当前余额 |
|
||||
| frozen_balance | DECIMAL(10,2) | 冻结余额 |
|
||||
| total_earned | DECIMAL(10,2) | 累计获得 |
|
||||
| total_withdrawn | DECIMAL(10,2) | 累计提现 |
|
||||
| updated_at | TIMESTAMPTZ | 更新时间 |
|
||||
| UNIQUE(merchant_id, user_id) | | 联合唯一约束 |
|
||||
|
||||
---
|
||||
|
||||
## 三、分享免单系统
|
||||
|
||||
### 3.1 功能描述
|
||||
|
||||
用户购买商品后,可分享商品链接给其他用户(二级用户)。当二级用户通过分享链接购买该商品累计达到指定数量时,原用户可获得免单奖励。
|
||||
|
||||
**重要变更:**
|
||||
- 只有开启了分享免单功能的商家,其订单才显示分享免单入口
|
||||
- 分享免单所需购买数由商家自行设置(默认4人)
|
||||
- 奖励金额可以是商品价格或固定金额
|
||||
|
||||
### 3.2 业务流程
|
||||
|
||||
```
|
||||
商家开启推销模式 → 用户购买商品 → 订单详情显示"分享免单"按钮
|
||||
↓
|
||||
用户点击创建分享记录
|
||||
↓
|
||||
分享给好友B/C/D
|
||||
↓
|
||||
好友通过分享码购买
|
||||
↓
|
||||
累计购买数量达标
|
||||
↓
|
||||
用户获得免单奖励
|
||||
↓
|
||||
奖励存入该商家下的用户余额
|
||||
```
|
||||
|
||||
### 3.3 数据库设计
|
||||
|
||||
#### 3.3.1 分享记录表 ml_share_records
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| merchant_id | UUID | 商家ID(新增) |
|
||||
| user_id | UUID | 分享用户ID |
|
||||
| product_id | UUID | 商品ID |
|
||||
| order_id | UUID | 关联订单ID |
|
||||
| share_code | VARCHAR(20) | 分享码(唯一) |
|
||||
| required_count | INT | 需要的购买数量 |
|
||||
| current_count | INT | 当前已购买数量 |
|
||||
| status | INT | 状态:0-进行中,1-已完成,2-已失效 |
|
||||
| reward_amount | DECIMAL(10,2) | 奖励金额 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
| completed_at | TIMESTAMPTZ | 完成时间 |
|
||||
|
||||
#### 3.3.2 二级购买记录表 ml_secondary_purchases
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| share_record_id | UUID | 关联分享记录ID |
|
||||
| buyer_id | UUID | 购买用户ID |
|
||||
| order_id | UUID | 订单ID |
|
||||
| quantity | INT | 购买数量 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
#### 3.3.3 免单奖励记录表 ml_free_order_rewards
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| merchant_id | UUID | 商家ID(新增) |
|
||||
| user_id | UUID | 获得奖励的用户ID |
|
||||
| share_record_id | UUID | 关联分享记录ID |
|
||||
| amount | DECIMAL(10,2) | 奖励金额 |
|
||||
| status | INT | 状态:0-待处理,1-已发放,2-已清零 |
|
||||
| cleared_at | TIMESTAMPTZ | 清零时间 |
|
||||
| cleared_by | UUID | 清零操作人 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
### 3.4 前端逻辑变更
|
||||
|
||||
#### 3.4.1 订单列表页/订单详情页
|
||||
|
||||
```typescript
|
||||
// 判断是否显示分享免单按钮
|
||||
async function checkShareFreeEnabled(merchantId: string): Promise<boolean> {
|
||||
const config = await supabaseService.getMerchantPromotionConfig(merchantId)
|
||||
return config?.promotion_enabled && config?.share_free_enabled
|
||||
}
|
||||
|
||||
// 在订单卡片中
|
||||
<view v-if="order.status >= 2 && order.status <= 4 && isShareFreeEnabled(order.merchant_id)"
|
||||
class="share-free-row" @click="shareForFree(order)">
|
||||
<text>🎁 分享免单</text>
|
||||
</view>
|
||||
```
|
||||
|
||||
#### 3.4.2 我的余额页
|
||||
|
||||
- 需要按商家分组显示余额
|
||||
- 或显示总余额,点击查看各商家余额明细
|
||||
|
||||
---
|
||||
|
||||
## 四、会员等级系统
|
||||
|
||||
### 4.1 功能描述
|
||||
|
||||
会员等级系统为**全局功能**,用户等级在所有商家通用。用户可通过累计消费金额自动升级,或由商家手动设置等级。
|
||||
|
||||
### 4.2 等级设置(全局配置)
|
||||
|
||||
| 等级 | 名称 | 升级条件 | 折扣 |
|
||||
|------|------|---------|------|
|
||||
| 0 | 普通会员 | 注册即可 | 无折扣 |
|
||||
| 1 | 铜牌会员 | 累计消费500元 | 98折 |
|
||||
| 2 | 银牌会员 | 累计消费2000元 | 95折 |
|
||||
| 3 | 金牌会员 | 累计消费5000元 | 92折 |
|
||||
| 4 | 钻石会员 | 累计消费10000元 | 88折 |
|
||||
| 5 | VIP会员 | 商家手动设置 | 85折 |
|
||||
|
||||
### 4.3 数据库设计
|
||||
|
||||
保持原有设计不变,会员等级为全局配置。
|
||||
|
||||
---
|
||||
|
||||
## 五、经销点返利系统
|
||||
|
||||
### 5.1 功能描述
|
||||
|
||||
商家可创建多个经销点,每个经销点有独立的推广码。经销点通过推广码产生的订单可获得返利。
|
||||
|
||||
**重要变更:**
|
||||
- 经销点返利为商家级别功能
|
||||
- 商家需先开启经销点功能才能使用
|
||||
|
||||
### 5.2 数据库设计
|
||||
|
||||
#### 5.2.1 经销点表 ml_distribution_points
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| merchant_id | UUID | 商家ID(新增) |
|
||||
| name | VARCHAR(100) | 经销点名称 |
|
||||
| contact_name | VARCHAR(50) | 联系人 |
|
||||
| contact_phone | VARCHAR(20) | 联系电话 |
|
||||
| invite_code | VARCHAR(20) | 邀请码(唯一) |
|
||||
| owner_id | UUID | 负责人用户ID |
|
||||
| status | INT | 状态:0-禁用,1-启用 |
|
||||
| total_orders | INT | 累计订单数 |
|
||||
| total_rebate | DECIMAL(10,2) | 累计返利 |
|
||||
| balance | DECIMAL(10,2) | 可提现余额 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
---
|
||||
|
||||
## 六、余额变动记录表
|
||||
|
||||
### 6.1 ml_balance_records(按商家区分)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| merchant_id | UUID | 商家ID(新增) |
|
||||
| user_id | UUID | 用户ID |
|
||||
| type | VARCHAR(50) | 类型 |
|
||||
| amount | DECIMAL(10,2) | 变动金额 |
|
||||
| balance_before | DECIMAL(10,2) | 变动前余额 |
|
||||
| balance_after | DECIMAL(10,2) | 变动后余额 |
|
||||
| related_id | UUID | 关联ID |
|
||||
| description | VARCHAR(200) | 描述 |
|
||||
| operator_id | UUID | 操作人 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
---
|
||||
|
||||
## 七、API 接口变更
|
||||
|
||||
### 7.1 新增接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| /api/merchant/promotion-config | GET | 获取商家推销配置 |
|
||||
| /api/merchant/user-balance | GET | 获取用户在某商家的余额 |
|
||||
| /api/merchant/user-balance-list | GET | 获取用户所有商家余额列表 |
|
||||
|
||||
### 7.2 修改接口
|
||||
|
||||
| 接口 | 变更说明 |
|
||||
|------|---------|
|
||||
| /api/share/create | 新增merchant_id参数 |
|
||||
| /api/share/my-records | 按merchant_id筛选 |
|
||||
| /api/balance/info | 新增merchant_id参数 |
|
||||
|
||||
---
|
||||
|
||||
## 八、前端页面变更
|
||||
|
||||
### 8.1 消费者端
|
||||
|
||||
| 页面 | 变更说明 |
|
||||
|------|---------|
|
||||
| 订单列表/详情 | 根据商家配置显示分享免单按钮 |
|
||||
| 我的余额 | 按商家分组显示余额,或显示余额列表 |
|
||||
| 我的分享 | 按商家筛选分享记录 |
|
||||
|
||||
### 8.2 商家端(新增)
|
||||
|
||||
| 页面 | 说明 |
|
||||
|------|------|
|
||||
| 推销模式设置 | 开启/关闭分享免单、经销点功能 |
|
||||
| 用户余额管理 | 查看用户余额、清零操作 |
|
||||
| 经销点管理 | 创建、编辑经销点 |
|
||||
|
||||
---
|
||||
|
||||
## 九、开发优先级
|
||||
|
||||
### 第一阶段
|
||||
1. 商家推销配置表(SQL)
|
||||
2. 修改用户余额表结构(按商家区分)
|
||||
3. 修改分享记录表结构(添加merchant_id)
|
||||
4. 前端:根据商家配置显示分享免单按钮
|
||||
|
||||
### 第二阶段
|
||||
1. 商家端推销模式设置页面
|
||||
2. 用户余额按商家分组显示
|
||||
3. 经销点返利系统
|
||||
|
||||
---
|
||||
|
||||
## 十、SQL变更清单
|
||||
|
||||
需要执行以下SQL变更:
|
||||
|
||||
1. **新增商家推销配置表** `ml_merchant_promotion_config`
|
||||
2. **修改用户余额表** 添加merchant_id字段,改为联合唯一约束
|
||||
3. **修改分享记录表** 添加merchant_id字段
|
||||
4. **修改余额记录表** 添加merchant_id字段
|
||||
5. **修改免单奖励表** 添加merchant_id字段
|
||||
6. **修改经销点表** 添加merchant_id字段
|
||||
|
||||
详细SQL语句见:`promotion_system_tables_v2.sql`
|
||||
@@ -1,629 +0,0 @@
|
||||
# 商城消费者端 - 积分与评价功能完善需求文档
|
||||
|
||||
## 一、项目概述
|
||||
|
||||
### 1.1 项目背景
|
||||
本项目为商城消费者端应用,当前积分和评价功能已有基础实现,但存在部分功能缺失和体验优化空间。本文档旨在明确积分和评价功能的完善需求。
|
||||
|
||||
### 1.2 当前实现状态
|
||||
|
||||
| 功能模块 | 当前状态 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 消费者端积分页面 | ✅ 已完成 | 显示积分余额、积分明细列表 |
|
||||
| 积分兑换商城 | ❌ 未实现 | 点击"积分兑换"提示"开发中" |
|
||||
| 消费者端商品评价 | ✅ 已完成 | 支持评分、文字、图片、匿名评价 |
|
||||
| 消费者端店铺评价 | ✅ 已完成 | 支持描述/物流/服务三维评分 |
|
||||
| 商家端评价管理 | ✅ 已完成 | 支持查看、筛选、回复评价 |
|
||||
|
||||
---
|
||||
|
||||
## 二、积分功能完善需求
|
||||
|
||||
### 2.1 功能架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 积分系统架构 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ 积分获取 │ │ 积分使用 │ │ 积分管理 │ │
|
||||
│ ├─────────────┤ ├─────────────┤ ├─────────────┤ │
|
||||
│ │ • 注册赠送 │ │ • 积分兑换 │ │ • 积分查询 │ │
|
||||
│ │ • 每日签到 │ │ • 订单抵扣 │ │ • 明细记录 │ │
|
||||
│ │ • 购物奖励 │ │ • 积分抽奖 │ │ • 过期处理 │ │
|
||||
│ │ • 评价奖励 │ │ │ │ • 积分规则 │ │
|
||||
│ │ • 邀请好友 │ │ │ │ │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 前端功能需求
|
||||
|
||||
#### 2.2.1 积分首页优化
|
||||
|
||||
**页面路径**: `pages/mall/consumer/points/index.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **积分概览卡片**
|
||||
- 显示当前可用积分
|
||||
- 显示即将过期积分(30天内)
|
||||
- 显示历史累计积分
|
||||
- 积分趋势图表(近7天/30天)
|
||||
|
||||
2. **快捷入口**
|
||||
- 签到入口(带签到状态提示)
|
||||
- 积分兑换入口
|
||||
- 积分规则说明
|
||||
|
||||
3. **积分明细列表**
|
||||
- 支持按类型筛选(获取/消费/过期)
|
||||
- 支持按时间范围筛选
|
||||
- 下拉刷新、上拉加载更多
|
||||
|
||||
#### 2.2.2 每日签到功能(新增)
|
||||
|
||||
**页面路径**: `pages/mall/consumer/points/signin.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **签到日历**
|
||||
- 显示当月签到记录
|
||||
- 连续签到天数统计
|
||||
- 签到奖励预览
|
||||
|
||||
2. **签到奖励规则**
|
||||
- 每日签到:+5积分
|
||||
- 连续签到7天:额外+20积分
|
||||
- 连续签到30天:额外+100积分
|
||||
|
||||
3. **签到弹窗**
|
||||
- 签到成功动画
|
||||
- 显示获得积分
|
||||
- 连续签到进度提示
|
||||
|
||||
#### 2.2.3 积分兑换商城(新增)
|
||||
|
||||
**页面路径**: `pages/mall/consumer/points/exchange.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **兑换商品列表**
|
||||
- 优惠券兑换
|
||||
- 实物商品兑换
|
||||
- 虚拟商品兑换(会员权益等)
|
||||
|
||||
2. **商品详情**
|
||||
- 商品图片、名称、描述
|
||||
- 兑换所需积分
|
||||
- 库存状态
|
||||
- 兑换记录
|
||||
|
||||
3. **兑换流程**
|
||||
- 积分不足提示
|
||||
- 确认兑换弹窗
|
||||
- 兑换成功/失败反馈
|
||||
- 物流跟踪(实物商品)
|
||||
|
||||
#### 2.2.4 积分规则页面(新增)
|
||||
|
||||
**页面路径**: `pages/mall/consumer/points/rules.uvue`
|
||||
|
||||
**需求描述**:
|
||||
- 积分获取规则说明
|
||||
- 积分使用规则说明
|
||||
- 积分有效期说明
|
||||
- 常见问题FAQ
|
||||
|
||||
### 2.3 后端API需求
|
||||
|
||||
#### 2.3.1 新增API接口
|
||||
|
||||
| 接口名称 | 请求方法 | 接口路径 | 说明 |
|
||||
|---------|---------|---------|------|
|
||||
| 签到 | POST | `/api/points/signin` | 用户每日签到 |
|
||||
| 获取签到记录 | GET | `/api/points/signin-records` | 获取月度签到记录 |
|
||||
| 获取兑换商品列表 | GET | `/api/points/exchange-products` | 获取可兑换商品 |
|
||||
| 积分兑换 | POST | `/api/points/exchange` | 兑换商品 |
|
||||
| 获取兑换记录 | GET | `/api/points/exchange-records` | 获取兑换历史 |
|
||||
| 积分过期提醒 | GET | `/api/points/expiring` | 获取即将过期积分 |
|
||||
|
||||
#### 2.3.2 接口详细设计
|
||||
|
||||
**签到接口**
|
||||
```typescript
|
||||
// 请求
|
||||
POST /api/points/signin
|
||||
Response: {
|
||||
success: boolean
|
||||
points: number // 本次获得积分
|
||||
continuous_days: number // 连续签到天数
|
||||
bonus_points: number // 额外奖励积分
|
||||
total_points: number // 当前总积分
|
||||
}
|
||||
```
|
||||
|
||||
**积分兑换接口**
|
||||
```typescript
|
||||
// 请求
|
||||
POST /api/points/exchange
|
||||
Body: {
|
||||
product_id: string // 兑换商品ID
|
||||
quantity: number // 兑换数量
|
||||
address_id?: string // 收货地址(实物商品)
|
||||
}
|
||||
Response: {
|
||||
success: boolean
|
||||
exchange_id: string // 兑换记录ID
|
||||
points_used: number // 消耗积分
|
||||
remaining_points: number // 剩余积分
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 数据库设计
|
||||
|
||||
#### 2.4.1 现有表结构
|
||||
|
||||
**用户积分表 (ml_user_points)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_user_points (
|
||||
user_id UUID NOT NULL PRIMARY KEY REFERENCES auth.users(id),
|
||||
points INT DEFAULT 0 NOT NULL, -- 当前可用积分
|
||||
total_earned INT DEFAULT 0, -- 历史累计获得积分
|
||||
total_used INT DEFAULT 0, -- 历史累计使用积分
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 添加索引
|
||||
CREATE INDEX idx_user_points_user_id ON ml_user_points(user_id);
|
||||
```
|
||||
|
||||
**积分记录表 (ml_point_records)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_point_records (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
points INT NOT NULL, -- 变动积分 (正/负)
|
||||
balance INT NOT NULL, -- 变动后余额
|
||||
type VARCHAR(50) NOT NULL, -- 类型
|
||||
reference_id UUID, -- 关联ID(订单ID/兑换ID等)
|
||||
description TEXT,
|
||||
expires_at TIMESTAMPTZ, -- 过期时间
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 添加索引
|
||||
CREATE INDEX idx_point_records_user_id ON ml_point_records(user_id);
|
||||
CREATE INDEX idx_point_records_type ON ml_point_records(type);
|
||||
CREATE INDEX idx_point_records_created_at ON ml_point_records(created_at);
|
||||
```
|
||||
|
||||
#### 2.4.2 新增表结构
|
||||
|
||||
**签到记录表 (ml_signin_records)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_signin_records (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
signin_date DATE NOT NULL, -- 签到日期
|
||||
points_earned INT DEFAULT 0, -- 获得积分
|
||||
bonus_points INT DEFAULT 0, -- 奖励积分
|
||||
continuous_days INT DEFAULT 1, -- 当次连续签到天数
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(user_id, signin_date) -- 每用户每天只能签到一次
|
||||
);
|
||||
|
||||
-- 添加索引
|
||||
CREATE INDEX idx_signin_records_user_id ON ml_signin_records(user_id);
|
||||
CREATE INDEX idx_signin_records_date ON ml_signin_records(signin_date);
|
||||
```
|
||||
|
||||
**积分兑换商品表 (ml_point_products)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_point_products (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL, -- 商品名称
|
||||
description TEXT, -- 商品描述
|
||||
image_url VARCHAR(500), -- 商品图片
|
||||
product_type VARCHAR(50) NOT NULL, -- 类型: coupon/physical/virtual
|
||||
points_required INT NOT NULL, -- 所需积分
|
||||
original_price DECIMAL(10,2), -- 原价(展示用)
|
||||
stock INT DEFAULT 0, -- 库存
|
||||
status INT DEFAULT 1, -- 状态: 0=下架, 1=上架
|
||||
sort_order INT DEFAULT 0, -- 排序
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**积分兑换记录表 (ml_point_exchanges)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_point_exchanges (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
product_id UUID NOT NULL REFERENCES ml_point_products(id),
|
||||
quantity INT DEFAULT 1, -- 兑换数量
|
||||
points_used INT NOT NULL, -- 消耗积分
|
||||
status INT DEFAULT 0, -- 状态: 0=待处理, 1=已发货, 2=已完成, 3=已取消
|
||||
tracking_no VARCHAR(100), -- 物流单号
|
||||
address_id UUID, -- 收货地址
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**积分规则配置表 (ml_point_rules)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_point_rules (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
rule_type VARCHAR(50) NOT NULL, -- 规则类型
|
||||
rule_name VARCHAR(100) NOT NULL, -- 规则名称
|
||||
points INT NOT NULL, -- 积分值
|
||||
description TEXT, -- 规则说明
|
||||
config JSONB, -- 扩展配置
|
||||
status INT DEFAULT 1, -- 状态
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 初始化规则数据
|
||||
INSERT INTO ml_point_rules (rule_type, rule_name, points, description) VALUES
|
||||
('register', '注册赠送', 100, '新用户注册赠送积分'),
|
||||
('signin_daily', '每日签到', 5, '每日签到获得积分'),
|
||||
('signin_continuous_7', '连续签到7天奖励', 20, '连续签到7天额外奖励'),
|
||||
('signin_continuous_30', '连续签到30天奖励', 100, '连续签到30天额外奖励'),
|
||||
('shopping', '购物奖励', 1, '每消费1元获得1积分'),
|
||||
('review', '评价奖励', 10, '完成商品评价获得积分'),
|
||||
('review_with_image', '带图评价奖励', 20, '带图评价额外奖励');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、评价功能完善需求
|
||||
|
||||
### 3.1 功能架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 评价系统架构 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ 评价提交 │ │ 评价展示 │ │ 评价管理 │ │
|
||||
│ ├─────────────┤ ├─────────────┤ ├─────────────┤ │
|
||||
│ │ • 商品评价 │ │ • 商品详情 │ │ • 商家回复 │ │
|
||||
│ │ • 店铺评价 │ │ • 评价列表 │ │ • 评价统计 │ │
|
||||
│ │ • 配送评价 │ │ • 评分统计 │ │ • 违规处理 │ │
|
||||
│ │ • 追加评价 │ │ • 筛选排序 │ │ • 评价审核 │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 前端功能需求
|
||||
|
||||
#### 3.2.1 评价页面优化
|
||||
|
||||
**页面路径**: `pages/mall/consumer/review.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **评价表单优化**
|
||||
- 支持视频上传(限1个,30秒内)
|
||||
- 支持图片拖拽排序
|
||||
- 添加评价标签选择(质量好/物流快/服务好等)
|
||||
- 添加商品满意度维度(质量/包装/性价比)
|
||||
|
||||
2. **追加评价功能**
|
||||
- 订单完成后7天内可追加评价
|
||||
- 追加评价入口(订单详情页)
|
||||
- 追加评价表单
|
||||
|
||||
3. **评价预览**
|
||||
- 提交前预览评价效果
|
||||
- 匿名评价预览效果
|
||||
|
||||
#### 3.2.2 商品评价列表(新增)
|
||||
|
||||
**页面路径**: `pages/mall/consumer/product-reviews.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **评价统计概览**
|
||||
- 好评率百分比
|
||||
- 评分分布图(1-5星)
|
||||
- 标签云(高频评价关键词)
|
||||
|
||||
2. **评价列表**
|
||||
- 支持按评分筛选
|
||||
- 支持按时间/点赞数排序
|
||||
- 支持只看有图/有视频评价
|
||||
- 评价点赞功能
|
||||
- 评价举报功能
|
||||
|
||||
3. **评价详情**
|
||||
- 点击评价查看详情
|
||||
- 查看商家回复
|
||||
- 查看追加评价
|
||||
|
||||
#### 3.2.3 我的评价页面(新增)
|
||||
|
||||
**页面路径**: `pages/mall/consumer/my-reviews.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **评价列表**
|
||||
- 显示已评价商品
|
||||
- 显示待评价商品
|
||||
- 显示可追加评价商品
|
||||
|
||||
2. **评价操作**
|
||||
- 编辑评价(24小时内)
|
||||
- 删除评价
|
||||
- 追加评价
|
||||
|
||||
#### 3.2.4 配送员评价(完善)
|
||||
|
||||
**页面路径**: `pages/mall/delivery/ratings.uvue`
|
||||
|
||||
**需求描述**:
|
||||
- 对接真实API(当前使用Mock数据)
|
||||
- 配送员评分统计
|
||||
- 配送评价列表
|
||||
|
||||
### 3.3 后端API需求
|
||||
|
||||
#### 3.3.1 新增API接口
|
||||
|
||||
| 接口名称 | 请求方法 | 接口路径 | 说明 |
|
||||
|---------|---------|---------|------|
|
||||
| 获取商品评价列表 | GET | `/api/reviews/product/:productId` | 分页获取商品评价 |
|
||||
| 获取评价统计 | GET | `/api/reviews/stats/:productId` | 获取商品评分统计 |
|
||||
| 追加评价 | POST | `/api/reviews/append` | 追加评价内容 |
|
||||
| 评价点赞 | POST | `/api/reviews/like/:id` | 点赞评价 |
|
||||
| 获取我的评价 | GET | `/api/reviews/my` | 获取用户评价列表 |
|
||||
| 编辑评价 | PUT | `/api/reviews/:id` | 编辑评价(24小时内) |
|
||||
| 删除评价 | DELETE | `/api/reviews/:id` | 删除评价 |
|
||||
| 评价举报 | POST | `/api/reviews/report/:id` | 举报违规评价 |
|
||||
|
||||
#### 3.3.2 接口详细设计
|
||||
|
||||
**获取商品评价列表**
|
||||
```typescript
|
||||
GET /api/reviews/product/:productId?page=1&limit=10&rating=5&has_image=true
|
||||
Response: {
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
data: [{
|
||||
id: string
|
||||
user_name: string
|
||||
user_avatar: string
|
||||
rating: number
|
||||
content: string
|
||||
images: string[]
|
||||
videos: string[]
|
||||
tags: string[]
|
||||
like_count: number
|
||||
is_liked: boolean
|
||||
reply: {
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
append_content?: {
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
created_at: string
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**获取评价统计**
|
||||
```typescript
|
||||
GET /api/reviews/stats/:productId
|
||||
Response: {
|
||||
total_count: number
|
||||
avg_rating: number
|
||||
good_rate: number // 好评率
|
||||
rating_distribution: { // 评分分布
|
||||
1: number
|
||||
2: number
|
||||
3: number
|
||||
4: number
|
||||
5: number
|
||||
}
|
||||
tags: [{ // 高频标签
|
||||
name: string
|
||||
count: number
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 数据库设计
|
||||
|
||||
#### 3.4.1 现有表结构优化
|
||||
|
||||
**商品评价表 (ml_product_reviews)**
|
||||
```sql
|
||||
-- 添加新字段
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS videos JSONB DEFAULT '[]';
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS tags JSONB DEFAULT '[]';
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS like_count INT DEFAULT 0;
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS is_edited BOOLEAN DEFAULT false;
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS append_content TEXT;
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS append_at TIMESTAMPTZ;
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS append_images JSONB DEFAULT '[]';
|
||||
```
|
||||
|
||||
#### 3.4.2 新增表结构
|
||||
|
||||
**评价点赞表 (ml_review_likes)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_review_likes (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
review_id UUID NOT NULL REFERENCES ml_product_reviews(id),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(review_id, user_id) -- 每用户对每条评价只能点赞一次
|
||||
);
|
||||
|
||||
CREATE INDEX idx_review_likes_review_id ON ml_review_likes(review_id);
|
||||
```
|
||||
|
||||
**评价举报表 (ml_review_reports)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_review_reports (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
review_id UUID NOT NULL REFERENCES ml_product_reviews(id),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
reason VARCHAR(200) NOT NULL, -- 举报原因
|
||||
description TEXT, -- 详细说明
|
||||
status INT DEFAULT 0, -- 状态: 0=待处理, 1=已处理, 2=已驳回
|
||||
handle_result TEXT, -- 处理结果
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**配送员评价表 (ml_delivery_ratings)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_delivery_ratings (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
order_id UUID NOT NULL REFERENCES ml_orders(id),
|
||||
delivery_user_id UUID NOT NULL, -- 配送员ID
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
rating INT CHECK (rating >= 1 AND rating <= 5),
|
||||
content TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(order_id) -- 每订单只能评价一次
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、开发优先级
|
||||
|
||||
### 4.1 第一阶段(高优先级)
|
||||
|
||||
| 序号 | 功能 | 模块 | 预估工时 |
|
||||
|-----|------|------|---------|
|
||||
| 1 | 每日签到功能 | 积分 | 2天 |
|
||||
| 2 | 积分兑换商城 | 积分 | 3天 |
|
||||
| 3 | 商品评价列表展示 | 评价 | 2天 |
|
||||
| 4 | 我的评价页面 | 评价 | 1天 |
|
||||
|
||||
### 4.2 第二阶段(中优先级)
|
||||
|
||||
| 序号 | 功能 | 模块 | 预估工时 |
|
||||
|-----|------|------|---------|
|
||||
| 1 | 积分规则配置 | 积分 | 1天 |
|
||||
| 2 | 追加评价功能 | 评价 | 1天 |
|
||||
| 3 | 评价点赞功能 | 评价 | 0.5天 |
|
||||
| 4 | 配送员评价对接 | 评价 | 1天 |
|
||||
|
||||
### 4.3 第三阶段(低优先级)
|
||||
|
||||
| 序号 | 功能 | 模块 | 预估工时 |
|
||||
|-----|------|------|---------|
|
||||
| 1 | 积分过期提醒 | 积分 | 0.5天 |
|
||||
| 2 | 评价视频上传 | 评价 | 1天 |
|
||||
| 3 | 评价举报功能 | 评价 | 0.5天 |
|
||||
| 4 | 管理端完善 | 综合 | 2天 |
|
||||
|
||||
---
|
||||
|
||||
## 五、技术要点
|
||||
|
||||
### 5.1 积分系统技术要点
|
||||
|
||||
1. **积分并发安全**
|
||||
- 使用数据库事务保证积分变动原子性
|
||||
- 添加乐观锁防止超扣
|
||||
|
||||
2. **积分过期处理**
|
||||
- 定时任务每日检查过期积分
|
||||
- 过期前7天推送提醒通知
|
||||
|
||||
3. **签到防刷**
|
||||
- 限制每日只能签到一次
|
||||
- 记录签到IP,检测异常行为
|
||||
|
||||
### 5.2 评价系统技术要点
|
||||
|
||||
1. **评价数据统计**
|
||||
- 使用触发器自动更新商品评分统计
|
||||
- 缓存热门商品评价数据
|
||||
|
||||
2. **图片/视频处理**
|
||||
- 图片压缩后上传
|
||||
- 视频转码处理
|
||||
- CDN加速访问
|
||||
|
||||
3. **敏感词过滤**
|
||||
- 评价内容敏感词检测
|
||||
- 自动替换或人工审核
|
||||
|
||||
---
|
||||
|
||||
## 六、测试要点
|
||||
|
||||
### 6.1 积分功能测试
|
||||
|
||||
- [ ] 签到功能:每日签到、连续签到奖励
|
||||
- [ ] 积分兑换:积分不足、库存不足、兑换成功
|
||||
- [ ] 积分过期:自动过期、过期提醒
|
||||
- [ ] 并发测试:同时兑换、同时签到
|
||||
|
||||
### 6.2 评价功能测试
|
||||
|
||||
- [ ] 评价提交:文字、图片、视频、匿名
|
||||
- [ ] 评价展示:列表、筛选、排序
|
||||
- [ ] 评价操作:编辑、删除、追加、点赞
|
||||
- [ ] 边界测试:空内容、超长内容、敏感词
|
||||
|
||||
---
|
||||
|
||||
## 七、附录
|
||||
|
||||
### 7.1 相关文件路径
|
||||
|
||||
```
|
||||
pages/
|
||||
├── mall/
|
||||
│ └── consumer/
|
||||
│ ├── points/
|
||||
│ │ ├── index.uvue # 积分首页(已有)
|
||||
│ │ ├── signin.uvue # 签到页面(新增)
|
||||
│ │ ├── exchange.uvue # 兑换商城(新增)
|
||||
│ │ └── rules.uvue # 积分规则(新增)
|
||||
│ ├── review.uvue # 评价页面(已有)
|
||||
│ ├── product-reviews.uvue # 商品评价列表(新增)
|
||||
│ └── my-reviews.uvue # 我的评价(新增)
|
||||
│
|
||||
└── user/
|
||||
└── login.uvue # 登录页面
|
||||
|
||||
utils/
|
||||
└── supabaseService.uts # API服务
|
||||
|
||||
types/
|
||||
└── mall-types.uts # 类型定义
|
||||
|
||||
doc_mall/
|
||||
└── consumer/
|
||||
└── sql/
|
||||
├── 01_wallet_and_points.sql
|
||||
└── add_reviews_tables.sql
|
||||
```
|
||||
|
||||
### 7.2 参考文档
|
||||
|
||||
- [Supabase 官方文档](https://supabase.com/docs)
|
||||
- [uni-app x 开发文档](https://doc.dcloud.net.cn/uni-app-x/)
|
||||
- [UTS 语法指南](https://doc.dcloud.net.cn/uni-app-x/uts/)
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**创建日期**: 2026-03-05
|
||||
**最后更新**: 2026-03-05
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.8 KiB |
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
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
File diff suppressed because it is too large
Load Diff
@@ -1,231 +0,0 @@
|
||||
# Supabase 消费者端集成指南 (Consumer Frontend Integration Guide)
|
||||
|
||||
本文档基于 `complete_mall_database.sql` 数据库设计,为消费者端 (Consumer App) 前端开发提供对接指引。
|
||||
|
||||
## 1. 核心表结构概览
|
||||
|
||||
所有商城相关表均以 `ml_` 开头。
|
||||
|
||||
| 功能模块 | 核心表 | 视图 (推荐使用) | 说明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **用户** | `ml_user_profiles`, `ml_user_addresses` | `ml_users_view` | 用户扩展信息、收货地址 |
|
||||
| **商品** | `ml_products`, `ml_categories`, `ml_product_skus` | `ml_products_detail_view` | 商品、分类、SKU库存 |
|
||||
| **店铺** | `ml_shops` | - | 店铺基础信息 |
|
||||
| **购物车** | `ml_shopping_cart` | - | 购物车数据 |
|
||||
| **订单** | `ml_orders`, `ml_order_items` | `ml_orders_detail_view` | 订单主表及明细 |
|
||||
| **营销** | `ml_user_coupons`, `ml_coupon_templates` | - | 优惠券 |
|
||||
| **互动** | `ml_user_favorites`, `ml_product_reviews` | - | 收藏、评价 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 关键业务场景与查询示例
|
||||
|
||||
### 2.1 首页展示
|
||||
|
||||
#### 获取一级分类
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('ml_categories')
|
||||
.select('id, name, icon_url')
|
||||
.eq('level', 1)
|
||||
.eq('is_active', true)
|
||||
.order('sort_order', { ascending: true });
|
||||
```
|
||||
|
||||
#### 获取热销/推荐商品
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('ml_products')
|
||||
.select('id, name, main_image_url, base_price, sale_count')
|
||||
.eq('status', 1) // 上架状态
|
||||
.eq('is_hot', true) // 热销标记
|
||||
.limit(10);
|
||||
```
|
||||
|
||||
### 2.2 商品详情页
|
||||
|
||||
建议优先使用 `ml_products` 表配合关联查询,或者使用视图。
|
||||
|
||||
#### 获取商品基础信息
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('ml_products')
|
||||
.select(`
|
||||
*,
|
||||
category:ml_categories(id, name),
|
||||
brand:ml_brands(id, name),
|
||||
shop:ml_shops(id, shop_name, shop_logo),
|
||||
skus:ml_product_skus(*),
|
||||
specs:ml_product_specs(*)
|
||||
`)
|
||||
.eq('id', productId)
|
||||
.single();
|
||||
```
|
||||
|
||||
*注意:`skus` 和 `specs` 是通过外键关联获取的,确保前端处理好 `1:N` 的关系。*
|
||||
|
||||
### 2.3 购物车管理
|
||||
|
||||
前端需维护购物车逻辑,数据均存储在 `ml_shopping_cart`。
|
||||
|
||||
#### 获取我的购物车 (含商品详情)
|
||||
**重要**:务必使用内联查询 (`!inner` 或关联) 获取商品最新价格和图片。
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('ml_shopping_cart')
|
||||
.select(`
|
||||
id,
|
||||
quantity,
|
||||
selected,
|
||||
sku_id,
|
||||
product:ml_products!inner (
|
||||
id,
|
||||
name,
|
||||
main_image_url,
|
||||
base_price, -- 基础价格
|
||||
status, -- 检查是否下架
|
||||
merchant_id -- 用于店铺分组
|
||||
),
|
||||
sku:ml_product_skus (
|
||||
id,
|
||||
sku_code,
|
||||
price, -- SKU价格(如果有)
|
||||
specifications,
|
||||
stock,
|
||||
image_url
|
||||
)
|
||||
`)
|
||||
.eq('user_id', currentUserId)
|
||||
.order('created_at', { ascending: false });
|
||||
```
|
||||
|
||||
#### 店铺分组逻辑 (前端处理)
|
||||
前端获取数据后,应根据 `product.merchant_id` 进行分组,并聚合显示店铺名称 (需另外查询或关联 `ml_shops`)。
|
||||
|
||||
### 2.4 收货地址
|
||||
|
||||
#### 获取地址列表
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('ml_user_addresses')
|
||||
.select('*')
|
||||
.eq('user_id', currentUserId)
|
||||
.eq('status', 1) // 1: 正常
|
||||
.order('is_default', { ascending: false }) // 默认地址排最前
|
||||
.order('updated_at', { ascending: false });
|
||||
```
|
||||
|
||||
### 2.5 订单列表
|
||||
|
||||
#### 查询我的订单
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('ml_orders')
|
||||
.select(`
|
||||
id,
|
||||
order_no,
|
||||
total_amount,
|
||||
order_status,
|
||||
created_at,
|
||||
items:ml_order_items (
|
||||
id,
|
||||
product_name,
|
||||
image_url,
|
||||
quantity,
|
||||
price,
|
||||
specifications
|
||||
),
|
||||
shop:ml_shops (
|
||||
shop_name
|
||||
)
|
||||
`)
|
||||
.eq('user_id', currentUserId)
|
||||
.order('created_at', { ascending: false });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. RLS (行级安全) 注意事项
|
||||
|
||||
数据库已配置 RLS 策略,前端直接调用 Supabase Client 即可,**无需在查询时手动增加 `user_id` 过滤** (除了显式需要对业务逻辑进行过滤的地方,RLS 会自动兜底)。
|
||||
|
||||
- **`ml_shopping_cart`**: 用户只能查/改/删自己的购物车记录。
|
||||
- **`ml_user_addresses`**: 用户只能查/改/删自己的地址。
|
||||
- **`ml_orders`**: 用户只能查看自己的订单。
|
||||
- **`ml_products`**: 设置为 `status = 1` 的商品所有人可读。
|
||||
|
||||
*确保在 App 启动时正确初始化 Supabase Auth 并处于登录状态。*
|
||||
|
||||
## 4. 推荐使用的数据库函数 (RPC)
|
||||
|
||||
可以直接通过 ` supabase.rpc('function_name', params)` 调用以下函数:
|
||||
|
||||
| 函数名 | 参数 | 描述 |
|
||||
| :--- | :--- | :--- |
|
||||
| `calculate_cart_total` | `p_user_id` | 计算当前用户购物车选中商品的总金额 (服务端计算更安全) |
|
||||
| `get_product_available_stock` | `p_product_id`, `p_sku_id` | 获取特定商品或SKU的实时可用库存 |
|
||||
| `get_user_default_address` | `p_user_id` | 快速获取用户的默认收货地址 |
|
||||
|
||||
### 调用示例
|
||||
```typescript
|
||||
// 计算购物车总价
|
||||
const { data: total, error } = await supabase
|
||||
.rpc('calculate_cart_total', {
|
||||
p_user_id: currentUserId
|
||||
});
|
||||
```
|
||||
|
||||
## 5. 类型定义参考 (TypeScript)
|
||||
|
||||
为方便前端开发,以下是核心表对应的推荐接口定义:
|
||||
|
||||
```typescript
|
||||
// 购物车项 (结合了关联查询的结果)
|
||||
export interface CartItem {
|
||||
id: string;
|
||||
quantity: number;
|
||||
selected: boolean;
|
||||
product: {
|
||||
id: string;
|
||||
name: string;
|
||||
main_image_url: string;
|
||||
base_price: number;
|
||||
merchant_id: string;
|
||||
};
|
||||
sku?: {
|
||||
id: string;
|
||||
price: number;
|
||||
specifications: string; // JSON string
|
||||
stock: number;
|
||||
};
|
||||
shop_name?: string; // 前端处理后注入
|
||||
}
|
||||
|
||||
// 订单结构
|
||||
export interface Order {
|
||||
id: string;
|
||||
order_no: string;
|
||||
total_amount: number;
|
||||
order_status: number; // 1:待付款 2:待发货 3:待收货 4:已完成 ...
|
||||
items: Array<{
|
||||
product_name: string;
|
||||
image_url: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 特殊字段说明
|
||||
|
||||
- **Product Images**:
|
||||
- `main_image_url`: 列表页和购物车主图。
|
||||
- `image_urls`: JSONB 数组,用于商品详情轮播图。
|
||||
- **Specifications**:
|
||||
- 在 `ml_product_skus` 表中 `specifications` 为 JSONB 格式 (例如 `{"color": "红色", "size": "L"}`),前端需解析展示。
|
||||
- **Money**:
|
||||
- 数据库使用 `DECIMAL`,API 返回为 `number`,建议前端统一处理为两位小数展示。
|
||||
|
||||
---
|
||||
*文档生成日期: 2026-02-02*
|
||||
@@ -1,114 +0,0 @@
|
||||
-- =====================================================================================
|
||||
-- 批量生成商品测试数据 (基于现有的店铺)
|
||||
-- 说明:为 ml_shops 表中的每个店铺生成 5 个测试商品
|
||||
-- 前提:ml_shops 表中已有数据
|
||||
-- =====================================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
shop_rec RECORD;
|
||||
cat_id UUID;
|
||||
i INTEGER;
|
||||
new_product_id UUID;
|
||||
v_total_shops INTEGER := 0;
|
||||
v_total_products INTEGER := 0;
|
||||
BEGIN
|
||||
-- 1. 获取一个可用的分类ID (如果没有则创建一个)
|
||||
SELECT id INTO cat_id FROM public.ml_categories LIMIT 1;
|
||||
|
||||
IF cat_id IS NULL THEN
|
||||
RAISE NOTICE '未找到分类,正在创建默认分类...';
|
||||
INSERT INTO public.ml_categories (name, slug, level, path)
|
||||
VALUES ('测试分类', 'test-category', 1, ARRAY['测试分类'])
|
||||
RETURNING id INTO cat_id;
|
||||
END IF;
|
||||
|
||||
-- 2. 统计现有店铺数量
|
||||
SELECT COUNT(*) INTO v_total_shops FROM public.ml_shops;
|
||||
RAISE NOTICE '发现 % 个店铺,准备生成商品...', v_total_shops;
|
||||
|
||||
-- 3. 遍历所有店铺
|
||||
FOR shop_rec IN SELECT merchant_id, shop_name FROM public.ml_shops LOOP
|
||||
|
||||
-- 为每个店铺生成 5 个商品
|
||||
FOR i IN 1..5 LOOP
|
||||
|
||||
-- 插入商品主表
|
||||
INSERT INTO public.ml_products (
|
||||
merchant_id,
|
||||
category_id,
|
||||
product_code,
|
||||
name,
|
||||
subtitle,
|
||||
description,
|
||||
main_image_url,
|
||||
image_urls,
|
||||
base_price,
|
||||
market_price,
|
||||
cost_price,
|
||||
total_stock,
|
||||
available_stock,
|
||||
status, -- 1:上架
|
||||
is_new,
|
||||
is_hot,
|
||||
sale_count, -- 初始销量
|
||||
rating_avg, -- 初始评分
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
shop_rec.merchant_id,
|
||||
cat_id,
|
||||
-- 生成唯一的商品编码: PROD + 商家ID前8位 + 序号 + 随机数
|
||||
'PROD-' || substring(shop_rec.merchant_id::text, 1, 8) || '-' || i || '-' || floor(random() * 10000)::text,
|
||||
shop_rec.shop_name || ' - 甄选商品 ' || i,
|
||||
'测试商品副标题 ' || i || ' | 正品保证 | 极速发货',
|
||||
'<h3>商品详情</h3><p>这是 ' || shop_rec.shop_name || ' 的第 ' || i || ' 款测试商品。</p><p>商品特点:优质材料,精湛工艺,性价比高。</p>',
|
||||
'/static/images/product/p' || (floor(random() * 4) + 1)::int || '.jpg', -- 随机图片 p1.jpg - p5.jpg
|
||||
('["/static/images/product/p1.jpg", "/static/images/product/p2.jpg", "/static/images/product/p3.jpg"]')::jsonb,
|
||||
(floor(random() * 500) + 50)::decimal(10,2), -- 价格 50 - 550
|
||||
(floor(random() * 200) + 600)::decimal(10,2), -- 市场价
|
||||
(floor(random() * 40) + 10)::decimal(10,2), -- 成本价
|
||||
999, -- 总库存
|
||||
999, -- 可用库存
|
||||
1, -- 状态:上架
|
||||
(random() > 0.5), -- 是否新品
|
||||
(random() > 0.7), -- 是否热销
|
||||
(floor(random() * 1000))::int, -- 随机初始销量
|
||||
(4.0 + random())::decimal(3,1), -- 随机评分 4.0 - 5.0
|
||||
NOW(),
|
||||
NOW()
|
||||
) RETURNING id INTO new_product_id;
|
||||
|
||||
-- 插入 SKU (默认生成一个 SKU)
|
||||
INSERT INTO public.ml_product_skus (
|
||||
product_id,
|
||||
sku_code,
|
||||
specifications,
|
||||
price,
|
||||
market_price,
|
||||
stock,
|
||||
status
|
||||
) VALUES (
|
||||
new_product_id,
|
||||
'SKU-' || substring(new_product_id::text, 1, 8),
|
||||
'{"默认": "标准规格"}'::jsonb,
|
||||
(floor(random() * 500) + 50)::decimal(10,2),
|
||||
(floor(random() * 200) + 600)::decimal(10,2),
|
||||
999,
|
||||
1
|
||||
);
|
||||
|
||||
v_total_products := v_total_products + 1;
|
||||
|
||||
END LOOP;
|
||||
|
||||
-- 更新店铺的商品统计数量
|
||||
UPDATE public.ml_shops
|
||||
SET product_count = product_count + 5,
|
||||
updated_at = NOW()
|
||||
WHERE merchant_id = shop_rec.merchant_id;
|
||||
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE '生成完成!共为 % 个店铺生成了 % 个商品。', v_total_shops, v_total_products;
|
||||
END $$;
|
||||
@@ -1,16 +0,0 @@
|
||||
软件订阅(consumer)
|
||||
|
||||
入口:
|
||||
- 用户中心 -> 软件订阅
|
||||
|
||||
页面:
|
||||
- plan-list.uvue:展示可用订阅方案(ml_subscription_plans)
|
||||
- plan-detail.uvue:展示某个订阅方案详情
|
||||
- subscribe-checkout.uvue:确认支付并创建订阅(写入 ml_user_subscriptions)
|
||||
|
||||
依赖表(示例名称,可按实际后端调整):
|
||||
- ml_subscription_plans(id, plan_code, name, description, features jsonb, price numeric, currency text, billing_period text, trial_days int, is_active bool, sort_order int, created_at, updated_at)
|
||||
- ml_user_subscriptions(id, user_id, plan_id, status text, start_date timestamptz, end_date timestamptz, next_billing_date timestamptz, auto_renew bool, cancel_at_period_end bool, metadata jsonb, created_at, updated_at)
|
||||
|
||||
注意:
|
||||
- 本实现使用 uni-app-x 兼容组件与 supaClient。实际支付请替换为你们的支付网关,并在后端完成对账与签名校验。
|
||||
@@ -1,951 +0,0 @@
|
||||
<!-- 閽卞寘椤甸潰 -->
|
||||
<template>
|
||||
<view class="wallet-page">
|
||||
<!-- 椤堕儴鏍?-->
|
||||
<view class="wallet-header">
|
||||
<text class="back-btn" @click="goBack">鈥?/text>
|
||||
<text class="header-title">鎴戠殑閽卞寘</text>
|
||||
<text class="more-btn" @click="showMoreActions">路路路</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="wallet-content" scroll-y>
|
||||
<!-- 浣欓姒傝 -->
|
||||
<view class="balance-overview">
|
||||
<text class="balance-label">璐︽埛浣欓</text>
|
||||
<text class="balance-value">楼{{ balance.toFixed(2) }}</text>
|
||||
<view class="balance-actions">
|
||||
<button class="action-btn recharge" @click="recharge">鍏呭€?/button>
|
||||
<button class="action-btn withdraw" @click="withdraw">鎻愮幇</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 璧勪骇缁熻 -->
|
||||
<view class="assets-stats">
|
||||
<view class="stat-item">
|
||||
<text class="stat-label">绱鍏呭€?/text>
|
||||
<text class="stat-value">楼{{ stats.totalRecharge.toFixed(2) }}</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-label">绱娑堣垂</text>
|
||||
<text class="stat-value">楼{{ stats.totalConsume.toFixed(2) }}</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-label">绱鎻愮幇</text>
|
||||
<text class="stat-value">楼{{ stats.totalWithdraw.toFixed(2) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 蹇嵎鍔熻兘 -->
|
||||
<view class="quick-actions">
|
||||
<view class="action-grid">
|
||||
<view class="action-item" @click="goToCoupons">
|
||||
<text class="action-icon">馃帿</text>
|
||||
<text class="action-text">浼樻儬鍒?/text>
|
||||
</view>
|
||||
<view class="action-item" @click="goToRedPackets">
|
||||
<text class="action-icon">馃Ё</text>
|
||||
<text class="action-text">绾㈠寘</text>
|
||||
</view>
|
||||
<view class="action-item" @click="goToPoints">
|
||||
<text class="action-icon">猸?/text>
|
||||
<text class="action-text">绉垎</text>
|
||||
</view>
|
||||
<view class="action-item" @click="goToBankCards">
|
||||
<text class="action-icon">馃挸</text>
|
||||
<text class="action-text">閾惰鍗?/text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 浜ゆ槗璁板綍 -->
|
||||
<view class="transactions-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">浜ゆ槗璁板綍</text>
|
||||
<view class="filter-tabs">
|
||||
<text :class="['filter-tab', { active: activeFilter === 'all' }]"
|
||||
@click="changeFilter('all')">鍏ㄩ儴</text>
|
||||
<text :class="['filter-tab', { active: activeFilter === 'income' }]"
|
||||
@click="changeFilter('income')">鏀跺叆</text>
|
||||
<text :class="['filter-tab', { active: activeFilter === 'expense' }]"
|
||||
@click="changeFilter('expense')">鏀嚭</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 绌虹姸鎬?-->
|
||||
<view v-if="transactions.length === 0 && !isLoading" class="empty-transactions">
|
||||
<text class="empty-icon">馃挵</text>
|
||||
<text class="empty-text">鏆傛棤浜ゆ槗璁板綍</text>
|
||||
<text class="empty-subtext">蹇幓浣跨敤閽卞寘鍔熻兘鍚?/text>
|
||||
</view>
|
||||
|
||||
<!-- 浜ゆ槗鍒楄〃 -->
|
||||
<view class="transactions-list">
|
||||
<view v-for="transaction in transactions"
|
||||
:key="transaction.id"
|
||||
class="transaction-item">
|
||||
<view class="transaction-left">
|
||||
<text class="transaction-icon">{{ getTransactionIcon(transaction.type) }}</text>
|
||||
<view class="transaction-info">
|
||||
<text class="transaction-title">{{ getTransactionTitle(transaction.type) }}</text>
|
||||
<text class="transaction-time">{{ formatTime(transaction.created_at) }}</text>
|
||||
<text v-if="transaction.remark" class="transaction-remark">{{ transaction.remark }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="transaction-right">
|
||||
<text :class="['transaction-amount',
|
||||
{ income: transaction.amount > 0, expense: transaction.amount < 0 }]">
|
||||
{{ transaction.amount > 0 ? '+' : '' }}楼{{ Math.abs(transaction.amount).toFixed(2) }}
|
||||
</text>
|
||||
<text class="transaction-balance">浣欓: 楼{{ transaction.current_balance.toFixed(2) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 鍔犺浇鏇村 -->
|
||||
<view v-if="isLoading" class="loading-more">
|
||||
<text class="loading-text">鍔犺浇涓?..</text>
|
||||
</view>
|
||||
<view v-if="!hasMore && transactions.length > 0" class="no-more">
|
||||
<text class="no-more-text">娌℃湁鏇村璁板綍浜?/text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 瀹夊叏鎻愮ず -->
|
||||
<view class="security-tips">
|
||||
<text class="tip-title">瀹夊叏鎻愮ず</text>
|
||||
<text class="tip-item">1. 璇峰Ε鍠勪繚绠℃偍鐨勬敮浠樺瘑鐮?/text>
|
||||
<text class="tip-item">2. 涓嶈鍚戜粬浜洪€忛湶鎮ㄧ殑璐︽埛淇℃伅</text>
|
||||
<text class="tip-item">3. 瀹氭湡淇敼瀵嗙爜浠ョ‘淇濊处鎴峰畨鍏?/text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 鍏呭€煎脊绐?-->
|
||||
<view v-if="showRechargePopup" class="recharge-popup">
|
||||
<view class="popup-mask" @click="closeRechargePopup"></view>
|
||||
<view class="popup-content">
|
||||
<view class="popup-header">
|
||||
<text class="popup-title">鍏呭€?/text>
|
||||
<text class="popup-close" @click="closeRechargePopup">脳</text>
|
||||
</view>
|
||||
<view class="popup-body">
|
||||
<text class="amount-label">鍏呭€奸噾棰?/text>
|
||||
<view class="amount-input">
|
||||
<text class="currency-symbol">楼</text>
|
||||
<input class="amount-field"
|
||||
v-model="rechargeAmount"
|
||||
type="number"
|
||||
placeholder="璇疯緭鍏ュ厖鍊奸噾棰?
|
||||
focus />
|
||||
</view>
|
||||
<view class="quick-amounts">
|
||||
<text v-for="amount in quickAmounts"
|
||||
:key="amount"
|
||||
:class="['quick-amount', { active: rechargeAmount === amount.toString() }]"
|
||||
@click="selectQuickAmount(amount)">
|
||||
楼{{ amount }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="recharge-tip">鍗曠瑪鍏呭€兼渶浣?0鍏冿紝鏈€楂?000鍏?/text>
|
||||
</view>
|
||||
<view class="popup-footer">
|
||||
<button class="cancel-btn" @click="closeRechargePopup">鍙栨秷</button>
|
||||
<button class="confirm-btn"
|
||||
:class="{ disabled: !canRecharge }"
|
||||
@click="confirmRecharge">
|
||||
纭鍏呭€?
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
type WalletType = {
|
||||
id: string
|
||||
user_id: string
|
||||
balance: number
|
||||
total_recharge: number
|
||||
total_consume: number
|
||||
total_withdraw: number
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
type TransactionType = {
|
||||
id: string
|
||||
user_id: string
|
||||
change_amount: number
|
||||
current_balance: number
|
||||
change_type: string // 'recharge' | 'consume' | 'withdraw' | 'refund' | 'reward'
|
||||
related_id: string | null
|
||||
remark: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type StatsType = {
|
||||
totalRecharge: number
|
||||
totalConsume: number
|
||||
totalWithdraw: number
|
||||
}
|
||||
|
||||
const balance = ref<number>(0)
|
||||
const stats = ref<StatsType>({
|
||||
totalRecharge: 0,
|
||||
totalConsume: 0,
|
||||
totalWithdraw: 0
|
||||
})
|
||||
const transactions = ref<Array<TransactionType>>([])
|
||||
const activeFilter = ref<string>('all')
|
||||
const isLoading = ref<boolean>(false)
|
||||
const currentPage = ref<number>(1)
|
||||
const pageSize = ref<number>(20)
|
||||
const hasMore = ref<boolean>(true)
|
||||
const showRechargePopup = ref<boolean>(false)
|
||||
const rechargeAmount = ref<string>('')
|
||||
const quickAmounts = [50, 100, 200, 500, 1000]
|
||||
|
||||
// 璁$畻灞炴€?
|
||||
const canRecharge = computed(() => {
|
||||
const amount = parseFloat(rechargeAmount.value)
|
||||
return !isNaN(amount) && amount >= 10 && amount <= 5000
|
||||
})
|
||||
|
||||
// 鐩戝惉杩囨护鍣ㄥ彉鍖?
|
||||
watch(activeFilter, () => {
|
||||
resetTransactions()
|
||||
loadTransactions()
|
||||
})
|
||||
|
||||
// 鐢熷懡鍛ㄦ湡
|
||||
onMounted(() => {
|
||||
loadWalletData()
|
||||
})
|
||||
|
||||
// 閲嶇疆浜ゆ槗璁板綍
|
||||
const resetTransactions = () => {
|
||||
transactions.value = []
|
||||
currentPage.value = 1
|
||||
hasMore.value = true
|
||||
}
|
||||
|
||||
// 鍔犺浇閽卞寘鏁版嵁
|
||||
const loadWalletData = async () => {
|
||||
const userId = getCurrentUserId()
|
||||
if (!userId) {
|
||||
uni.navigateTo({
|
||||
url: '/pages/user/login'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
loadBalance(),
|
||||
loadTransactions()
|
||||
])
|
||||
}
|
||||
|
||||
// 鍔犺浇浣欓淇℃伅
|
||||
const loadBalance = async () => {
|
||||
const userId = getCurrentUserId()
|
||||
if (!userId) return
|
||||
|
||||
try {
|
||||
const { data, error } = await supa
|
||||
.from('user_wallets')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (error !== null) {
|
||||
console.error('鍔犺浇閽卞寘澶辫触:', error)
|
||||
return
|
||||
}
|
||||
|
||||
if (data) {
|
||||
balance.value = data.balance || 0
|
||||
stats.value = {
|
||||
totalRecharge: data.total_recharge || 0,
|
||||
totalConsume: data.total_consume || 0,
|
||||
totalWithdraw: data.total_withdraw || 0
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('鍔犺浇閽卞寘寮傚父:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 鍔犺浇浜ゆ槗璁板綍
|
||||
const loadTransactions = async (loadMore: boolean = false) => {
|
||||
if (isLoading.value || (!hasMore.value && loadMore)) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const userId = getCurrentUserId()
|
||||
if (!userId) return
|
||||
|
||||
const page = loadMore ? currentPage.value + 1 : 1
|
||||
|
||||
let query = supa
|
||||
.from('balance_records')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
// 鏍规嵁杩囨护鍣ㄧ瓫閫?
|
||||
if (activeFilter.value === 'income') {
|
||||
query = query.gt('change_amount', 0)
|
||||
} else if (activeFilter.value === 'expense') {
|
||||
query = query.lt('change_amount', 0)
|
||||
}
|
||||
|
||||
// 鍒嗛〉
|
||||
query = query.range((page - 1) * pageSize.value, page * pageSize.value - 1)
|
||||
|
||||
const { data, error } = await query
|
||||
|
||||
if (error !== null) {
|
||||
console.error('鍔犺浇浜ゆ槗璁板綍澶辫触:', error)
|
||||
return
|
||||
}
|
||||
|
||||
const newTransactions = data || []
|
||||
|
||||
if (loadMore) {
|
||||
transactions.value.push(...newTransactions)
|
||||
currentPage.value = page
|
||||
} else {
|
||||
transactions.value = newTransactions
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
hasMore.value = newTransactions.length === pageSize.value
|
||||
} catch (err) {
|
||||
console.error('鍔犺浇浜ゆ槗璁板綍寮傚父:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 鑾峰彇褰撳墠鐢ㄦ埛ID
|
||||
const getCurrentUserId = (): string => {
|
||||
const userStore = uni.getStorageSync('userInfo')
|
||||
return userStore?.id || ''
|
||||
}
|
||||
|
||||
// 鑾峰彇浜ゆ槗鍥炬爣
|
||||
const getTransactionIcon = (type: string): string => {
|
||||
const icons: Record<string, string> = {
|
||||
recharge: '馃挸',
|
||||
consume: '馃洅',
|
||||
withdraw: '馃彟',
|
||||
refund: '馃攧',
|
||||
reward: '馃巵',
|
||||
income: '馃挵',
|
||||
expense: '馃摛'
|
||||
}
|
||||
return icons[type] || '馃挵'
|
||||
}
|
||||
|
||||
// 鑾峰彇浜ゆ槗鏍囬
|
||||
const getTransactionTitle = (type: string): string => {
|
||||
const titles: Record<string, string> = {
|
||||
recharge: '璐︽埛鍏呭€?,
|
||||
consume: '鍟嗗搧娑堣垂',
|
||||
withdraw: '浣欓鎻愮幇',
|
||||
refund: '璁㈠崟閫€娆?,
|
||||
reward: '娲诲姩濂栧姳',
|
||||
income: '鏀跺叆',
|
||||
expense: '鏀嚭'
|
||||
}
|
||||
return titles[type] || '浜ゆ槗'
|
||||
}
|
||||
|
||||
// 鏍煎紡鍖栨椂闂?
|
||||
const formatTime = (timeStr: string): string => {
|
||||
const date = new Date(timeStr)
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
// 鏄剧ず鏇村鎿嶄綔
|
||||
const showMoreActions = () => {
|
||||
uni.showActionSheet({
|
||||
itemList: ['浜ゆ槗璁板綍', '瀹夊叏璁剧疆', '甯姪涓績'],
|
||||
success: (res) => {
|
||||
switch (res.tapIndex) {
|
||||
case 0:
|
||||
// 浜ゆ槗璁板綍宸茬粡鍦ㄥ綋鍓嶉〉
|
||||
break
|
||||
case 1:
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/settings'
|
||||
})
|
||||
break
|
||||
case 2:
|
||||
uni.navigateTo({
|
||||
url: '/pages/info/help'
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 鍏呭€?
|
||||
const recharge = () => {
|
||||
showRechargePopup.value = true
|
||||
rechargeAmount.value = ''
|
||||
}
|
||||
|
||||
// 鎻愮幇
|
||||
const withdraw = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/withdraw'
|
||||
})
|
||||
}
|
||||
|
||||
// 璺宠浆鍒颁紭鎯犲埜
|
||||
const goToCoupons = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/coupons'
|
||||
})
|
||||
}
|
||||
|
||||
// 璺宠浆鍒扮孩鍖?
|
||||
const goToRedPackets = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/red-packets'
|
||||
})
|
||||
}
|
||||
|
||||
// 璺宠浆鍒扮Н鍒?
|
||||
const goToPoints = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/points'
|
||||
})
|
||||
}
|
||||
|
||||
// 璺宠浆鍒伴摱琛屽崱
|
||||
const goToBankCards = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/bank-cards'
|
||||
})
|
||||
}
|
||||
|
||||
// 鍒囨崲杩囨护鍣?
|
||||
const changeFilter = (filter: string) => {
|
||||
activeFilter.value = filter
|
||||
}
|
||||
|
||||
// 鍔犺浇鏇村
|
||||
const loadMore = () => {
|
||||
if (hasMore.value && !isLoading.value) {
|
||||
loadTransactions(true)
|
||||
}
|
||||
}
|
||||
|
||||
// 閫夋嫨蹇嵎閲戦
|
||||
const selectQuickAmount = (amount: number) => {
|
||||
rechargeAmount.value = amount.toString()
|
||||
}
|
||||
|
||||
// 纭鍏呭€?
|
||||
const confirmRecharge = async () => {
|
||||
if (!canRecharge.value) return
|
||||
|
||||
const amount = parseFloat(rechargeAmount.value)
|
||||
if (isNaN(amount)) return
|
||||
|
||||
// 杩欓噷搴旇璺宠浆鍒版敮浠橀〉闈㈣繘琛屽厖鍊?
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/payment?type=recharge&amount=${amount}`
|
||||
})
|
||||
|
||||
closeRechargePopup()
|
||||
}
|
||||
|
||||
// 鍏抽棴鍏呭€煎脊绐?
|
||||
const closeRechargePopup = () => {
|
||||
showRechargePopup.value = false
|
||||
rechargeAmount.value = ''
|
||||
}
|
||||
|
||||
// 杩斿洖
|
||||
const goBack = () => {
|
||||
uni.navigateBack()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wallet-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.wallet-header {
|
||||
background-color: #ffffff;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
font-size: 24px;
|
||||
color: #333333;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
color: #333333;
|
||||
font-size: 20px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.wallet-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.balance-overview {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 30px 20px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.balance-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.balance-value {
|
||||
display: block;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.balance-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.action-btn.recharge {
|
||||
background-color: #ffffff;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.action-btn.withdraw {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.assets-stats {
|
||||
background-color: #ffffff;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
background-color: #ffffff;
|
||||
margin-top: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.action-grid {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.transactions-section {
|
||||
background-color: #ffffff;
|
||||
margin-top: 10px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
padding: 5px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
color: #007aff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.filter-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: #007aff;
|
||||
}
|
||||
|
||||
.empty-transactions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 60px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
color: #666666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.empty-subtext {
|
||||
font-size: 14px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.transactions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.transaction-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 15px 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.transaction-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.transaction-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.transaction-icon {
|
||||
font-size: 24px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.transaction-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.transaction-title {
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.transaction-time {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.transaction-remark {
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.transaction-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.transaction-amount {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.transaction-amount.income {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.transaction-amount.expense {
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.transaction-balance {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.loading-more,
|
||||
.no-more {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.no-more-text {
|
||||
color: #999999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.security-tips {
|
||||
background-color: #ffffff;
|
||||
margin-top: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.tip-title {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tip-item {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tip-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.recharge-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.popup-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #ffffff;
|
||||
border-top-left-radius: 15px;
|
||||
border-top-right-radius: 15px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
font-size: 24px;
|
||||
color: #999999;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.amount-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.amount-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.currency-symbol {
|
||||
font-size: 20px;
|
||||
color: #333333;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.amount-field {
|
||||
flex: 1;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.quick-amounts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.quick-amount {
|
||||
padding: 8px 15px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 15px;
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.quick-amount.active {
|
||||
background-color: #007aff;
|
||||
color: #ffffff;
|
||||
border-color: #007aff;
|
||||
}
|
||||
|
||||
.recharge-tip {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.confirm-btn {
|
||||
flex: 1;
|
||||
height: 45px;
|
||||
border-radius: 22.5px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background-color: #f5f5f5;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background-color: #007aff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.confirm-btn.disabled {
|
||||
background-color: #cccccc;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,985 +0,0 @@
|
||||
<!-- 閽卞寘椤甸潰 -->
|
||||
<template>
|
||||
<view class="wallet-page">
|
||||
<!-- 椤堕儴鏍?-->
|
||||
<!--<view class="wallet-header">
|
||||
<text class="back-btn" @click="goBack">鈥?/text>
|
||||
</view>-->
|
||||
|
||||
<scroll-view class="wallet-content" scroll-y>
|
||||
<!-- 浣欓姒傝 -->
|
||||
<view class="balance-overview">
|
||||
<text class="balance-label">璐︽埛浣欓</text>
|
||||
<text class="balance-value">楼{{ balance.toFixed(2) }}</text>
|
||||
<view class="balance-actions">
|
||||
<button class="action-btn recharge" @click="recharge">鍏呭€?/button>
|
||||
<button class="action-btn withdraw" @click="withdraw">鎻愮幇</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 璧勪骇缁熻 -->
|
||||
<view class="assets-stats">
|
||||
<view class="stat-item">
|
||||
<text class="stat-label">绱鍏呭€?/text>
|
||||
<text class="stat-value">楼{{ stats.totalRecharge.toFixed(2) }}</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-label">绱娑堣垂</text>
|
||||
<text class="stat-value">楼{{ stats.totalConsume.toFixed(2) }}</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-label">绱鎻愮幇</text>
|
||||
<text class="stat-value">楼{{ stats.totalWithdraw.toFixed(2) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 蹇嵎鍔熻兘 -->
|
||||
<view class="quick-actions">
|
||||
<view class="action-grid">
|
||||
<view class="action-item" @click="goToCoupons">
|
||||
<text class="action-icon">馃帿</text>
|
||||
<text class="action-text">浼樻儬鍒?/text>
|
||||
</view>
|
||||
<view class="action-item" @click="goToRedPackets">
|
||||
<text class="action-icon">馃Ё</text>
|
||||
<text class="action-text">绾㈠寘</text>
|
||||
</view>
|
||||
<view class="action-item" @click="goToPoints">
|
||||
<text class="action-icon">猸?/text>
|
||||
<text class="action-text">绉垎</text>
|
||||
</view>
|
||||
<view class="action-item" @click="goToBankCards">
|
||||
<text class="action-icon">馃挸</text>
|
||||
<text class="action-text">閾惰鍗?/text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 浜ゆ槗璁板綍 -->
|
||||
<view class="transactions-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">浜ゆ槗璁板綍</text>
|
||||
<view class="filter-tabs">
|
||||
<text :class="['filter-tab', { active: activeFilter === 'all' }]"
|
||||
@click="changeFilter('all')">鍏ㄩ儴</text>
|
||||
<text :class="['filter-tab', { active: activeFilter === 'income' }]"
|
||||
@click="changeFilter('income')">鏀跺叆</text>
|
||||
<text :class="['filter-tab', { active: activeFilter === 'expense' }]"
|
||||
@click="changeFilter('expense')">鏀嚭</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 绌虹姸鎬?-->
|
||||
<view v-if="transactions.length === 0 && !isLoading" class="empty-transactions">
|
||||
<text class="empty-icon">馃挵</text>
|
||||
<text class="empty-text">鏆傛棤浜ゆ槗璁板綍</text>
|
||||
<text class="empty-subtext">蹇幓浣跨敤閽卞寘鍔熻兘鍚?/text>
|
||||
</view>
|
||||
|
||||
<!-- 浜ゆ槗鍒楄〃 -->
|
||||
<view class="transactions-list">
|
||||
<view v-for="transaction in transactions"
|
||||
:key="transaction.id"
|
||||
class="transaction-item">
|
||||
<view class="transaction-left">
|
||||
<text class="transaction-icon">{{ getTransactionIcon(transaction.type) }}</text>
|
||||
<view class="transaction-info">
|
||||
<text class="transaction-title">{{ getTransactionTitle(transaction.type) }}</text>
|
||||
<text class="transaction-time">{{ formatTime(transaction.created_at) }}</text>
|
||||
<text v-if="transaction.remark" class="transaction-remark">{{ transaction.remark }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="transaction-right">
|
||||
<text :class="['transaction-amount',
|
||||
{ income: transaction.amount > 0, expense: transaction.amount < 0 }]">
|
||||
{{ transaction.amount > 0 ? '+' : '' }}楼{{ Math.abs(transaction.amount).toFixed(2) }}
|
||||
</text>
|
||||
<text class="transaction-balance">浣欓: 楼{{ transaction.current_balance.toFixed(2) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 鍔犺浇鏇村 -->
|
||||
<view v-if="isLoading" class="loading-more">
|
||||
<text class="loading-text">鍔犺浇涓?..</text>
|
||||
</view>
|
||||
<view v-if="!hasMore && transactions.length > 0" class="no-more">
|
||||
<text class="no-more-text">娌℃湁鏇村璁板綍浜?/text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 瀹夊叏鎻愮ず -->
|
||||
<view class="security-tips">
|
||||
<text class="tip-title">瀹夊叏鎻愮ず</text>
|
||||
<text class="tip-item">1. 璇峰Ε鍠勪繚绠℃偍鐨勬敮浠樺瘑鐮?/text>
|
||||
<text class="tip-item">2. 涓嶈鍚戜粬浜洪€忛湶鎮ㄧ殑璐︽埛淇℃伅</text>
|
||||
<text class="tip-item">3. 瀹氭湡淇敼瀵嗙爜浠ョ‘淇濊处鎴峰畨鍏?/text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 鍏呭€煎脊绐?-->
|
||||
<view v-if="showRechargePopup" class="recharge-popup">
|
||||
<view class="popup-mask" @click="closeRechargePopup"></view>
|
||||
<view class="popup-content">
|
||||
<view class="popup-header">
|
||||
<text class="popup-title">鍏呭€?/text>
|
||||
<text class="popup-close" @click="closeRechargePopup">脳</text>
|
||||
</view>
|
||||
<view class="popup-body">
|
||||
<text class="amount-label">鍏呭€奸噾棰?/text>
|
||||
<view class="amount-input">
|
||||
<text class="currency-symbol">楼</text>
|
||||
<input class="amount-field"
|
||||
v-model="rechargeAmount"
|
||||
type="number"
|
||||
placeholder="璇疯緭鍏ュ厖鍊奸噾棰?
|
||||
focus />
|
||||
</view>
|
||||
<view class="quick-amounts">
|
||||
<text v-for="amount in quickAmounts"
|
||||
:key="amount"
|
||||
:class="['quick-amount', { active: rechargeAmount === amount.toString() }]"
|
||||
@click="selectQuickAmount(amount)">
|
||||
楼{{ amount }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="recharge-tip">鍗曠瑪鍏呭€兼渶浣?0鍏冿紝鏈€楂?000鍏?/text>
|
||||
</view>
|
||||
<view class="popup-footer">
|
||||
<button class="cancel-btn" @click="closeRechargePopup">鍙栨秷</button>
|
||||
<button class="confirm-btn"
|
||||
:class="{ disabled: !canRecharge }"
|
||||
@click="confirmRecharge">
|
||||
纭鍏呭€?
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
//import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
type WalletType = {
|
||||
id: string
|
||||
user_id: string
|
||||
balance: number
|
||||
total_recharge: number
|
||||
total_consume: number
|
||||
total_withdraw: number
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
type TransactionType = {
|
||||
id: string
|
||||
user_id: string
|
||||
change_amount: number
|
||||
current_balance: number
|
||||
change_type: string // 'recharge' | 'consume' | 'withdraw' | 'refund' | 'reward'
|
||||
related_id: string | null
|
||||
remark: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type StatsType = {
|
||||
totalRecharge: number
|
||||
totalConsume: number
|
||||
totalWithdraw: number
|
||||
}
|
||||
|
||||
const balance = ref<number>(0)
|
||||
const stats = ref<StatsType>({
|
||||
totalRecharge: 0,
|
||||
totalConsume: 0,
|
||||
totalWithdraw: 0
|
||||
})
|
||||
const transactions = ref<Array<TransactionType>>([])
|
||||
const activeFilter = ref<string>('all')
|
||||
const isLoading = ref<boolean>(false)
|
||||
const currentPage = ref<number>(1)
|
||||
const pageSize = ref<number>(20)
|
||||
const hasMore = ref<boolean>(true)
|
||||
const showRechargePopup = ref<boolean>(false)
|
||||
const rechargeAmount = ref<string>('')
|
||||
const quickAmounts = [50, 100, 200, 500, 1000]
|
||||
|
||||
// 璁$畻灞炴€?
|
||||
const canRecharge = computed(() => {
|
||||
const amount = parseFloat(rechargeAmount.value)
|
||||
return !isNaN(amount) && amount >= 10 && amount <= 5000
|
||||
})
|
||||
|
||||
// 鐩戝惉杩囨护鍣ㄥ彉鍖?
|
||||
watch(activeFilter, () => {
|
||||
resetTransactions()
|
||||
loadTransactions()
|
||||
})
|
||||
|
||||
// 鐢熷懡鍛ㄦ湡
|
||||
onMounted(() => {
|
||||
loadWalletData()
|
||||
})
|
||||
|
||||
// 閲嶇疆浜ゆ槗璁板綍
|
||||
const resetTransactions = () => {
|
||||
transactions.value = []
|
||||
currentPage.value = 1
|
||||
hasMore.value = true
|
||||
}
|
||||
|
||||
// 鍔犺浇閽卞寘鏁版嵁
|
||||
const loadWalletData = async () => {
|
||||
const userId = getCurrentUserId()
|
||||
if (!userId) {
|
||||
uni.navigateTo({
|
||||
url: '/pages/user/login'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
loadBalance(),
|
||||
loadTransactions()
|
||||
])
|
||||
}
|
||||
|
||||
// 鍔犺浇浣欓淇℃伅
|
||||
const loadBalance = async () => {
|
||||
const userId = getCurrentUserId()
|
||||
if (!userId) return
|
||||
|
||||
try {
|
||||
const { data, error } = await supa
|
||||
.from('user_wallets')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (error !== null) {
|
||||
console.error('鍔犺浇閽卞寘澶辫触:', error)
|
||||
return
|
||||
}
|
||||
|
||||
if (data) {
|
||||
balance.value = data.balance || 0
|
||||
stats.value = {
|
||||
totalRecharge: data.total_recharge || 0,
|
||||
totalConsume: data.total_consume || 0,
|
||||
totalWithdraw: data.total_withdraw || 0
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('鍔犺浇閽卞寘寮傚父:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 鍔犺浇浜ゆ槗璁板綍
|
||||
const loadTransactions = async (loadMore: boolean = false) => {
|
||||
if (isLoading.value || (!hasMore.value && loadMore)) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const userId = getCurrentUserId()
|
||||
if (!userId) return
|
||||
|
||||
const page = loadMore ? currentPage.value + 1 : 1
|
||||
|
||||
let query = supa
|
||||
.from('balance_records')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
// 鏍规嵁杩囨护鍣ㄧ瓫閫?
|
||||
if (activeFilter.value === 'income') {
|
||||
query = query.gt('change_amount', 0)
|
||||
} else if (activeFilter.value === 'expense') {
|
||||
query = query.lt('change_amount', 0)
|
||||
}
|
||||
|
||||
// 鍒嗛〉
|
||||
query = query.range((page - 1) * pageSize.value, page * pageSize.value - 1)
|
||||
|
||||
const { data, error } = await query
|
||||
|
||||
if (error !== null) {
|
||||
console.error('鍔犺浇浜ゆ槗璁板綍澶辫触:', error)
|
||||
return
|
||||
}
|
||||
|
||||
const newTransactions = data || []
|
||||
|
||||
if (loadMore) {
|
||||
transactions.value.push(...newTransactions)
|
||||
currentPage.value = page
|
||||
} else {
|
||||
transactions.value = newTransactions
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
hasMore.value = newTransactions.length === pageSize.value
|
||||
} catch (err) {
|
||||
console.error('鍔犺浇浜ゆ槗璁板綍寮傚父:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 鑾峰彇褰撳墠鐢ㄦ埛ID
|
||||
const getCurrentUserId = (): string => {
|
||||
const userStore = uni.getStorageSync('userInfo')
|
||||
return userStore?.id || ''
|
||||
}
|
||||
|
||||
// 鑾峰彇浜ゆ槗鍥炬爣
|
||||
const getTransactionIcon = (type: string): string => {
|
||||
const icons: Record<string, string> = {
|
||||
recharge: '馃挸',
|
||||
consume: '馃洅',
|
||||
withdraw: '馃彟',
|
||||
refund: '馃攧',
|
||||
reward: '馃巵',
|
||||
income: '馃挵',
|
||||
expense: '馃摛'
|
||||
}
|
||||
return icons[type] || '馃挵'
|
||||
}
|
||||
|
||||
// 鑾峰彇浜ゆ槗鏍囬
|
||||
const getTransactionTitle = (type: string): string => {
|
||||
const titles: Record<string, string> = {
|
||||
recharge: '璐︽埛鍏呭€?,
|
||||
consume: '鍟嗗搧娑堣垂',
|
||||
withdraw: '浣欓鎻愮幇',
|
||||
refund: '璁㈠崟閫€娆?,
|
||||
reward: '娲诲姩濂栧姳',
|
||||
income: '鏀跺叆',
|
||||
expense: '鏀嚭'
|
||||
}
|
||||
return titles[type] || '浜ゆ槗'
|
||||
}
|
||||
|
||||
// 鏍煎紡鍖栨椂闂?
|
||||
const formatTime = (timeStr: string): string => {
|
||||
const date = new Date(timeStr)
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
// 鏄剧ず鏇村鎿嶄綔
|
||||
const showMoreActions = () => {
|
||||
uni.showActionSheet({
|
||||
itemList: ['浜ゆ槗璁板綍', '瀹夊叏璁剧疆', '甯姪涓績'],
|
||||
success: (res) => {
|
||||
switch (res.tapIndex) {
|
||||
case 0:
|
||||
// 浜ゆ槗璁板綍宸茬粡鍦ㄥ綋鍓嶉〉
|
||||
break
|
||||
case 1:
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/settings'
|
||||
})
|
||||
break
|
||||
case 2:
|
||||
uni.navigateTo({
|
||||
url: '/pages/info/help'
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 鍏呭€?
|
||||
const recharge = () => {
|
||||
showRechargePopup.value = true
|
||||
rechargeAmount.value = ''
|
||||
}
|
||||
|
||||
// 鎻愮幇
|
||||
const withdraw = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/withdraw'
|
||||
})
|
||||
}
|
||||
|
||||
// 璺宠浆鍒颁紭鎯犲埜
|
||||
const goToCoupons = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/coupons'
|
||||
})
|
||||
}
|
||||
|
||||
// 璺宠浆鍒扮孩鍖?
|
||||
const goToRedPackets = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/red-packets'
|
||||
})
|
||||
}
|
||||
|
||||
// 璺宠浆鍒扮Н鍒?
|
||||
const goToPoints = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/points'
|
||||
})
|
||||
}
|
||||
|
||||
// 璺宠浆鍒伴摱琛屽崱
|
||||
const goToBankCards = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/bank-cards'
|
||||
})
|
||||
}
|
||||
|
||||
// 鍒囨崲杩囨护鍣?
|
||||
const changeFilter = (filter: string) => {
|
||||
activeFilter.value = filter
|
||||
}
|
||||
|
||||
// 鍔犺浇鏇村
|
||||
const loadMore = () => {
|
||||
if (hasMore.value && !isLoading.value) {
|
||||
loadTransactions(true)
|
||||
}
|
||||
}
|
||||
|
||||
// 閫夋嫨蹇嵎閲戦
|
||||
const selectQuickAmount = (amount: number) => {
|
||||
rechargeAmount.value = amount.toString()
|
||||
}
|
||||
|
||||
// 纭鍏呭€?
|
||||
const confirmRecharge = async () => {
|
||||
if (!canRecharge.value) return
|
||||
|
||||
const amount = parseFloat(rechargeAmount.value)
|
||||
if (isNaN(amount)) return
|
||||
|
||||
// 杩欓噷搴旇璺宠浆鍒版敮浠橀〉闈㈣繘琛屽厖鍊?
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/payment?type=recharge&amount=${amount}`
|
||||
})
|
||||
|
||||
closeRechargePopup()
|
||||
}
|
||||
|
||||
// 鍏抽棴鍏呭€煎脊绐?
|
||||
const closeRechargePopup = () => {
|
||||
showRechargePopup.value = false
|
||||
rechargeAmount.value = ''
|
||||
}
|
||||
|
||||
// 杩斿洖
|
||||
const goBack = () => {
|
||||
uni.navigateBack()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 鍝嶅簲寮忓竷灞€浼樺寲 */
|
||||
@media screen and (min-width: 768px) {
|
||||
.wallet-content {
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.balance-overview {
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
max-width: 800px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.assets-stats, .quick-actions, .transactions-section, .security-tips {
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
max-width: 800px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
width: 400px;
|
||||
left: 50%;
|
||||
bottom: 50%;
|
||||
transform: translate(-50%, 50%);
|
||||
border-radius: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
.wallet-page {
|
||||
flex-direction: row; /* 澶у睆涓嬫敼涓烘í鍚戝竷灞€ */
|
||||
}
|
||||
|
||||
.wallet-header {
|
||||
display: none; /* 澶у睆涓嬮殣钘忛《閮ㄦ爮 */
|
||||
}
|
||||
|
||||
.wallet-content {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.wallet-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.wallet-header {
|
||||
background-color: #ffffff;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
font-size: 24px;
|
||||
color: #333333;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.wallet-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.balance-overview {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 30px 20px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.balance-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.balance-value {
|
||||
display: block;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.balance-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.action-btn.recharge {
|
||||
background-color: #ffffff;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.action-btn.withdraw {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.assets-stats {
|
||||
background-color: #ffffff;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
background-color: #ffffff;
|
||||
margin-top: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.action-grid {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.transactions-section {
|
||||
background-color: #ffffff;
|
||||
margin-top: 10px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
padding: 5px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
color: #007aff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.filter-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: #007aff;
|
||||
}
|
||||
|
||||
.empty-transactions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 60px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
color: #666666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.empty-subtext {
|
||||
font-size: 14px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.transactions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.transaction-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 15px 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.transaction-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.transaction-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.transaction-icon {
|
||||
font-size: 24px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.transaction-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.transaction-title {
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.transaction-time {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.transaction-remark {
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.transaction-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.transaction-amount {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.transaction-amount.income {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.transaction-amount.expense {
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.transaction-balance {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.loading-more,
|
||||
.no-more {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.no-more-text {
|
||||
color: #999999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.security-tips {
|
||||
background-color: #ffffff;
|
||||
margin-top: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.tip-title {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tip-item {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tip-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.recharge-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.popup-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #ffffff;
|
||||
border-top-left-radius: 15px;
|
||||
border-top-right-radius: 15px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
font-size: 24px;
|
||||
color: #999999;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.amount-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.amount-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.currency-symbol {
|
||||
font-size: 20px;
|
||||
color: #333333;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.amount-field {
|
||||
flex: 1;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.quick-amounts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.quick-amount {
|
||||
padding: 8px 15px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 15px;
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.quick-amount.active {
|
||||
background-color: #007aff;
|
||||
color: #ffffff;
|
||||
border-color: #007aff;
|
||||
}
|
||||
|
||||
.recharge-tip {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.confirm-btn {
|
||||
flex: 1;
|
||||
height: 45px;
|
||||
border-radius: 22.5px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background-color: #f5f5f5;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background-color: #007aff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.confirm-btn.disabled {
|
||||
background-color: #cccccc;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -99,7 +99,7 @@ const userStats = ref<UserStatsType>({
|
||||
streak: 0
|
||||
} as UserStatsType)
|
||||
const currentLocale = ref<string>('zh-CN')
|
||||
const userAvatar = ref<string>('/static/default-avatar.png')
|
||||
const userAvatar = ref<string>('/static/images/default-product.png')
|
||||
|
||||
const toggleLanguage = (): void => {
|
||||
if (currentLocale.value === 'zh-CN') {
|
||||
|
||||
Reference in New Issue
Block a user