consumer模块完成95%,在和商家端对接聊天购物闭环

This commit is contained in:
2026-02-06 17:10:31 +08:00
parent 06b7369494
commit e2f1dfb097
1454 changed files with 5425 additions and 210555 deletions

View File

@@ -66,7 +66,7 @@
</view>
<!-- 编辑/新增自动回复 -->
<view v-if="showModal" class="modal-overlay" @click.self="closeModal">
<view v-if="showModal" class="modal-overlay" @click="closeModal">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ editingId ? '编辑自动回复' : '新增自动回复' }}</text>

View File

@@ -47,8 +47,8 @@
<view v-if="loading" class="loading"><text>加载中...</text></view>
<!-- 编辑弹层(纯样式实现,兼容 uni-app-x -->
<view v-if="editVisible" class="overlay" @click.self="closeEdit">
<view class="sheet">
<view v-if="editVisible" class="overlay" @click="closeEdit">
<view class="sheet" @click.stop>
<text class="sheet-title">{{ editMode === 'create' ? '新增方案' : '编辑方案' }}</text>
<scroll-view scroll-y="true" class="form">

View File

@@ -73,8 +73,8 @@
<view v-if="loading" class="loading">加载中...</view>
<!-- 状态选择 ActionSheet -->
<view v-if="statusSheet.visible" class="overlay" @click.self="closeStatusSheet">
<view class="sheet">
<view v-if="statusSheet.visible" class="overlay" @click="closeStatusSheet">
<view class="sheet" @click.stop>
<text class="sheet-title">设置订阅状态</text>
<view class="sheet-list">
<button v-for="s in statusUpdateList" :key="s" class="sheet-item" @click="applyStatus(s)">{{ mapStatus(s) }}</button>

View File

@@ -1,5 +1,5 @@
<template>
<view class="page" @click.self="closeMoreMenu">
<view class="page">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'优惠券效果分析'"

View File

@@ -1,5 +1,5 @@
<template>
<view class="page" @click.self="closeMoreMenu">
<view class="page">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'数据分析中心'"

View File

@@ -1,5 +1,5 @@
<template>
<view class="page" @click.self="closeMoreMenu">
<view class="page">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'市场趋势'"

View File

@@ -1,5 +1,5 @@
<template>
<view class="page" @click.self="closeMoreMenu">
<view class="page">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'商品洞察'"

View File

@@ -1013,8 +1013,6 @@ function goToFeedback() {
font-size: 22rpx;
color: #8c929b;
}
color: #999;
}
.trend-legend {
display: flex;

View File

@@ -1,5 +1,5 @@
<template>
<view class="page" @click.self="closeMoreMenu">
<view class="page">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'销售报表'"

View File

@@ -1,5 +1,5 @@
<template>
<view class="page" @click.self="closeMoreMenu">
<view class="page">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'用户分析'"

View File

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

View File

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

View File

@@ -40,9 +40,9 @@
<text v-if="isShopSelected(group.shopId)" class="selected-icon">✓</text>
<text v-else class="unselected-icon"></text>
</view>
<text class="shop-icon">🏪</text>
<text class="shop-name">{{ group.shopName }}</text>
<text class="shop-arrow">></text>
<text class="shop-icon" @click="navigateToShop(group.shopId, group.merchantId)">🏪</text>
<text class="shop-name" @click="navigateToShop(group.shopId, group.merchantId)">{{ group.shopName }}</text>
<text class="shop-arrow" @click="navigateToShop(group.shopId, group.merchantId)">></text>
</view>
<!-- 店铺商品 -->
@@ -188,6 +188,7 @@ const cartGroups = computed(() => {
groups.set(shopKey, {
shopId: item.shopId,
shopName: item.shopName || '商城优选', // Better default name
merchantId: item.merchantId, // Add merchantId for navigation
items: [] as any[]
})
}
@@ -550,6 +551,17 @@ const addToCart = async (product: any) => {
}
// 导航函数
const navigateToShop = (shopId: string, merchantId: any) => {
// Prevent navigation for invalid shops
if (!shopId || shopId === 'default_shop' || shopId === 'unknown') return
let url = `/pages/mall/consumer/shop-detail?id=${shopId}`
if (merchantId) {
url += `&merchantId=${merchantId}`
}
uni.navigateTo({ url })
}
const goShopping = () => {
uni.switchTab({ url: '/pages/mall/consumer/index' })
}

View File

@@ -153,14 +153,38 @@ onMounted(async() => {
// 添加加载分类的方法
const loadCategories = async () => {
try {
const categories = await supabaseService.getCategories()
console.log('加载分类数据成功,数量:', categories.length)
const categoriesData = await supabaseService.getCategories()
console.log('加载分类数据成功,数量:', categoriesData.length)
// 映射数据并添加默认颜色,防止选中时背景透明导致文字看不清
const categories = categoriesData.map((cat: any) => ({
id: cat.id,
name: cat.name,
icon: cat.icon_url || '📦',
desc: cat.description || '',
description: cat.description || '', // 兼容不同字段名
color: cat.color || '#4CAF50' // 默认绿色如果有color字段则使用
})) as Category[]
if (categories.length > 0) {
primaryCategories.value = categories
// 如果没有通过参数设置分类,则设置默认选中第一个分类
if (!activePrimary.value && categories[0]) {
activePrimary.value = categories[0].id
console.log('设置默认分类为:', categories[0].name, 'ID:', categories[0].id)
currentCategoryName.value = categories[0].name
currentCategoryDesc.value = categories[0].description || ''
} else if (activePrimary.value) {
// 如果已经选中了分类可能来自Storage更新显示信息
const current = categories.find(c => c.id == activePrimary.value)
if (current) {
currentCategoryName.value = current.name
currentCategoryDesc.value = current.description || ''
// 如果此时没有商品列表(且没有正在加载),可能需要加载
if (productList.value.length === 0 && !loading.value) {
loadProducts()
}
}
}
} else {
console.warn('从Supabase获取的分类数据为空')
@@ -290,6 +314,21 @@ onShow(() => {
console.log('页面显示时间:', Date.now())
console.log('当前活动分类:', activePrimary.value)
// 1. 优先检查 Storage 中的参数 (由首页传入)
const storageCategoryId = uni.getStorageSync('selectedCategory')
if (storageCategoryId) {
console.log('✅ onShow中找到Storage分类参数:', storageCategoryId)
hasLoadedFromParams.value = true
// 清除Storage防止下次误读
uni.removeStorageSync('selectedCategory')
if (activePrimary.value !== storageCategoryId) {
selectPrimaryCategory(storageCategoryId)
}
// 如果分类还没加载完这里设置了ID等loadCategories完成后会自动匹配信息
return
}
// 在onShow中我们也需要检查是否有新的参数
// 因为当从主页再次点击分类跳转过来时可能不会触发onLoad
// 而是触发onShow

View File

@@ -2,12 +2,12 @@
<template>
<view class="chat-page">
<!-- 聊天头部 -->
<view class="chat-header">
<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">在线客服</text>
<text class="chat-title">{{ headerTitle }}</text>
<text class="chat-status">在线</text>
</view>
<view class="header-actions">
@@ -119,8 +119,10 @@
</template>
<script setup lang="uts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
import { supabaseService, type ChatMessage } from '@/utils/supabaseService.uts'
import supa from '@/components/supadb/aksupainstance.uts'
import { getCurrentUser } from '@/utils/store.uts'
// 响应式数据
const messages = ref<any[]>([])
@@ -128,64 +130,113 @@ 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 navPaddingTop = ref<string>('30px') // 默认值,包含状态栏高度+原有内边距
let realtimeChannel: any = null
// 模拟表情列表
const emojiList = ['😊', '😂', '🤣', '😍', '😘', '🥰', '😭', '😡', '👍', '👏', '🙏', '🎉', '❤️', '🔥', '⭐']
// Mock 聊天记录
const mockMessages = [
{
id: 1,
type: 'received',
content: '您好,欢迎咨询!有什么可以帮助您的吗?',
time: '14:30'
},
{
id: 2,
type: 'sent',
content: '你好,我昨天下的订单一直没有发货',
time: '14:31'
},
{
id: 3,
type: 'received',
content: '请问您的订单号是多少?我帮您查询一下',
time: '14:32'
},
{
id: 4,
type: 'sent',
content: '订单号是202311230001',
time: '14:33'
},
{
id: 5,
type: 'received',
content: '正在为您查询订单状态...',
time: '14:34'
}
]
// 生命周期
onMounted(() => {
loadChatHistory()
onLoad((options: any) => {
// 动态获取状态栏高度
const sysInfo = uni.getSystemInfoSync()
const statusBarH = sysInfo.statusBarHeight
// 状态栏高度 + 10px 原有顶部内边距
navPaddingTop.value = (statusBarH + 10) + 'px'
if (options.merchantId) {
merchantId.value = options.merchantId
}
if (options.merchantName) {
headerTitle.value = options.merchantName
}
})
onMounted(async () => {
const user = await getCurrentUser()
if (user) {
currentUserId.value = user.id
}
loadChatHistory()
setupRealtimeSubscription()
})
onUnmounted(() => {
if (realtimeChannel) {
supa.removeChannel(realtimeChannel)
}
})
// 建立实时订阅
const setupRealtimeSubscription = () => {
console.log('开始建立聊天实时订阅...')
realtimeChannel = supa.channel('public:ml_chat_messages')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'ml_chat_messages' }, (payload: any) => {
const newMsg = payload.new
console.log('收到新消息:', newMsg)
// 如果是我发的消息因为已经乐观更新了所以忽略或者根据ID更新状态
if (newMsg.sender_id === currentUserId.value) {
return
}
// 如果是发给我的消息
if (newMsg.receiver_id === currentUserId.value) {
// 如果指定了商家,只接收该商家的消息
if (merchantId.value && newMsg.sender_id !== merchantId.value) {
return
}
// 转换为UI消息格式
const date = new Date(newMsg.created_at || new Date().toISOString())
const timeStr = `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
const incomingMsg = {
id: newMsg.id || Date.now(), // 优先使用DB ID
type: 'received',
content: newMsg.content,
time: timeStr
}
messages.value.push(incomingMsg)
scrollToBottom()
}
})
.subscribe((status: string) => {
console.log('订阅状态:', status)
})
}
// 加载聊天记录
const loadChatHistory = async () => {
const rawMsgs = await supabaseService.getUserChatMessages()
let rawMsgs: ChatMessage[] = []
if (merchantId.value) {
rawMsgs = await supabaseService.getChatMessages(merchantId.value)
} else {
rawMsgs = await supabaseService.getUserChatMessages()
}
messages.value = rawMsgs.reverse().map((m: ChatMessage) => {
const date = new Date(m.created_at || new Date().toISOString())
const timeStr = `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
// Use explicit 'as' casting to avoid type errors if needed, though map handles it
const msg : any = {
id: m.id,
type: m.is_from_user ? 'sent' : 'received',
type: m.is_from_user ? 'sent' : 'received', // 假设is_from_user标志是准确的或者比较 sender_id
content: m.content,
time: timeStr
}
// 双重确认类型
if (currentUserId.value && m.sender_id === currentUserId.value) {
msg.type = 'sent'
} else if (currentUserId.value && m.sender_id !== currentUserId.value) {
msg.type = 'received'
}
return msg
})
@@ -200,7 +251,7 @@ const sendMessage = async () => {
const content = inputMessage.value.trim()
if (!content) return
// 添加发送的消息
// 添加发送的消息 (乐观更新)
const newMessage = {
id: Date.now(),
type: 'sent',
@@ -214,44 +265,34 @@ const sendMessage = async () => {
// 滚动到底部
scrollToBottom()
// Backend Save
await supabaseService.sendChatMessage(content)
// 发送到 Supabase
// 如果有 merchantId发送给指定商家否则可能是发给系统或默认客服
const success = await supabaseService.sendChatMessage(content, merchantId.value || null)
// 模拟客服回复2秒后
if (!success) {
uni.showToast({
title: '发送失败',
icon: 'none'
})
// 实际项目中可能需要标记消息为发送失败状态
}
// 移除模拟回复,依赖 Realtime 接收真实回复
/*
setTimeout(() => {
simulateCustomerReply()
}, 2000)
*/
}
// 模拟客服回复
// 模拟客服回复 (已禁用,改用 Realtime)
/*
const simulateCustomerReply = async () => {
const replies = [
'好的,已为您记录',
'这个问题需要进一步核实',
'我明白了,马上为您处理',
'请稍等,正在为您查询',
'感谢您的反馈'
]
const randomReply = replies[Math.floor(Math.random() * replies.length)]
await supabaseService.simulateServiceReply(randomReply)
addReceivedMessage(randomReply)
// ...
}
*/
// 添加接收的消息
const addReceivedMessage = (content: string) => {
const newMessage = {
id: Date.now(),
type: 'received',
content: content,
time: getCurrentTime()
}
messages.value.push(newMessage)
scrollToBottom()
}
/* 移除不再使用的 simulateCustomerReply 和 addReceivedMessage */
// 滚动到底部
const scrollToBottom = () => {
@@ -358,7 +399,9 @@ const goBack = () => {
.chat-header {
background-color: white;
padding: 10px 15px;
/* padding-top 由内联样式控制 */
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #eee;
@@ -437,6 +480,7 @@ const goBack = () => {
/* 消息项 */
.message-wrapper {
display: flex;
flex-direction: row;
margin-bottom: 15px;
}
@@ -517,6 +561,7 @@ const goBack = () => {
.input-tools {
display: flex;
flex-direction: row;
margin-bottom: 10px;
}
@@ -528,6 +573,7 @@ const goBack = () => {
.input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
}
@@ -573,6 +619,7 @@ const goBack = () => {
.emoji-category {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,8 @@
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import supabaseService from '@/utils/supabaseService.uts'
import type { UserCoupon } from '@/utils/supabaseService.uts'
type Coupon = {
title: string
@@ -37,21 +39,24 @@ onMounted(() => {
loadCoupons()
})
const loadCoupons = () => {
// 从本地存储获取已领取的优惠券详情
// 假设存储格式为 JSON 字符串数组
const storedCoupons = uni.getStorageSync('myCoupons')
if (storedCoupons) {
const loadCoupons = async () => {
uni.showLoading({ title: '加载中...' })
try {
coupons.value = JSON.parse(storedCoupons as string) as Coupon[]
const userCoupons = await supabaseService.getUserCoupons(1) // 1: unused
coupons.value = userCoupons.map((item: UserCoupon) => {
return {
id: item.id,
title: item.template_name || '优惠券',
amount: `¥${item.amount || 0}`,
expiry: item.expire_at ? item.expire_at.substring(0, 10) : '长期有效'
} as Coupon
})
} catch (e) {
console.error('Failed to parse coupons', e)
coupons.value = []
console.error('加载优惠券失败', e)
coupons.value = []
} finally {
uni.hideLoading()
}
} else {
// 默认空或者是mock一些基础数据如果需要
coupons.value = []
}
}
const useCoupon = (coupon: Coupon) => {

View File

@@ -0,0 +1,155 @@
# 基于 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支持多类型消息渲染特别是商品卡片和订单卡片

View File

@@ -0,0 +1,192 @@
# 消费者端前端数据库文档 (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 | 主键 | |
| `user_id` | UUID | 用户 ID | |
| `merchant_id` | UUID | 商家 ID | |
| `order_no` | Text | 订单号 | 唯一业务单号 |
| `total_amount` | Numeric | 订单总金额 | |
| `pay_amount` | Numeric | 实付金额 | |
| `status` | Integer | 订单状态 | 0: 待付款, 1: 待发货, 2: 待收货, 3: 已完成, -1: 已取消 |
| `address_snapshot` | JSONB | 收货地址快照 | 下单时的地址信息 |
| `remark` | Text | 订单备注 | |
| `created_at` | Timestamp | 下单时间 | |
### 3.3 订单项表 (`ml_order_items`) (推测结构)
| 字段名 | 类型 | 描述 | 备注 |
| :--- | :--- | :--- | :--- |
| `id` | UUID | 主键 | |
| `order_id` | UUID | 订单 ID | |
| `product_id` | UUID | 商品 ID | |
| `sku_id` | UUID | SKU ID | |
| `product_name` | Text | 商品名称快照 | |
| `price` | Numeric | 成交单价 | |
| `quantity` | Integer | 购买数量 | |
| `sku_snapshot` | JSONB | 规格快照 | |
---
## 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` 等于自己账号的数据。

View File

@@ -187,8 +187,10 @@ const removeFavorite = async (id: string) => {
.product-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 15px;
width: 100%;
}
.empty-state {
@@ -222,12 +224,32 @@ const removeFavorite = async (id: string) => {
}
.product-item {
width: calc(50% - 8px);
width: calc(50% - 8px); /* Default Mobile: 2 items per row */
background-color: white;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box; /* Important for grid */
}
/* PC/Tablet Responsive */
@media (min-width: 768px) {
.product-item {
width: calc(33.33% - 10px) !important; /* Tablet: 3 items (gap 15px roughly distributed) */
}
}
@media (min-width: 1024px) {
.product-item {
width: calc(16.66% - 13px) !important; /* PC: 6 items */
}
/* Center content on large screens */
.product-grid, .header {
max-width: 1200px;
margin: 0 auto;
}
}
.product-image {

View File

@@ -3,9 +3,10 @@
<view class="footprint-page">
<!-- 顶部栏 -->
<view class="footprint-header">
<view class="header-title">
<!-- Title hidden as requested -->
<!-- <view class="header-title">
<text class="title-text">我的足迹</text>
</view>
</view> -->
<view v-if="footprints.length > 0" class="header-actions">
<text class="action-btn" @click="toggleEditMode">{{ isEditMode ? '完成' : '编辑' }}</text>
<text class="action-btn" @click="clearAll">清空</text>
@@ -39,17 +40,19 @@
</view>
</view>
<view class="item-content" @click="viewProduct(item)">
<image class="product-image" :src="item.image" />
<image class="product-image" :src="item.image" mode="aspectFill" />
<view class="product-info">
<text class="product-name">{{ item.name }}</text>
<view class="product-price-row">
<text class="current-price">¥{{ item.price }}</text>
<text v-if="item.original_price && item.original_price > item.price"
class="original-price">¥{{ item.original_price }}</text>
</view>
<view class="product-meta">
<text class="sales-text">已售{{ item.sales }}</text>
<text class="time-text">{{ formatTime(item.viewTime) }}</text>
<view class="product-bottom">
<view class="product-price-row">
<text class="current-price">¥{{ item.price }}</text>
<!-- <text v-if="item.original_price && item.original_price > item.price"
class="original-price">¥{{ item.original_price }}</text> -->
</view>
<!-- <view class="product-meta">
<text class="sales-text">已售{{ item.sales }}</text>
<text class="time-text">{{ formatTime(item.viewTime) }}</text>
</view> -->
</view>
</view>
</view>
@@ -415,7 +418,8 @@ const goShopping = () => {
}
.header-title {
flex: 1;
/* margin-left: 15px; */ /* Removed */
display: none;
}
.title-text {
@@ -426,13 +430,19 @@ const goShopping = () => {
.header-actions {
display: flex;
flex-direction: row;
gap: 20px;
flex: 1;
justify-content: flex-end; /* Align Edit/Clear to the right */
align-items: center;
padding-right: 0;
}
.action-btn {
color: #007aff;
font-size: 14px;
padding: 5px;
margin-left: 20px;
}
.footprint-content {
@@ -477,12 +487,12 @@ const goShopping = () => {
.date-group {
background-color: #ffffff;
margin-bottom: 10px;
padding: 0 15px;
padding: 0 10px;
}
.group-header {
padding: 15px 0;
border-bottom: 1px solid #f5f5f5;
padding: 15px 5px;
/* border-bottom: 1px solid #f5f5f5; */
display: flex;
align-items: center;
justify-content: space-between;
@@ -500,21 +510,41 @@ const goShopping = () => {
}
.group-items {
padding: 10px 0;
padding: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
}
.footprint-item {
display: flex;
padding: 15px 0;
border-bottom: 1px solid #f5f5f5;
flex-direction: column; /* 垂直排列内容 */
/* padding: 15px 0; */ /* Grid layout uses gap */
margin-bottom: 10px;
border-bottom: none;
width: calc(50% - 5px); /* Mobile: 2 items per row */
background-color: #fff;
/* border-radius: 8px; */ /* Optional card style */
/* overflow: hidden; */
position: relative; /* For absolute positioning of selector */
}
/*
.footprint-item:last-child {
border-bottom: none;
}
}
*/
.item-selector {
width: 50px;
position: absolute;
top: 5px;
right: 5px;
z-index: 10;
width: 30px;
height: 30px;
/* background-color: rgba(255,255,255,0.8); */
border-radius: 15px;
display: flex;
align-items: center;
justify-content: center;
@@ -525,6 +555,7 @@ const goShopping = () => {
height: 20px;
border: 1px solid #cccccc;
border-radius: 10px;
background-color: rgba(255,255,255,0.5);
display: flex;
align-items: center;
justify-content: center;
@@ -543,13 +574,16 @@ const goShopping = () => {
.item-content {
flex: 1;
display: flex;
flex-direction: column; /* 垂直堆叠 */
}
.product-image {
width: 80px;
height: 80px;
width: 100%;
height: 170px; /* Adjust height based on aspect ratio preference */
border-radius: 5px;
margin-right: 15px;
margin-right: 0;
margin-bottom: 8px;
background-color: #f5f5f5;
}
.product-info {
@@ -557,30 +591,57 @@ const goShopping = () => {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 0 4px;
}
.product-name {
font-size: 14px;
color: #333333;
line-height: 1.4;
margin-bottom: 10px;
margin-bottom: 6px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
height: 40px;
}
.product-bottom {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.product-price-row {
display: flex;
align-items: baseline;
margin-bottom: 10px;
margin-bottom: 0;
}
.current-price {
font-size: 16px;
color: #ff4757;
font-weight: bold;
margin-right: 10px;
margin-right: 0;
}
/* PC/Tablet Responsive */
@media (min-width: 768px) {
.footprint-item {
width: calc(33.33% - 7px) !important; /* Tablet: 3 items */
}
}
@media (min-width: 1024px) {
.footprint-item {
width: calc(16.66% - 9px) !important; /* PC: 6 items */
}
.footprint-content, .footprint-header {
max-width: 1200px;
margin: 0 auto;
}
}
.original-price {

View File

@@ -15,12 +15,12 @@
<text class="search-placeholder">请输入药品名称、症状或品牌</text>
<!-- 扫码图标 -->
<view class="nav-icon-btn" @click="onScan">
<view class="nav-icon-btn" @click.stop="onScan">
<text class="nav-icon">🔳</text>
</view>
<!-- 相机图标 -->
<view class="nav-camera-btn" @click="onCamera">
<view class="nav-camera-btn" @click.stop="onCamera">
<text class="nav-camera-icon">📷</text>
</view>
@@ -47,40 +47,12 @@
@scroll="handleScroll"
>
<!-- 健康资讯轮播 (Moved Up) -->
<!-- 健康资讯轮播 (Hidden) -->
<!--
<view class="health-news">
<view class="news-header">
<text class="news-title">健康资讯</text>
<text class="news-more" @click="navigateToNews">更多 ></text>
</view>
<swiper
class="news-swiper"
:autoplay="true"
:interval="4000"
:duration="500"
:circular="true"
:indicator-dots="true"
indicator-color="rgba(255,255,255,0.3)"
indicator-active-color="#4CAF50"
>
<swiper-item
v-for="news in healthNews"
:key="news.id"
class="news-item"
>
<view class="news-content" @click="viewNewsDetail(news)">
<image
class="news-image"
:src="news.image"
mode="aspectFill"
/>
<view class="news-overlay">
<text class="news-tag">{{ news.tag }}</text>
<text class="news-caption">{{ news.title }}</text>
</view>
</view>
</swiper-item>
</swiper>
...
</view>
-->
<!-- 智能健康卡片 (Hidden) -->
<!-- <view class="smart-health-card" :style="{ marginTop: (statusBarHeight + 44 + 10) + 'px' }">
@@ -98,12 +70,19 @@
</view> -->
<!-- 智能分类网格 - 完全响应式 -->
<view class="smart-categories">
<view class="smart-categories" :style="{ marginTop: (statusBarHeight + 44 + 10) + 'px' }">
<view class="section-header">
<text class="section-title">智能分类</text>
<text class="section-desc">快速定位所需药品</text>
<view class="category-tabs-pills">
<view :class="['tab-pill', { active: categoryTab == 'category' }]" @click="categoryTab = 'category'">
<text class="tab-text">智能分类</text>
</view>
<view :class="['tab-pill', { active: categoryTab == 'brand' }]" @click="categoryTab = 'brand'">
<text class="tab-text">品牌甄选</text>
</view>
</view>
<text class="section-desc">快速定位</text>
</view>
<view class="category-grid">
<view class="category-grid" v-if="categoryTab === 'category'">
<view
v-for="category in categories"
:key="category.id"
@@ -118,6 +97,22 @@
<text class="card-desc">{{ category.desc }}</text>
</view>
</view>
<view class="category-grid" v-else>
<view
v-for="brand in brands"
:key="brand.id"
class="category-card"
@click="switchBrand(brand)"
style="--card-color: #5785e5"
>
<image v-if="brand.logo_url" :src="brand.logo_url" mode="aspectFit" class="brand-logo" style="width: 40px; height: 40px; border-radius: 20px;" />
<view v-else class="card-icon">
<text>🏢</text>
</view>
<text class="card-name">{{ brand.name }}</text>
</view>
</view>
</view>
<!-- 健康资讯轮播 (Original Position - Removed) -->
@@ -210,7 +205,7 @@
</view>
<view class="product-action">
<view class="cart-btn" @click="addToCart(product)">
<view class="cart-btn" @click.stop="addToCart(product)">
<text class="cart-icon">+</text>
<text class="cart-text">加入购物车</text>
</view>
@@ -276,7 +271,7 @@
import { ref, reactive, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import supabaseService from '@/utils/supabaseService.uts'
import type { Product, Category } from '@/utils/supabaseService.uts'
import type { Product, Category, Brand } from '@/utils/supabaseService.uts'
import { getCurrentUser } from '@/utils/store.uts'
// 响应式数据
@@ -305,7 +300,9 @@ const scrollThreshold = 30 // 降低滚动阈值,使其更灵敏
const scrollingUp = ref(false)
// 分类数据 - 从Supabase获取
const categoryTab = ref<string>('category')
const categories = ref<Category[]>([])
const brands = ref<Brand[]>([])
// 排序标签
const sortTabs = [
@@ -344,26 +341,34 @@ const loadCategories = async () => {
try {
const categoriesData = await supabaseService.getCategories()
// 映射字段根据ml_categories表结构映射
categories.value = categoriesData.map((cat: any) => ({
const mappedCategories = categoriesData.map((cat: any) => ({
id: cat.id,
name: cat.name,
icon: cat.icon_url || '📦', // 使用icon_url字段
desc: cat.description || '', // 使用description字段
color: '#4CAF50' // 默认颜色表中可能没有color字段
}))
// 保持原始顺序或按ID排序移除随机打乱
categories.value = mappedCategories
} catch (error) {
console.error('加载分类数据失败:', error)
// 如果加载失败,使用默认分类作为后备
categories.value = [
{ id: 'cold', name: '感冒发烧', icon: '🤧', desc: '解热镇痛', color: '#2196F3' },
{ id: 'stomach', name: '肠胃用药', icon: '🤢', desc: '消化系统', color: '#4CAF50' },
{ id: 'pain', name: '止痛消炎', icon: '💊', desc: '镇痛消炎', color: '#F44336' },
{ id: 'skin', name: '皮肤用药', icon: '🤕', desc: '皮肤护理', color: '#9C27B0' },
{ id: 'vitamin', name: '维生素', icon: '🍊', desc: '营养补充', color: '#FF9800' }
]
categories.value = []
}
}
// 获取品牌数据
const loadBrands = async () => {
try {
const brandsData = await supabaseService.getBrands()
// 保持原始顺序
brands.value = brandsData
} catch (e) {
console.error('加载品牌失败:', e)
brands.value = []
}
}
// 获取热销商品(根据当前排序方式)
const loadHotProducts = async (targetLimit: number = 6) => {
try {
@@ -403,6 +408,10 @@ const loadHotProducts = async (targetLimit: number = 6) => {
}
console.log('加载到的商品数量:', products.length)
if (products.length > 0) {
console.log('Sample Product Merchant IDs:')
products.slice(0, 3).forEach(p => console.log(` - Product: ${p.name}, MerchantID: ${p.merchant_id}`))
}
hotProducts.value = products
} catch (error) {
console.error('加载热销商品失败:', error)
@@ -425,6 +434,7 @@ const initData = async () => {
console.error('加载用户资料失败:', error)
}
await loadCategories()
await loadBrands()
await loadHotProducts()
await loadRecommendedProducts()
}
@@ -581,8 +591,8 @@ const switchCategory = (category: any) => {
console.log('=== switchCategory函数开始执行 ===')
console.log('分类ID:', category.id, '分类名称:', category.name)
// 清除可能存在的旧数据
uni.removeStorageSync('selectedCategory')
// 使用Storage传递参数确保switchTab后能被读取
uni.setStorageSync('selectedCategory', category.id)
// 生成唯一的时间戳和随机参数,确保每次跳转都是新的页面
const timestamp = Date.now()
@@ -590,26 +600,24 @@ const switchCategory = (category: any) => {
// 构建带参数的URL直接通过URL传递分类信息
const url = `/pages/mall/consumer/category?categoryId=${category.id}&name=${encodeURIComponent(category.name)}&timestamp=${timestamp}&random=${randomParam}`
console.log('跳转URL:', url)
console.log('分类ID参数:', category.id)
console.log('时间戳:', timestamp)
console.log('随机参数:', randomParam)
// 使用uni.reLaunch跳转到分类页面关闭所有页面并打开新页面
// 这样可以确保每次跳转都是全新的页面实例,避免页面缓存问题
// 虽然这会关闭当前主页,但可以确保分类页面总是重新加载
uni.reLaunch({
url: url,
success: () => {
console.log('✅ 使用reLaunch跳转到分类页面成功')
console.log('=== switchCategory函数执行完成 ===')
},
fail: (err) => {
console.error('❌ 跳转到分类页面失败:', err)
console.log('=== switchCategory函数执行完成 ===')
}
})
uni.switchTab({
url: '/pages/mall/consumer/category',
success: () => {
// 通过 event channel 或 globalData 传递
const app = getApp()
if (app.globalData != null) {
app.globalData['selectedCategory'] = category.id
}
}
})
}
const switchBrand = (brand: Brand) => {
// 假设跳转到搜索结果页或者分类页带 filter
uni.navigateTo({
url: `/pages/mall/consumer/search?keyword=${encodeURIComponent(brand.name)}&type=brand&brandId=${brand.id}`
})
}
// 切换排序
@@ -635,15 +643,27 @@ const viewNewsDetail = (news: any) => {
}
// 下拉刷新
const onRefresh = () => {
const onRefresh = async () => {
refreshing.value = true
setTimeout(() => {
refreshing.value = false
uni.showToast({
title: '刷新成功',
icon: 'success'
})
}, 1500)
try {
// 重新加载数据
await initData()
} catch (e) {
console.error('刷新数据失败:', e)
} finally {
// 延迟关闭刷新动画,确保用户能看到刷新过程
setTimeout(() => {
refreshing.value = false
// 延迟显示提示,避免与动画冲突
setTimeout(() => {
uni.showToast({
title: '刷新成功',
icon: 'success'
})
}, 200)
}, 800)
}
}
// 加载更多
@@ -692,8 +712,7 @@ const loadMore = async () => {
}
// 添加到购物车
const addToCart = async (product: any, e: any | null = null) => {
e?.stopPropagation()
const addToCart = async (product: any) => {
uni.showLoading({ title: '添加中...' })
try {
// 尝试调用 Supabase 服务添加
@@ -750,44 +769,6 @@ const navigateToPrescription = () => uni.navigateTo({ url: '/pages/medicine/pres
const navigateToOTC = () => uni.navigateTo({ url: '/pages/medicine/otc' })
const navigateToHealthTools = () => uni.navigateTo({ url: '/pages/medicine/tools' })
const navigateToReminders = () => uni.navigateTo({ url: '/pages/user/reminders' })
// 扫码功能
const onScan = (e: any | null) => {
e?.stopPropagation()
uni.scanCode({
success: (res) => {
console.log('扫码结果:' + res.result)
uni.showToast({
title: '扫码成功',
icon: 'success'
})
// 这里可以添加基于扫码结果的逻辑,比如跳转到商品详情
},
fail: (err) => {
console.error('扫码失败:', err)
}
})
}
// 相机功能e: any | null) => {
e?.stopPropagation()
const onCamera = () => {
uni.chooseImage({
count: 1,
sourceType: ['camera'],
success: (res) => {
console.log('拍照结果:', res.tempFilePaths)
uni.showToast({
title: '拍摄成功',
icon: 'success'
})
// 这里可以添加基于图片的逻辑,比如图搜
},
fail: (err) => {
console.error('拍照失败:', err)
}
})
}
</script>
<style>
@@ -1004,10 +985,51 @@ const onCamera = () => {
margin-bottom: 20px;
}
.category-tabs-pills {
display: flex;
flex-direction: row;
background-color: #f0f2f5;
padding: 3px;
border-radius: 20px;
align-items: center;
}
.tab-pill {
padding: 6px 18px;
border-radius: 17px;
transition: all 0.3s;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.tab-pill.active {
background-color: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.tab-text {
font-size: 14px;
color: #888;
font-weight: 500;
}
.tab-pill.active .tab-text {
color: #4CAF50;
font-weight: 600;
}
.section-title {
font-size: 18px;
font-weight: bold;
color: #333;
color: #666;
transition: color 0.3s;
}
.section-title.active {
color: #4CAF50;
font-size: 20px;
}
.section-desc {

View File

@@ -214,6 +214,7 @@
<script setup lang="uts">
import { ref, reactive, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { supabaseService, type Notification, type ChatMessage } from '@/utils/supabaseService.uts'
// 响应式数据
@@ -265,7 +266,12 @@ const currentMessages = computed(() => {
onMounted(() => {
console.log('Messages Page Mounted')
initPage()
loadMessages()
// loadMessages() // 移至 onShow 调用
})
onShow(() => {
console.log('Messages Page Show')
loadMessages()
})
// 简单的日期格式化
@@ -332,37 +338,79 @@ const loadMessages = async () => {
// 2. 获取客服消息 (Chat)
const chats = await supabaseService.getUserChatMessages()
if (chats.length > 0) {
// 简单处理:将最新一条显示为"在线客服"会话
const lastMsg = chats[0]
serviceMessages.push({
id: lastMsg.id,
title: '在线客服',
role: '客服专员',
content: lastMsg.content,
lastMessage: lastMsg.content,
time: formatTime(lastMsg.created_at || ''),
read: lastMsg.is_read,
type: 'service',
avatar: '/static/icons/service-avatar.png',
online: true,
unreadCount: chats.filter((m: ChatMessage) => !m.is_read && !m.is_from_user).length,
tags: ['官方客服'],
icon: '👩‍💼',
color: '#2196F3',
important: false,
coupon: '',
expiry: '',
claimed: false,
order_no: '',
status: '',
statusText: ''
})
} else {
// 如果没有真实数据,保留一个默认客服入口
// console.log('Raw chats:', chats)
if (chats.length > 0) {
const currentUserId = supabaseService.getCurrentUserId()
const conversations = new Map<string, any>()
// 1. Group by conversation partner
for (const msg of chats) {
const partnerId = (msg.sender_id == currentUserId) ? msg.receiver_id : msg.sender_id
// Skip if partner is null/invalid
if (!partnerId) continue;
if (!conversations.has(partnerId)) {
conversations.set(partnerId, {
partnerId: partnerId,
lastMessage: msg,
unreadCount: 0
})
}
const conv = conversations.get(partnerId)
// Since chats are likely sorted desc, the first one seen is the latest.
// Just count unread: if I am the receiver and it's not read
if (msg.receiver_id == currentUserId && !msg.is_read) {
conv.unreadCount++
}
}
console.log('Conversations found:', conversations.size)
// 2. Fetch shop details for each conversation
const convList = Array.from(conversations.values())
const promises = convList.map(async (conv) => {
const shop = await supabaseService.getShopByMerchantId(conv.partnerId)
const shopName = shop ? shop.shop_name : '未知商家'
const shopAvatar = (shop && shop.logo_url) ? shop.logo_url : '/static/icons/shop-default.png'
return {
id: conv.partnerId, // Use partnerId as the ID for navigation
title: shopName,
role: '商家客服',
content: conv.lastMessage.content,
lastMessage: conv.lastMessage.content,
time: formatTime(conv.lastMessage.created_at || ''),
read: conv.unreadCount === 0,
type: 'service',
avatar: shopAvatar,
online: true,
unreadCount: conv.unreadCount,
tags: shop ? ['官方认证'] : [],
icon: '🏪',
color: '#FF9800',
important: false,
coupon: '',
expiry: '',
claimed: false,
order_no: '',
status: '',
statusText: ''
}
})
const renderedMessages = await Promise.all(promises)
serviceMessages.push(...renderedMessages)
}
// 如果没有消息,为了演示效果(或者真的需要),可以保留一个默认的系统客服
if (serviceMessages.length === 0) {
serviceMessages.push({
id: 'default_service',
title: '在线客服',
title: '平台客服',
role: '智能助手',
content: '有问题请随时联系我们',
lastMessage: '欢迎咨询',
@@ -429,8 +477,12 @@ const startChatWithService = (message: any) => {
message.unreadCount = 0
updateUnreadCount()
// 这里的 message.id 已经被我们修改为 conversation partner (merchantId)
// 所以参数传递需要调整
const merchantId = message.id === 'default_service' ? '' : message.id
uni.navigateTo({
url: `/pages/mall/consumer/chat?id=${message.id}&name=${encodeURIComponent(message.title)}&role=${encodeURIComponent(message.role)}`
url: `/pages/mall/consumer/chat?merchantId=${merchantId}&merchantName=${encodeURIComponent(message.title)}`
})
}

View File

@@ -158,9 +158,27 @@
<script setup lang="uts">
import { ref, reactive, onMounted, computed } from 'vue'
import { onShow, onLoad } from '@dcloudio/uni-app'
import { onShow, onLoad, onBackPress } from '@dcloudio/uni-app'
import { supabaseService } from '@/utils/supabaseService.uts'
// 拦截返回事件,避免跳回登录页
onBackPress((options) => {
if (options.from === 'navigateBack') {
const pages = getCurrentPages()
if (pages.length > 1) {
const prevPage = pages[pages.length - 2]
// 如果上一页是登录页,则重定向到个人中心
if (prevPage.route.includes('login')) {
uni.redirectTo({
url: '/pages/mall/consumer/profile'
})
return true
}
}
}
return false
})
// 响应式数据
const orders = ref<any[]>([])
const allOrdersList = ref<any[]>([]) // Store all fetched orders for client-side filtering
@@ -642,6 +660,8 @@ const goShopping = () => {
display: flex;
flex-direction: row;
padding: 0 10px;
/* 关键:确保宽度包含所有子元素,允许滚动 */
width: max-content;
min-width: 100%;
}

View File

@@ -0,0 +1,228 @@
<template>
<view class="points-page">
<view class="points-header">
<view class="points-info">
<text class="points-label">当前积分</text>
<text class="points-value">{{ totalPoints }}</text>
</view>
<view class="points-actions">
<button class="exchange-btn" @click="handleExchange">积分兑换</button>
</view>
</view>
<view class="records-section">
<text class="section-title">积分明细</text>
<view v-if="loading" class="loading-state">
<text>加载中...</text>
</view>
<view v-else-if="records.length === 0" class="empty-state">
<text class="empty-text">暂无积分记录</text>
</view>
<view v-else class="record-list">
<view v-for="item in records" :key="item.id" class="record-item">
<view class="record-left">
<text class="record-title">{{ item.description || getTypeText(item.type) }}</text>
<text class="record-time">{{ formatTime(item.created_at) }}</text>
</view>
<view class="record-right">
<text class="record-amount" :class="{ positive: item.points > 0, negative: item.points < 0 }">
{{ item.points > 0 ? '+' : '' }}{{ item.points }}
</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
type PointRecord = {
id: string
user_id: string
points: number
type: string
description: string
created_at: string
}
const totalPoints = ref<number>(0)
const records = ref<PointRecord[]>([])
const loading = ref<boolean>(true)
onMounted(() => {
loadData()
})
const loadData = async () => {
loading.value = true
await Promise.all([
loadPoints(),
loadRecords()
])
loading.value = false
}
const loadPoints = async () => {
// 调用 service 获取积分 (需要supabaseService支持)
// 暂时如果service没更新先用mock
// const res = await supabaseService.getUserPoints()
// if (res != null) totalPoints.value = res
try {
const points = await supabaseService.getUserPoints()
totalPoints.value = points
} catch (e) {
console.error('获取积分失败', e)
}
}
const loadRecords = async () => {
try {
const list = await supabaseService.getPointRecords()
records.value = list
} catch (e) {
console.error('获取积分记录失败', e)
}
}
const handleExchange = () => {
uni.showToast({
title: '积分商城开发中',
icon: 'none'
})
}
const getTypeText = (type: string): string => {
const map: Record<string, string> = {
'signin': '每日签到',
'shopping': '购物奖励',
'redeem': '积分兑换',
'admin': '系统调整',
'register': '注册赠送'
}
return map[type] || '积分变动'
}
const formatTime = (timeStr: string): string => {
if (!timeStr) return ''
const date = new Date(timeStr)
const y = date.getFullYear()
const m = (date.getMonth() + 1).toString().padStart(2, '0')
const d = date.getDate().toString().padStart(2, '0')
const hh = date.getHours().toString().padStart(2, '0')
const mm = date.getMinutes().toString().padStart(2, '0')
return `${y}-${m}-${d} ${hh}:${mm}`
}
</script>
<style>
.points-page {
min-height: 100vh;
background-color: #f5f7fa;
}
.points-header {
background-color: #ff5000;
padding: 30px 20px;
color: white;
display: flex;
justify-content: space-between;
align-items: center;
}
.points-info {
display: flex;
flex-direction: column;
}
.points-label {
font-size: 14px;
opacity: 0.9;
margin-bottom: 8px;
}
.points-value {
font-size: 36px;
font-weight: bold;
}
.exchange-btn {
background-color: rgba(255,255,255,0.2);
color: white;
border: 1px solid rgba(255,255,255,0.4);
font-size: 14px;
border-radius: 20px;
padding: 0 15px;
height: 32px;
line-height: 32px;
}
.records-section {
background-color: white;
margin-top: 10px;
padding: 0 16px;
min-height: 500px;
}
.section-title {
font-size: 16px;
font-weight: bold;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
display: block;
}
.record-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f9f9f9;
}
.record-left {
display: flex;
flex-direction: column;
gap: 4px;
}
.record-title {
font-size: 15px;
color: #333;
}
.record-time {
font-size: 12px;
color: #999;
}
.record-amount {
font-size: 16px;
font-weight: bold;
color: #333;
}
.record-amount.positive {
color: #ff5000;
}
.record-amount.negative {
color: #333;
}
.empty-state {
padding: 40px 0;
display: flex;
justify-content: center;
}
.empty-text {
color: #999;
font-size: 14px;
}
</style>

View File

@@ -40,6 +40,19 @@
<text class="function-content">{{ product.usage }}</text>
</view>
<!-- 优惠券入口 (新增) -->
<view class="coupon-entry" @click="showCouponModal" v-if="coupons.length > 0">
<view class="coupon-entry-left">
<text class="coupon-entry-label">优惠</text>
<view class="coupon-tags-row">
<text class="coupon-tag" v-for="(coupon, index) in coupons.slice(0, 2)" :key="index">
{{ coupon.name }}
</text>
</view>
</view>
<text class="coupon-arrow">领券 ></text>
</view>
<!-- 商品参数 -->
<view class="params-section" @click="showParamsModal">
<text class="params-title">商品参数</text>
@@ -96,6 +109,11 @@
<!-- 底部操作栏 -->
<view class="bottom-actions">
<view class="action-buttons">
<!-- 客服按钮 (新增) -->
<view class="action-btn" @click="contactMerchant">
<text class="action-icon">💬</text>
<text class="action-text">客服</text>
</view>
<view class="action-btn" @click="goToCart">
<text class="action-icon">🛒</text>
<text class="action-text">购物车</text>
@@ -174,6 +192,34 @@
</view>
</view>
</view>
<!-- 优惠券弹窗 (新增) -->
<view v-if="showCoupons" class="popup-mask" @click="hideCouponModal">
<view class="popup-content" @click.stop>
<view class="popup-header">
<text class="popup-title">优惠券</text>
<text class="close-btn" @click="hideCouponModal">×</text>
</view>
<scroll-view scroll-y="true" class="coupon-list-scroll">
<view v-for="coupon in coupons" :key="coupon.id" class="coupon-item">
<view class="coupon-left">
<text class="coupon-amount">
<text class="symbol">¥</text>{{ coupon.discount_value }}
</text>
<text class="coupon-cond">满{{ coupon.min_order_amount }}可用</text>
</view>
<view class="coupon-right">
<view class="coupon-info-text">
<text class="coupon-name">{{ coupon.name }}</text>
<text class="coupon-time">{{ formatDate(coupon.start_time) }}-{{ formatDate(coupon.end_time) }}</text>
</view>
<button class="coupon-btn" @click="claimCoupon(coupon)">领取</button>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
@@ -219,7 +265,10 @@ export default {
selectedSpec: '',
quantity: 1,
isFavorite: false,
showParams: false
showParams: false,
// 新增: 优惠券相关
coupons: [] as any[],
showCoupons: false
}
},
onLoad(options: any) {
@@ -398,9 +447,6 @@ export default {
}
} catch(e) {}
}
// Load SKUs
// this.loadProductSkus(productId) // If SKU logic exists
} else {
throw new Error('No product found')
}
@@ -416,6 +462,8 @@ export default {
// Load Merchant and SKUs
if (this.product.merchant_id) {
await this.loadMerchantInfo(this.product.merchant_id)
// 加载优惠券
this.loadCoupons()
}
if (this.product.id) {
this.loadProductSkus(this.product.id)
@@ -508,10 +556,74 @@ export default {
} catch (e) {
console.error('Fetch SKUs error', e)
}
},
// 新增:加载优惠券
async loadCoupons() {
if (!this.product.merchant_id) return
// Safety check for cached service definition
// @ts-ignore
if (typeof supabaseService.fetchShopCoupons === 'function') {
this.coupons = await supabaseService.fetchShopCoupons(this.product.merchant_id)
} else if (typeof supabaseService.getAvailableCoupons === 'function') {
this.coupons = await supabaseService.getAvailableCoupons(this.product.merchant_id)
} else {
console.warn('SupabaseService.fetchShopCoupons method missing in runtime. Please restart project.')
}
},
// 如果没有从数据库加载到SKU则不显示规格选择直接作为无规格商品添加
// 移除之前的Mock逻辑因为Mock的ID不符合UUID格式会导致数据库错误
// 新增:联系客服(商家)
contactMerchant() {
if (!supabaseService.getCurrentUserId()) {
uni.navigateTo({ url: '/pages/auth/login' })
return
}
// Navigate to chat
const merchId = this.merchant.user_id || this.merchant.id || this.product.merchant_id;
uni.navigateTo({
url: `/pages/mall/consumer/chat?merchantId=${merchId}&merchantName=${this.merchant.shop_name}`
})
},
// 新增:优惠券弹窗
showCouponModal() {
this.showCoupons = true
},
hideCouponModal() {
this.showCoupons = false
},
// 新增:领取优惠券
async claimCoupon(coupon: any) {
const userId = supabaseService.getCurrentUserId()
if (!userId) {
uni.navigateTo({ url: '/pages/auth/login' })
return
}
uni.showLoading({ title: '领取中' })
let success = false
// @ts-ignore
if (typeof supabaseService.claimShopCoupon === 'function') {
success = await supabaseService.claimShopCoupon(coupon.id, userId)
} else if (typeof supabaseService.claimCoupon === 'function') {
success = await supabaseService.claimCoupon(coupon.id, userId)
} else {
console.warn('claimCoupon method missing')
}
uni.hideLoading()
if (success) {
uni.showToast({ title: '领取成功', icon: 'success' })
} else {
uni.showToast({ title: '领取失败或已领取', icon: 'none' })
}
},
formatDate(dateStr: string): string {
if (!dateStr) return ''
const date = new Date(dateStr)
return `${date.getFullYear()}.${date.getMonth()+1}.${date.getDate()}`
},
onSwiperChange(e: any) {
@@ -549,41 +661,26 @@ export default {
return
}
// 显示加载中
uni.showLoading({
title: '添加中...'
})
uni.showLoading({ title: '添加中...' })
try {
// 调用 Supabase 服务添加到购物车
// 传递 productId, quantity, skuId
const success = await supabaseService.addToCart(
this.product.id,
this.quantity,
this.selectedSkuId
)
uni.hideLoading()
if (success) {
uni.showToast({
title: '已添加到购物车',
icon: 'success'
})
uni.showToast({ title: '已添加到购物车', icon: 'success' })
} else {
console.error('添加购物车返回失败')
uni.showToast({
title: '添加失败,请登录重试',
icon: 'none'
})
uni.showToast({ title: '添加失败,请登录重试', icon: 'none' })
}
} catch (e) {
uni.hideLoading()
console.error('添加购物车异常', e)
uni.showToast({
title: '添加异常',
icon: 'none'
})
uni.showToast({ title: '添加异常', icon: 'none' })
}
},
@@ -598,32 +695,20 @@ export default {
const sku = this.selectedSkuId ? this.productSkus.find(s => s.id === this.selectedSkuId) : null
// 调试:打印价格信息
console.log('立即购买 - 商品价格信息:')
console.log('SKU价格:', sku ? sku.price : '无SKU')
console.log('商品价格:', this.product.price)
console.log('选择的价格:', (sku ? sku.price : this.product.price))
console.log('数量:', this.quantity)
const selectedItem = {
id: this.selectedSkuId,
product_id: this.product.id,
sku_id: this.selectedSkuId,
product_name: this.product.name,
product_image: (sku && sku.image_url) ? sku.image_url : this.product.images[0],
sku_specifications: sku ? sku.specifications : {},
price: Number(parseFloat((sku ? sku.price : this.product.price).toString()).toFixed(2)),
quantity: Number(this.quantity)
}
const selectedItem = {
id: this.selectedSkuId,
product_id: this.product.id,
sku_id: this.selectedSkuId,
product_name: this.product.name,
product_image: (sku && sku.image_url) ? sku.image_url : this.product.images[0],
sku_specifications: sku ? sku.specifications : {},
price: Number(parseFloat((sku ? sku.price : this.product.price).toString()).toFixed(2)),
quantity: Number(this.quantity)
}
// 调试:打印最终传递的数据
console.log('立即购买 - 传递的商品数据:', selectedItem)
// 使用Storage传递数据避免EventChannel可能的问题
uni.setStorageSync('checkout_type', 'buy_now')
uni.setStorageSync('checkout_items', JSON.stringify([selectedItem]))
// 跳转到订单确认页
uni.navigateTo({
url: '/pages/mall/consumer/checkout',
success: (res) => {
@@ -634,14 +719,7 @@ export default {
})
},
goToShop() {
uni.navigateTo({
url: `/pages/mall/consumer/shop-detail?merchantId=${this.merchant.id}`
})
},
checkFavoriteStatus(id: string) {
// console.log('product-detail checkFavoriteStatus id:', id)
this.checkFavorite(id)
},
@@ -652,65 +730,46 @@ export default {
async toggleFavorite() {
if (!this.product.id) return
// 显示loading
uni.showLoading({ title: '处理中' })
try {
// 记录操作前的状态
const wasFavorite = this.isFavorite
// 执行切换返回的是最新的状态true=已收藏false=未收藏)
const isNowFavorite = await supabaseService.toggleFavorite(this.product.id)
uni.hideLoading()
if (isNowFavorite !== wasFavorite) {
// 状态发生了改变,说明操作成功
this.isFavorite = isNowFavorite
uni.showToast({
title: isNowFavorite ? '收藏成功' : '已取消收藏',
icon: 'success'
})
} else {
// 状态未改变,说明操作失败
uni.showToast({
title: '操作失败',
icon: 'none'
})
// 确保状态同步
uni.showToast({ title: '操作失败', icon: 'none' })
this.checkFavoriteStatus(this.product.id)
}
} catch (e) {
uni.hideLoading()
console.error('Toggle favorite failed', e)
uni.showToast({
title: '操作异常',
icon: 'none'
})
uni.showToast({ title: '操作异常', icon: 'none' })
}
},
goToHome() {
uni.switchTab({
url: '/pages/mall/consumer/home'
})
uni.switchTab({ url: '/pages/mall/consumer/home' })
},
goToShop() {
if (this.merchant.user_id) {
uni.navigateTo({
url: `/pages/mall/consumer/shop-detail?merchantId=${this.merchant.user_id}`
url: `/pages/mall/consumer/shop-detail?merchantId=${this.merchant.id}`
})
}
},
goToCart() {
uni.switchTab({
url: '/pages/mall/consumer/cart'
})
uni.switchTab({ url: '/pages/mall/consumer/cart' })
},
// 数量选择相关方法
decreaseQuantity() {
if (this.quantity > 1) {
this.quantity--
@@ -722,37 +781,23 @@ export default {
if (this.quantity < maxQuantity) {
this.quantity++
} else {
uni.showToast({
title: `最多只能购买${maxQuantity}件`,
icon: 'none'
})
uni.showToast({ title: `最多只能购买${maxQuantity}件`, icon: 'none' })
}
},
validateQuantity() {
// 确保数量是数字
let num = parseInt(this.quantity)
if (isNaN(num)) {
num = 1
}
// 限制在1和最大库存之间
if (isNaN(num)) num = 1
const maxQuantity = this.getMaxQuantity()
if (num < 1) {
num = 1
} else if (num > maxQuantity) {
if (num < 1) num = 1
else if (num > maxQuantity) {
num = maxQuantity
uni.showToast({
title: `最多只能购买${maxQuantity}件`,
icon: 'none'
})
uni.showToast({ title: `最多只能购买${maxQuantity}件`, icon: 'none' })
}
this.quantity = num
},
getMaxQuantity() {
// 如果有选择SKU使用SKU的库存否则使用商品总库存
if (this.selectedSkuId) {
const sku = this.productSkus.find(s => s.id === this.selectedSkuId)
if (sku) return sku.stock
@@ -857,7 +902,7 @@ export default {
padding: 30rpx;
margin-bottom: 20rpx;
display: flex;
flex-direction: row; /* 显式横向排列 */
flex-direction: row;
align-items: center;
}
@@ -871,7 +916,7 @@ export default {
.shop-details {
flex: 1;
display: flex;
flex-direction: column; /* 内部信息保持纵向,或者根据需要改为横向 */
flex-direction: column;
justify-content: center;
}
@@ -884,7 +929,7 @@ export default {
.shop-stats-row {
display: flex;
flex-direction: row; /* 显式横向排列 */
flex-direction: row;
align-items: center;
}
@@ -899,6 +944,142 @@ export default {
color: #666;
}
/* Coupon Entry Styles */
.coupon-entry {
background-color: #fff;
padding: 30rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.coupon-entry-left {
display: flex;
align-items: center;
flex: 1;
}
.coupon-entry-label {
font-size: 30rpx;
color: #333;
width: 120rpx;
font-weight: bold;
}
.coupon-tags-row {
flex: 1;
display: flex;
flex-direction: row;
}
.coupon-tag {
font-size: 20rpx;
color: #ff4444;
border: 1px solid #ff4444;
padding: 2rpx 10rpx;
border-radius: 4rpx;
margin-right: 15rpx;
}
.coupon-arrow {
font-size: 26rpx;
color: #999;
}
/* Modal Popup Styles */
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 1000;
}
.popup-content {
background-color: #fff;
width: 100%;
max-height: 80vh;
border-radius: 20rpx 20rpx 0 0;
padding: 30rpx;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #eee;
}
.popup-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.close-btn {
font-size: 48rpx;
color: #999;
}
.coupon-list-scroll {
max-height: 60vh;
}
.coupon-item {
display: flex;
background-color: #fff5f5;
border-radius: 10rpx;
padding: 20rpx;
margin-bottom: 20rpx;
}
.coupon-left {
width: 180rpx;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border-right: 1px dashed #ffccc7;
color: #ff4444;
}
.coupon-amount {
font-size: 40rpx;
font-weight: bold;
}
.symbol {
font-size: 24rpx;
}
.coupon-cond {
font-size: 22rpx;
margin-top: 5rpx;
}
.coupon-right {
flex: 1;
padding-left: 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.coupon-info-text {
display: flex;
flex-direction: column;
}
.coupon-name {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 10rpx;
}
.coupon-time {
font-size: 22rpx;
color: #999;
}
.coupon-btn {
background-color: #ff4444;
color: #fff;
font-size: 24rpx;
padding: 0 24rpx;
height: 50rpx;
line-height: 50rpx;
border-radius: 25rpx;
margin: 0;
}
.spec-section {
background-color: #fff;
padding: 30rpx;
@@ -1012,21 +1193,21 @@ export default {
padding: 10rpx 20rpx;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: row; /* 显式设置横向排列 */
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.action-buttons {
display: flex;
flex-direction: row; /* 显式设置横向排列 */
flex-direction: row;
align-items: center;
margin-right: 20rpx;
}
.action-btn {
display: flex;
flex-direction: column; /* 图标文字保持纵向 */
flex-direction: column;
align-items: center;
justify-content: center;
margin-right: 20rpx;
@@ -1046,7 +1227,7 @@ export default {
.btn-group {
flex: 1;
display: flex;
flex-direction: row; /* 显式设置横向排列 */
flex-direction: row;
align-items: center;
}
@@ -1105,11 +1286,6 @@ export default {
color: #333;
}
.close-btn {
font-size: 48rpx;
color: #999;
}
.spec-list {
max-height: 60vh;
overflow-y: auto;
@@ -1306,4 +1482,4 @@ export default {
margin-left: 20rpx;
}
}
</style>
</style>

View File

@@ -20,9 +20,9 @@
<text class="nav-stat-value">{{ userStats.points }}</text>
</view>
<view class="nav-stat-item" @click="goToWallet">
<view class="nav-stat-item">
<text class="nav-stat-label">余额</text>
<text class="nav-stat-value">¥{{ userStats.balance }}</text>
<text class="nav-stat-value" @click="goToWallet">¥{{ userStats.balance }}</text>
</view>
<view class="nav-stat-item" @click="goToCoupons">
@@ -74,9 +74,9 @@
<text class="service-icon">📝</text>
<text class="service-text">评价</text>
</view>
<view class="service-item" @click="goToMySubscriptions">
<text class="service-icon">🧩</text>
<text class="service-text">我的订阅</text>
<view class="service-item" @click="goToFollowedShops">
<text class="service-icon"></text>
<text class="service-text">关注店铺</text>
</view>
<view class="service-item" @click="goToSubscriptions">
<text class="service-icon">📱</text>
@@ -249,7 +249,6 @@
<script>
import { UserType, OrderType } from '@/types/mall-types.uts'
import supabaseService from '@/utils/supabaseService.uts'
import { getCurrentUser } from '@/utils/store.uts'
type UserStatsType = {
points: number
@@ -423,45 +422,86 @@ export default {
const systemInfo = uni.getSystemInfoSync()
this.statusBarHeight = systemInfo.statusBarHeight || 0
},
loadUserProfile() {
// 模拟加载用户信息
this.userInfo = {
id: 'user_001',
phone: '13800138000',
email: 'user@example.com',
nickname: '张三',
avatar_url: '/static/avatar1.jpg',
gender: 1,
user_type: 1,
status: 1,
created_at: '2023-06-15T10:30:00'
async loadUserProfile() {
try {
// 获取用户资料
const profile = await supabaseService.getUserProfile()
if (profile != null) {
// 映射字段
let uId = ''
let uPhone = ''
let uEmail = ''
let uNickname = ''
let uAvatar = ''
let uGender = 0
if (profile instanceof UTSJSONObject) {
uId = profile.getString('user_id') || ''
uPhone = profile.getString('phone') || ''
uEmail = profile.getString('email') || ''
uNickname = profile.getString('nickname') || ''
uAvatar = profile.getString('avatar_url') || ''
uGender = profile.getNumber('gender') || 0
} else {
uId = (profile['user_id'] as string) || ''
uPhone = (profile['phone'] as string) || ''
uEmail = (profile['email'] as string) || ''
uNickname = (profile['nickname'] as string) || ''
uAvatar = (profile['avatar_url'] as string) || ''
uGender = (profile['gender'] as number) || 0
}
// 如果昵称为空,使用手机号脱敏显示
if (!uNickname && uPhone) {
uNickname = uPhone.substring(0, 3) + '****' + uPhone.substring(7)
}
this.userInfo = {
id: uId,
phone: uPhone,
email: uEmail,
nickname: uNickname || '微信用户',
avatar_url: uAvatar || '/static/default-avatar.png',
gender: uGender,
user_type: 1,
status: 1,
created_at: new Date().toISOString()
} as UserType
} else {
// 如果获取失败未登录或无档案尝试获取当前登录ID
const userId = supabaseService.getCurrentUserId()
if (userId != null) {
this.userInfo.id = userId
this.userInfo.nickname = '用户' + userId.substring(0, 4)
} else {
this.userInfo.nickname = '未登录'
}
}
// 获取积分和余额(并行获取)
const [balance, points] = await Promise.all([
supabaseService.getUserBalance(),
supabaseService.getUserPoints()
])
this.userStats = {
points: points,
balance: balance,
level: this.calculateLevel(points) // 根据积分计算等级
} as UserStatsType
} catch (e) {
console.error('加载用户信息失败', e)
// 保持默认或显示错误
}
this.userStats = {
points: 1580,
balance: 268.50,
level: 3
}
// orderCounts 将通过 loadOrders 从真实数据获取
// init with zeros
this.orderCounts = {
total: 0,
pending: 0,
toship: 0,
shipped: 0,
review: 0
}
this.serviceCounts = {
coupons: 5,
favorites: 12
}
// recentOrders 将通过 loadOrders 从真实数据获取
this.recentOrders = []
this.loadConsumptionStats()
},
calculateLevel(points: number): number {
if (points < 1000) return 0
if (points < 5000) return 1
if (points < 20000) return 2
if (points < 50000) return 3
return 4
},
loadConsumptionStats() {
@@ -503,11 +543,15 @@ export default {
this.updateCouponCount() // 更新优惠券数量
},
updateCouponCount() {
// 从本地存储读取领取的优惠券数量并叠加到基础数量上
const baseCoupons = 5
const claimedCoupons = uni.getStorageSync('claimedCoupons') || 0
this.serviceCounts.coupons = baseCoupons + (claimedCoupons as number)
async updateCouponCount() {
// 从 Supabase 获取真实的优惠券数量
try {
const count = await supabaseService.getUserCouponCount()
this.serviceCounts.coupons = count
} catch (e) {
console.error('获取优惠券数量失败', e)
this.serviceCounts.coupons = 0
}
},
getUserLevel(): string {
@@ -585,39 +629,10 @@ export default {
// 跳转钱包
goToWallet() {
const user = getCurrentUser()
if (!user) {
uni.showToast({
title: '请先登录',
icon: 'none'
})
uni.navigateTo({
url: '/pages/user/login'
})
return
}
uni.navigateTo({
url: '/pages/mall/consumer/wallet'
})
},
// 跳转积分
goToPoints() {
const user = getCurrentUser()
if (!user) {
uni.showToast({
title: '请先登录',
icon: 'none'
})
uni.navigateTo({
url: '/pages/user/login'
})
return
}
uni.navigateTo({
url: '/pages/mall/consumer/points'
})
},
goToOrders(type: string) {
uni.navigateTo({
@@ -670,6 +685,12 @@ export default {
url: '/pages/mall/consumer/coupons'
})
},
goToPoints() {
uni.navigateTo({
url: '/pages/mall/consumer/points/index'
})
},
goToAddress() {
// 暂时跳转到设置页的地址管理
@@ -711,6 +732,11 @@ export default {
url: '/pages/mall/consumer/subscription/my-subscriptions'
})
},
goToFollowedShops() {
uni.navigateTo({
url: '/pages/mall/consumer/subscription/followed-shops'
})
},
goToSubscriptions() {
uni.navigateTo({
url: '/pages/mall/consumer/subscription/plan-list'

View File

@@ -0,0 +1,263 @@
<template>
<view class="red-packets-page">
<view class="tab-header">
<text
class="tab-item"
:class="{ active: currentTab === 0 }"
@click="currentTab = 0">未使用</text>
<text
class="tab-item"
:class="{ active: currentTab === 1 }"
@click="currentTab = 1">已使用/过期</text>
</view>
<view v-if="loading" class="loading-state">
<text>加载中...</text>
</view>
<scroll-view v-else class="packet-list" scroll-y>
<view v-if="filteredPackets.length === 0" class="empty-state">
<text class="empty-text">暂无相关红包</text>
</view>
<view v-else v-for="item in filteredPackets" :key="item.id" class="packet-item" :class="{ disabled: item.status !== 0 }">
<view class="packet-left">
<text class="packet-amount">¥<text class="amount-num">{{ item.amount }}</text></text>
<text class="packet-condition">无门槛</text>
</view>
<view class="packet-right">
<view class="packet-info">
<text class="packet-name">{{ item.name }}</text>
<text class="packet-date">有效期至 {{ formatTime(item.expire_at) }}</text>
</view>
<view class="packet-action">
<button v-if="item.status === 0" class="use-btn" @click="usePacket(item)">立即使用</button>
<text v-else class="status-text">{{ getStatusText(item.status) }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
type RedPacket = {
id: string
user_id: string
amount: number
name: string
status: number // 0: unused, 1: used, 2: expired
expire_at: string
created_at: string
}
const loading = ref(true)
const currentTab = ref(0)
const packets = ref<RedPacket[]>([])
const filteredPackets = computed((): RedPacket[] => {
if (currentTab.value === 0) {
return packets.value.filter((p:RedPacket):boolean => p.status === 0)
} else {
return packets.value.filter((p:RedPacket):boolean => p.status !== 0)
}
})
onMounted(() => {
loadData()
})
const loadData = async () => {
loading.value = true
try {
const rawList = await supabaseService.getUserRedPackets()
packets.value = rawList.map((item: any): RedPacket => {
let id = ''
let amount = 0
let name = ''
let status = 0
let expireAt = ''
let createdAt = ''
if (item instanceof UTSJSONObject) {
id = item.getString('id') || ''
amount = item.getNumber('amount') || 0
name = item.getString('name') || ''
status = item.getNumber('status') || 0
expireAt = item.getString('expire_at') || ''
createdAt = item.getString('created_at') || ''
} else {
id = (item['id'] as string) || ''
amount = (item['amount'] as number) || 0
name = (item['name'] as string) || ''
status = (item['status'] as number) || 0
expireAt = (item['expire_at'] as string) || ''
createdAt = (item['created_at'] as string) || ''
}
return {
id: id,
user_id: '',
amount: amount,
name: name,
status: status,
expire_at: expireAt,
created_at: createdAt
} as RedPacket
})
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const usePacket = (item: RedPacket) => {
uni.switchTab({
url: '/pages/mall/consumer/index'
})
}
const getStatusText = (status: number): string => {
if (status === 1) return '已使用'
if (status === 2) return '已过期'
return ''
}
const formatTime = (timeStr: string): string => {
if (!timeStr) return '永久有效'
const date = new Date(timeStr)
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`
}
</script>
<style>
.red-packets-page {
background-color: #f5f5f5;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.tab-header {
display: flex;
background-color: #fff;
padding: 10px 0;
position: sticky;
top: 0;
z-index: 10;
}
.tab-item {
flex: 1;
text-align: center;
font-size: 14px;
color: #666;
padding-bottom: 8px;
border-bottom: 2px solid transparent;
}
.tab-item.active {
color: #ff5000;
border-bottom-color: #ff5000;
font-weight: bold;
}
.packet-list {
flex: 1;
padding: 15px;
}
.packet-item {
display: flex;
background-color: #fff;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.packet-item.disabled .packet-left,
.packet-item.disabled .packet-name,
.packet-item.disabled .amount-num {
color: #999;
background-color: #f0f0f0;
}
.packet-item.disabled .packet-left {
background-color: #e0e0e0;
}
.packet-left {
width: 100px;
background-color: #fff5f0;
color: #ff5000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 15px 0;
}
.packet-amount {
font-size: 14px;
}
.amount-num {
font-size: 28px;
font-weight: bold;
}
.packet-condition {
font-size: 12px;
margin-top: 4px;
}
.packet-right {
flex: 1;
padding: 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
.packet-info {
display: flex;
flex-direction: column;
}
.packet-name {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
.packet-date {
font-size: 12px;
color: #999;
}
.use-btn {
font-size: 12px;
background-color: #ff5000;
color: #fff;
border-radius: 15px;
padding: 4px 12px;
line-height: 1.5;
}
.status-text {
font-size: 14px;
color: #999;
}
.loading-state, .empty-state {
padding: 40px;
align-items: center;
justify-content: center;
display: flex;
}
</style>

View File

@@ -16,7 +16,7 @@
:value="searchKeyword"
@input="onInput"
@confirm="onSearch"
placeholder="请输入品名称、症状或品牌"
placeholder="请输入品名称、店铺"
placeholder-class="placeholder"
:focus="autoFocus"
/>
@@ -152,8 +152,31 @@
<!-- 搜索结果 -->
<view v-if="showResults" class="search-results">
<!-- 店铺搜索结果 -->
<view v-if="searchShopResults.length > 0" class="shop-results-section">
<view class="section-top">
<text class="result-title-sm">相关店铺</text>
</view>
<scroll-view scroll-x class="shop-list-scroll">
<view class="shop-list-row">
<view
v-for="shop in searchShopResults"
:key="shop.id"
class="shop-card"
@click="viewShopDetail(shop)"
>
<image class="shop-logo" :src="shop.logo" mode="aspectFill" />
<view class="shop-info">
<text class="shop-name-txt">{{ shop.name }}</text>
<text class="shop-products-txt">共{{ shop.productCount }}件商品</text>
</view>
</view>
</view>
</scroll-view>
</view>
<view class="results-header">
<text class="results-title">搜索结果</text>
<text class="results-title">商品结果</text>
<view class="filter-tabs">
<text
class="filter-tab"
@@ -250,23 +273,15 @@ const hotSearchList = ref<any[]>([])
const guessList = ref<any[]>([])
const allGuessItems = ref<any[]>([]) // 缓存所有猜你喜欢商品
const searchResults = ref<any[]>([])
const searchShopResults = ref<any[]>([]) // 搜索到的店铺
// 搜索建议
const searchSuggestions = computed(() => {
if (!searchKeyword.value) return []
// 简单模拟
return [
`${searchKeyword.value}胶囊`,
`${searchKeyword.value}颗粒`,
`${searchKeyword.value}片`,
`儿童${searchKeyword.value}`
]
})
onMounted(() => {
initPage()
})
const initPage = () => {
try {
const systemInfo = uni.getSystemInfoSync()
@@ -287,9 +302,11 @@ const initPage = () => {
const keyword = decodeURIComponent(options['keyword'])
searchKeyword.value = keyword
if (options['type'] === 'family') {
// 如果是家庭常备药类型,直接添加到历史并搜索
addToHistory(keyword)
if (options['type'] === 'family' || options['type'] === 'brand') {
// 如果是家庭常备药或品牌类型,直接添加到历史并搜索
if (options['type'] === 'family') {
addToHistory(keyword)
}
// 立即显示结果区域并设置为加载中
showResults.value = true
loading.value = true
@@ -397,12 +414,44 @@ const deleteHistoryItem = (index: number) => {
saveSearchHistory()
}
// 搜索建议 - 改为实时获取
const searchSuggestions = ref<string[]>([])
let suggestTimer = 0
const fetchSuggestions = async (kw: string) => {
if (!kw || showResults.value) return
// 简单搜索前5个相关商品作为建议
try {
const res = await supabaseService.searchProducts(kw.trim(), 1, 5)
if (res.data.length > 0) {
// 去重
const names = res.data.map((p:any) => p.name as string)
// @ts-ignore
searchSuggestions.value = [...new Set(names)]
} else {
searchSuggestions.value = []
}
} catch(e) {
searchSuggestions.value = []
}
}
// 搜索逻辑
const onInput = (e: any) => {
searchKeyword.value = e.detail.value
if (!searchKeyword.value) {
const val = e.detail.value
searchKeyword.value = val
if (!val) {
showResults.value = false
searchSuggestions.value = []
return
}
// Debounce suggestion search
if (suggestTimer > 0) clearTimeout(suggestTimer)
suggestTimer = setTimeout(() => {
fetchSuggestions(val)
}, 300)
}
const clearSearch = () => {
@@ -460,9 +509,29 @@ const performSearch = async () => {
}
try {
const response = await supabaseService.searchProducts(keyword, currentPage.value, 20, sortBy, ascending)
// 并行请求:商品搜索 + 店铺搜索
const [prodResp, shopResp] = await Promise.all([
supabaseService.searchProducts(keyword, currentPage.value, 20, sortBy, ascending),
// 只有第一页搜索且非价格排序时搜索店铺,避免重复和无关搜索
currentPage.value === 1 && activeSort.value === 'default'
? supabaseService.searchShops(keyword)
: Promise.resolve({ data: [], total: 0, page: 1, limit: 0, hasmore: false })
])
searchResults.value = response.data.map((p: any) => {
// 处理店铺结果
if (shopResp.data.length > 0) {
searchShopResults.value = shopResp.data.map((s: any) => ({
id: s.id,
name: s.shop_name,
logo: s.shop_logo || '/static/shop_logo_default.png',
productCount: s.product_count || 0
}))
} else {
searchShopResults.value = []
}
// 处理商品结果
searchResults.value = prodResp.data.map((p: any) => {
let tag = ''
if (p.tags) {
try {
@@ -482,7 +551,7 @@ const performSearch = async () => {
}
})
hasMore.value = response.hasmore
hasMore.value = prodResp.hasmore
} catch(e) {
console.error('Search failed', e)
} finally {
@@ -578,6 +647,12 @@ const viewProductDetail = (item: any) => {
})
}
const viewShopDetail = (shop: any) => {
uni.navigateTo({
url: `/pages/mall/consumer/shop-detail?id=${shop.id}`
})
}
// 添加到购物车 - 搜索列表无法选择规格,跳转详情页
const addToCart = (product: any) => {
uni.showToast({ title: '请选择规格', icon: 'none' })
@@ -607,7 +682,15 @@ const goBack = () => {
searchKeyword.value = ''
} else {
// 如果在搜索初始页,则返回上一页
uni.navigateBack()
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
// 如果只有一页(由于深链接或重定向),返回首页
uni.switchTab({
url: '/pages/mall/consumer/index'
})
}
}
}
</script>
@@ -621,6 +704,75 @@ const goBack = () => {
flex-direction: column;
}
/* 店铺搜索结果 */
.shop-results-section {
background-color: #fff;
margin-bottom: 10px;
padding: 10px 0;
}
.section-top {
padding: 0 12px 10px;
}
.result-title-sm {
font-size: 14px;
font-weight: bold;
color: #333;
}
.shop-list-scroll {
width: 100%;
white-space: nowrap;
}
.shop-list-row {
display: flex;
flex-direction: row;
padding: 0 12px;
}
.shop-card {
display: flex;
flex-direction: column;
align-items: center;
width: 80px;
margin-right: 15px;
background-color: #f9f9f9;
padding: 10px 5px;
border-radius: 8px;
}
.shop-logo {
width: 48px;
height: 48px;
border-radius: 24px;
margin-bottom: 5px;
border: 1px solid #f0f0f0;
background-color: white;
}
.shop-info {
width: 100%;
text-align: center;
}
.shop-name-txt {
font-size: 12px;
color: #333;
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: block;
margin-bottom: 2px;
}
.shop-products-txt {
font-size: 10px;
color: #999;
}
/* 头部样式 */
.search-header {
background-color: #ffffff;

View File

@@ -12,9 +12,34 @@
<text class="stat-item">销量 {{ merchant.total_sales }}</text>
</view>
</view>
<button class="follow-btn" @click="toggleFollow">{{ isFollowed ? '已关注' : '+ 关注' }}</button>
<view class="shop-actions">
<view class="action-btn chat-btn" @click="contactService">
<text class="action-text">客服</text>
</view>
<view class="action-btn follow-btn" @click="toggleFollow">
<text class="action-text" :class="{ followed: isFollowed }">{{ isFollowed ? '已关注' : '+ 关注' }}</text>
</view>
</view>
</view>
<text class="shop-desc">{{ merchant.shop_description || '这家店很懒,什么都没写~' }}</text>
<!-- 优惠券列表 (新增) -->
<view class="shop-coupons" v-if="coupons.length > 0">
<scroll-view scroll-x="true" class="coupon-scroll" show-scrollbar="false">
<view class="coupon-wrapper">
<view class="coupon-card" v-for="coupon in coupons" :key="coupon.id" @click="claimCoupon(coupon)">
<view class="coupon-left">
<text class="coupon-amount"><text style="font-size:10px">¥</text>{{ coupon.discount_value }}</text>
<text class="coupon-cond" v-if="parseFloat(String(coupon.min_order_amount)) > 0">满{{ coupon.min_order_amount }}</text>
<text class="coupon-cond" v-else>无门槛</text>
</view>
<view class="coupon-right">
<text class="coupon-btn-label">领取</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
<!-- 商品列表 -->
@@ -43,9 +68,17 @@
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { MerchantType, ProductType } from '@/types/mall-types.uts'
import { supabaseService } from '@/utils/supabaseService.uts'
// 分页相关状态
const currentPage = ref(1)
const pageSize = ref(6) // 默认显示六个
const hasMore = ref(true)
const isLoading = ref(false)
const currentMerchantId = ref('')
const merchant = ref<MerchantType>({
id: '',
user_id: '',
@@ -63,21 +96,72 @@ const merchant = ref<MerchantType>({
const products = ref<ProductType[]>([])
const isFollowed = ref(false)
const coupons = ref<any[]>([]) // 新增优惠券
onMounted(() => {
const pages = getCurrentPages()
const options = pages[pages.length - 1].options as any
const merchantId = options['merchantId'] as string
// Search传递的是 id (shop_id), 其他地方可能传递 merchantId
const paramId = (options['merchantId'] || options['id']) as string
if (merchantId) {
loadShopData(merchantId)
loadShopProducts(merchantId)
if (paramId) {
console.log('Page mounted with params:', paramId)
// 优先加载店铺信息
loadShopData(paramId).then(() => {
// 加载成功后,使用确定的 merchant_id 来查询关联数据 (商品/优惠券通常是关联在 merchant_id 上的)
const realMerchantId = merchant.value.user_id // 这里 user_id 映射了 DB 中的 merchant_id
if (realMerchantId && realMerchantId !== '') {
console.log('Chain loading products for Corrected Merchant ID:', realMerchantId)
currentMerchantId.value = realMerchantId // 更新当前上下文ID
loadShopProducts(realMerchantId)
loadCoupons(realMerchantId)
} else {
// 防御性策略:如果没能获取 merchant_id尝试用传入 ID
console.warn('Shop load failed or id empty, fallback using original id:', paramId)
currentMerchantId.value = paramId
loadShopProducts(paramId)
loadCoupons(paramId)
}
})
} else {
console.error('No ID passed to shop-detail')
uni.showToast({title: '参数错误', icon: 'error'})
}
})
onPullDownRefresh(() => {
// 下拉刷新
currentPage.value = 1
hasMore.value = true
isLoading.value = false
if (currentMerchantId.value != '') {
const id = currentMerchantId.value
// 重新加载所有数据
loadShopData(id)
loadCoupons(id)
loadShopProducts(id)
} else {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 500)
}
})
onReachBottom(() => {
// 触底加载更多
if (hasMore.value && !isLoading.value && currentMerchantId.value != '') {
console.log('Reach bottom, loading more...')
loadShopProducts(currentMerchantId.value)
}
})
const loadShopData = async (id: string) => {
console.log('Loading shop data for:', id)
const shop = await supabaseService.getShopByMerchantId(id)
if (shop) {
console.log('Shop loaded successfully:', shop.shop_name)
merchant.value = {
id: shop.id,
user_id: shop.merchant_id, // 映射关系
@@ -92,41 +176,132 @@ const loadShopData = async (id: string) => {
total_sales: shop.total_sales || 0,
created_at: shop.created_at || ''
}
// 检查关注状态
checkFollowStatus(shop.id)
} else {
console.warn('Shop data is null for ID:', id)
uni.showToast({
title: '未找到店铺信息',
icon: 'none',
duration: 3000
})
}
}
const loadCoupons = async (id: string) => {
// 安全检查,防止因编译器可以缓存导致的方法未定义错误
// @ts-ignore
if (typeof supabaseService.fetchShopCoupons === 'function') {
coupons.value = await supabaseService.fetchShopCoupons(id)
} else if (typeof supabaseService.getAvailableCoupons === 'function') {
// Fallback to old name
coupons.value = await supabaseService.getAvailableCoupons(id)
} else {
console.warn('SupabaseService.fetchShopCoupons method missing. Please rebuild project.')
}
}
const claimCoupon = async (coupon: any) => {
const userId = supabaseService.getCurrentUserId()
if (!userId) {
uni.navigateTo({ url: '/pages/auth/login' })
return
}
uni.showLoading({ title: '领取中' })
let success = false
// @ts-ignore
if (typeof supabaseService.claimShopCoupon === 'function') {
success = await supabaseService.claimShopCoupon(coupon.id, userId)
} else if (typeof supabaseService.claimCoupon === 'function') {
success = await supabaseService.claimCoupon(coupon.id, userId)
} else {
console.warn('claimCoupon not found')
}
uni.hideLoading()
if (success) {
uni.showToast({ title: '领取成功', icon: 'success' })
} else {
uni.showToast({ title: '领取失败', icon: 'none' })
}
}
const loadShopProducts = async (id: string) => {
const res = await supabaseService.getProductsByMerchantId(id)
if (res.data.length > 0) {
products.value = res.data.map((item): ProductType => {
if (isLoading.value) return
isLoading.value = true
// 保存当前使用的MerchantID供下拉/触底使用
if (currentPage.value === 1) {
currentMerchantId.value = id
}
console.log(`shop-detail loadShopProducts for: ${id} page: ${currentPage.value}`)
// @ts-ignore
if (typeof supabaseService.getProductsByMerchantId !== 'function') {
console.error('getProductsByMerchantId missing')
isLoading.value = false
uni.stopPullDownRefresh()
return
}
// 传入分页参数
const res = await supabaseService.getProductsByMerchantId(id, currentPage.value, pageSize.value)
console.log(`shop-detail getProductsByMerchantId result count: ${res.data?.length}`)
const rawList = res.data
if (rawList != null && Array.isArray(rawList) && rawList.length > 0) {
const list = rawList.map((item): ProductType => {
// 解析图片数组
let images: string[] = []
if (item.image_urls) {
// 注意类型转换UTS中 any 到具体的类型转换
// 1. 尝试 main_image_url
if (item.main_image_url != null && item.main_image_url !== '') {
images.push(item.main_image_url!)
}
// 2. 尝试 image_urls (如果 main 为空,或者需要展示多图)
if (item.image_urls != null) {
try {
const rawUrl = item.image_urls
if (Array.isArray(rawUrl)) {
// 已经是数组
images = rawUrl as string[]
const arr = rawUrl as string[]
if (arr.length > 0) {
// 如果目前没有图,就全加进去;如果有图(main_image),考虑是否去重
if (images.length == 0) images.push(...arr)
}
} else if (typeof rawUrl === 'string') {
if (rawUrl.startsWith('[')) {
images = JSON.parse(rawUrl) as string[]
const parsed = JSON.parse(rawUrl)
if (Array.isArray(parsed)) {
const arr = parsed as string[]
if (images.length == 0) images.push(...arr)
}
} else {
// 单个图片路径字符串
images = [rawUrl]
// 单个图片路径字符串,如果跟 main_image 不一样才加
if (images.indexOf(rawUrl) === -1) images.push(rawUrl)
}
}
} catch(e) {
console.error('解析图片数组失败:', e)
// 降级处理:尝试直接作为单个图片
if (typeof item.image_urls === 'string') {
images = [item.image_urls!]
}
}
}
if (images.length === 0 && item.main_image_url) {
images.push(item.main_image_url!)
// 没有任何图片则使用默认
if (images.length === 0) {
images.push('/static/default-product.png')
}
// 安全获取属性的方式,处理字段名称不一样的问题
const safeItem = item as any
const safePrice = (safeItem['base_price'] || safeItem['price'] || 0) as number
const safeMarketPrice = (safeItem['market_price'] || safeItem['original_price'] || safePrice) as number
const safeStock = (safeItem['total_stock'] || safeItem['available_stock'] || safeItem['stock'] || 0) as number
const safeSales = (safeItem['sale_count'] || safeItem['sales'] || 0) as number
return {
id: item.id,
merchant_id: item.merchant_id,
@@ -134,24 +309,102 @@ const loadShopProducts = async (id: string) => {
name: item.name,
description: item.description || '',
images: images,
price: item.base_price,
original_price: item.market_price || item.base_price,
stock: item.total_stock || 0,
sales: item.sale_count || 0,
price: safePrice,
original_price: safeMarketPrice,
stock: safeStock,
sales: safeSales,
status: 1,
created_at: item.created_at || ''
}
})
if (currentPage.value === 1) {
products.value = list
} else {
products.value.push(...list)
}
// 判断是否还有更多
if (list.length < pageSize.value) {
hasMore.value = false
} else {
hasMore.value = true
currentPage.value++ // 准备下一页
}
} else {
console.log('未加载到店铺商品 (本页为空)')
if (currentPage.value === 1) {
products.value = []
}
hasMore.value = false
}
isLoading.value = false
uni.stopPullDownRefresh()
}
const toggleFollow = () => {
// TODO: Implement actual follow logic with Supabase
isFollowed.value = !isFollowed.value
uni.showToast({
title: isFollowed.value ? '关注成功' : '已取消关注',
icon: 'none'
})
const checkFollowStatus = async (shopId: string) => {
const userId = supabaseService.getCurrentUserId()
if (userId) {
// @ts-ignore
if (typeof supabaseService.isShopFollowed === 'function') {
isFollowed.value = await supabaseService.isShopFollowed(shopId, userId)
}
}
}
const toggleFollow = async () => {
const userId = supabaseService.getCurrentUserId()
if (!userId) {
uni.navigateTo({ url: '/pages/auth/login' })
return
}
// 这里的 merchant.value.id 假如是 ML_SHOPS.id
const shopId = merchant.value.id
if (!shopId) return
uni.showLoading({ title: '处理中' })
// @ts-ignore
if (isFollowed.value) {
// 取消关注
// @ts-ignore
const success = await supabaseService.unfollowShop(shopId, userId)
if (success) {
isFollowed.value = false
uni.showToast({ title: '已取消关注', icon: 'none' })
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
} else {
// 关注
// @ts-ignore
const success = await supabaseService.followShop(shopId, userId)
if (success) {
isFollowed.value = true
uni.showToast({ title: '关注成功', icon: 'success' })
} else {
uni.showToast({ title: '关注失败', icon: 'none' })
}
}
uni.hideLoading()
}
const contactService = () => {
const currentUser = supabaseService.getCurrentUserId()
if (!currentUser) {
uni.navigateTo({ url: '/pages/user/login' })
return
}
if (merchant.value.user_id) {
uni.navigateTo({
url: `/pages/mall/consumer/chat?merchantId=${merchant.value.user_id}&merchantName=${encodeURIComponent(merchant.value.shop_name)}`
})
} else {
uni.showToast({ title: '无法联系商家', icon: 'none'})
}
}
const addToCart = async (product: ProductType) => {
@@ -246,14 +499,48 @@ const goToProduct = (id: string) => {
border-radius: 4px;
}
.follow-btn {
font-size: 14px;
background-color: #ff4444;
color: white;
padding: 6px 16px;
.shop-actions {
display: flex;
flex-direction: row;
align-items: center;
padding-top: 30px;
}
.action-btn {
/* Common Button Styles */
border-radius: 20px;
margin-top: 30px; /* 对齐 */
line-height: 1.5;
margin-left: 10px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 6px 16px;
}
.action-text {
font-size: 14px;
}
.chat-btn {
background-color: #ffffff;
border: 1px solid #ddd;
}
.chat-btn .action-text {
color: #333;
}
.follow-btn {
background-color: #ff4444;
border: 1px solid #ff4444;
}
.follow-btn .action-text {
color: #ffffff;
}
.follow-btn .followed {
opacity: 0.9;
}
.shop-desc {
@@ -264,6 +551,66 @@ const goToProduct = (id: string) => {
line-height: 1.4;
}
/* Coupon Styles */
.shop-coupons {
margin-top: 15px;
padding: 0 15px;
}
.coupon-scroll {
width: 100%;
white-space: nowrap;
flex-direction: row; /* Ensure flex direction for scroll view */
}
.coupon-wrapper {
display: flex;
flex-direction: row;
flex-wrap: nowrap; /* Prevent wrapping */
align-items: center;
}
.coupon-card {
display: flex; /* Changed from inline-flex to flex */
flex-direction: row;
background-color: #fff5f5;
border: 1px solid #ffccc7;
border-radius: 4px;
margin-right: 10px;
width: 150px; /* Slight increase */
height: 64px;
overflow: hidden;
flex-shrink: 0; /* Critical for horizontal scroll */
}
.coupon-left {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border-right: 1px dashed #ffccc7;
padding: 0 5px;
}
.coupon-amount {
color: #ff4444;
font-weight: bold;
font-size: 18px;
}
.coupon-cond {
color: #999;
font-size: 10px;
}
.coupon-right {
width: 40px;
display: flex;
justify-content: center;
align-items: center;
background-color: #ff4444;
writing-mode: vertical-rl; /* Note: writing-mode may not work in all environments, used flex direction in product detail instead, but let's try or use flex col */
}
.coupon-btn-label {
color: #fff;
font-size: 12px;
writing-mode: vertical-rl;
}
.product-section {
padding: 15px;
}
@@ -279,8 +626,10 @@ const goToProduct = (id: string) => {
.product-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
width: 100%;
}
.product-item {
@@ -291,6 +640,7 @@ const goToProduct = (id: string) => {
display: flex;
flex-direction: column;
margin-bottom: 10px;
box-sizing: border-box;
}
.product-image {
@@ -355,4 +705,28 @@ const goToProduct = (id: string) => {
font-size: 12px;
color: #999;
}
/* PC/Tablet Responsive */
/* Note: UTS/uni-app x media queries support depends on platform.
On Web/H5 this works standard. On App, width is fixed based on screen.
Using standard CSS media queries for H5/PC adaptation.
*/
@media (min-width: 768px) {
.product-item {
width: calc(33.33% - 7px) !important; /* Tablet: 3 items */
}
}
@media (min-width: 1024px) {
.product-item {
width: calc(16.66% - 9px) !important; /* PC: 6 items */
}
.shop-info-card, .shop-header, .product-section {
/* Limit max width on PC to avoid overly stretched content */
max-width: 1200px;
margin-left: auto;
margin-right: auto;
}
}
</style>

View File

@@ -0,0 +1,214 @@
<template>
<view class="followed-shops-page">
<view class="header">
<text class="header-title">我关注的店铺</text>
</view>
<view class="shop-list" v-if="shops.length > 0">
<view class="shop-item" v-for="shop in shops" :key="shop.id" @click="goToShop(shop)">
<image :src="shop.shop_logo || '/static/default-shop.png'" class="shop-logo" mode="aspectFill" />
<view class="shop-info">
<text class="shop-name">{{ shop.shop_name }}</text>
<text class="shop-desc">{{ shop.description || '暂无介绍' }}</text>
<view class="shop-meta">
<text class="rating">⭐ {{ shop.rating_avg || 5.0 }}</text>
<text class="sales">销量: {{ shop.total_sales || 0 }}</text>
</view>
</view>
<button class="unfollow-btn" @click.stop="unfollow(shop)">已关注</button>
</view>
</view>
<view v-else-if="!loading" class="empty-state">
<text class="empty-text">暂无关注的店铺</text>
<button class="go-shop-btn" @click="goHome">去逛逛</button>
</view>
<view v-if="loading" class="loading-state">
<text>加载中...</text>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
type FollowedShop = {
id: string
merchant_id: string
shop_name: string
shop_logo: string | null
description: string | null
rating_avg: number
total_sales: number
}
const shops = ref<FollowedShop[]>([])
const loading = ref(true)
onMounted(() => {
loadFollowedShops()
})
const loadFollowedShops = async () => {
loading.value = true
const userId = supabaseService.getCurrentUserId()
if (!userId) {
uni.navigateTo({ url: '/pages/auth/login' })
return
}
const res = await supabaseService.getFollowedShops(userId)
// res is array of { id, user_id, shop_id, ml_shops: {...} }
const list: FollowedShop[] = []
res.forEach((item: any) => {
const shopData = item['ml_shops'] as any
if (shopData != null) {
list.push({
id: shopData['id'] as string, // Shop ID
merchant_id: shopData['merchant_id'] as string,
shop_name: shopData['shop_name'] as string,
shop_logo: shopData['shop_logo'] as string | null,
description: shopData['description'] as string | null,
rating_avg: (shopData['rating_avg'] || 5.0) as number,
total_sales: (shopData['total_sales'] || 0) as number
})
}
})
shops.value = list
loading.value = false
}
const unfollow = async (shop: FollowedShop) => {
const userId = supabaseService.getCurrentUserId()
if (!userId) return
uni.showModal({
title: '提示',
content: '确定取消关注该店铺吗?',
success: async (res) => {
if (res.confirm) {
const success = await supabaseService.unfollowShop(shop.id, userId)
if (success) {
uni.showToast({ title: '已取消', icon: 'none' })
loadFollowedShops() // Reload list
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
}
})
}
const goToShop = (shop: FollowedShop) => {
// Navigate using the Shop ID or Merchant ID?
// shop-detail uses merchantId parameter but we patched it to handle ShopID too.
// Let's prefer passing the raw ID we have.
// If shop.id is UUID of shop, and shop.merchant_id is User UUID.
// Since shop-detail handles both, passing shop.id (which is ml_shops.id) is fine?
// Wait, shop-detail logic: 1. getShopByMerchantId(id) [tries merchant_id then id].
// So passing shop.id is safer if merchant_id is not unique or confusing.
uni.navigateTo({
url: `/pages/mall/consumer/shop-detail?merchantId=${shop.merchant_id || shop.id}`
})
}
const goHome = () => {
uni.switchTab({ url: '/pages/mall/consumer/index' })
}
</script>
<style>
.followed-shops-page {
padding: 15px;
background-color: #f5f5f5;
min-height: 100vh;
}
.header {
margin-bottom: 15px;
}
.header-title {
font-size: 18px;
font-weight: bold;
}
.shop-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.shop-item {
background-color: #fff;
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: row;
align-items: center;
}
.shop-logo {
width: 50px;
height: 50px;
border-radius: 4px;
background-color: #eee;
margin-right: 12px;
}
.shop-info {
flex: 1;
display: flex;
flex-direction: column;
}
.shop-name {
font-size: 16px;
font-weight: bold;
color: #333;
}
.shop-desc {
font-size: 12px;
color: #999;
margin-top: 4px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shop-meta {
font-size: 10px;
color: #999;
margin-top: 4px;
display: flex;
gap: 8px;
}
.unfollow-btn {
font-size: 12px;
padding: 4px 12px;
background-color: #eee;
color: #666;
border-radius: 20px;
margin-left: 10px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 100px;
}
.empty-text {
color: #999;
margin-bottom: 20px;
}
.go-shop-btn {
background-color: #ff4444;
color: white;
padding: 8px 24px;
border-radius: 20px;
font-size: 14px;
}
.loading-state {
text-align: center;
padding-top: 50px;
color: #999;
}
</style>

View File

@@ -167,8 +167,9 @@
</template>
<script setup lang="uts">
import { ref, onMounted, computed, watch } from 'vue'
//import supa from '@/components/supadb/aksupainstance.uts'
import { ref, computed, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { supabaseService } from '@/utils/supabaseService.uts'
type WalletType = {
id: string
@@ -226,7 +227,7 @@ watch(activeFilter, () => {
})
// 生命周期
onMounted(() => {
onShow(() => {
loadWalletData()
})
@@ -240,10 +241,10 @@ const resetTransactions = () => {
// 加载钱包数据
const loadWalletData = async () => {
const userId = getCurrentUserId()
if (!userId) {
uni.navigateTo({
url: '/pages/user/login'
})
if (userId == null) {
// uni.navigateTo({
// url: '/pages/user/login'
// })
return
}
@@ -255,35 +256,20 @@ const loadWalletData = async () => {
// 加载余额信息
const loadBalance = async () => {
const userId = getCurrentUserId()
if (!userId) return
try {
// 使用本地模拟数据
const mockBalance = {
balance: 12580.00,
total_recharge: 20000.00,
total_consume: 7420.00,
total_withdraw: 0.00
}
// 尝试从本地存储获取
const storedWallet = uni.getStorageSync(`wallet_${userId}`)
const data = storedWallet ? JSON.parse(storedWallet as string) : mockBalance
if (data) {
// 类型断言,处理 any 类型
const walletData = data as any
balance.value = Number(walletData.balance || 0)
stats.value = {
totalRecharge: Number(walletData.total_recharge || 0),
totalConsume: Number(walletData.total_consume || 0),
totalWithdraw: Number(walletData.total_withdraw || 0)
}
}
} catch (err) {
console.error('加载钱包异常:', err)
}
try {
// 调用 Supabase 服务获取真实余额
const realBalance = await supabaseService.getUserBalance()
balance.value = realBalance
// 统计数据暂时保持 mock 或设为 0因为后端还未实现具体统计接口
stats.value = {
totalRecharge: 0,
totalConsume: 0,
totalWithdraw: 0
}
} catch (err) {
console.error('加载钱包异常:', err)
}
}
// 加载交易记录
@@ -295,65 +281,70 @@ const loadTransactions = async (loadMore: boolean = false) => {
isLoading.value = true
try {
const userId = getCurrentUserId()
if (!userId) return
const userId = getCurrentUserId()
if (!userId) {
isLoading.value = false
return
}
const page = loadMore ? currentPage.value + 1 : 1
// 模拟交易记录数据
const mockTransactions: TransactionType[] = [
{
id: 't1',
user_id: userId,
change_amount: -128.00,
current_balance: 12580.00,
change_type: 'consume',
related_id: 'ord_001',
remark: '购买药品',
created_at: new Date().toISOString()
},
{
id: 't2',
user_id: userId,
change_amount: 500.00,
current_balance: 12708.00,
change_type: 'recharge',
related_id: 'rec_001',
remark: '账户充值',
created_at: new Date(Date.now() - 86400000).toISOString()
},
{
id: 't3',
user_id: userId,
change_amount: -58.50,
current_balance: 12208.00,
change_type: 'consume',
related_id: 'ord_002',
remark: '购买保健品',
created_at: new Date(Date.now() - 172800000).toISOString()
}
]
// 简单模拟分页和筛选
let filtered = mockTransactions
if (activeFilter.value === 'income') {
filtered = filtered.filter(t => t.change_amount > 0)
} else if (activeFilter.value === 'expense') {
filtered = filtered.filter(t => t.change_amount < 0)
}
const newTransactions = filtered
const page = loadMore ? currentPage.value + 1 : 1
const limit = 20
// 使用 Supabase 获取真实数据
// 注意:目前后端接口暂不支持 activeFilter 筛选,会返回所有记录
const data = await supabaseService.getTransactions(page, limit)
const mappedData: TransactionType[] = []
for (let i = 0; i < data.length; i++) {
const item = data[i]
let id = ''
let amount = 0
let balance = 0
let type = ''
let remark = ''
let createdAt = ''
if (item instanceof UTSJSONObject) {
id = item.getString('id') || ''
amount = item.getNumber('amount') || 0
balance = item.getNumber('balance_after') || 0
type = item.getString('type') || 'consume'
remark = item.getString('description') || ''
createdAt = item.getString('created_at') || ''
} else {
id = (item['id'] as string) || ''
amount = (item['amount'] as number) || 0
balance = (item['balance_after'] as number) || 0
type = (item['type'] as string) || 'consume'
remark = (item['description'] as string) || ''
createdAt = (item['created_at'] as string) || ''
}
mappedData.push({
id: id,
user_id: userId,
change_amount: amount,
current_balance: balance,
change_type: type,
related_id: null,
remark: remark,
created_at: createdAt
})
}
if (loadMore) {
transactions.value.push(...newTransactions)
currentPage.value = page
transactions.value.push(...mappedData)
} else {
transactions.value = newTransactions
currentPage.value = 1
transactions.value = mappedData
}
// 模拟没有更多数据
hasMore.value = false
if (mappedData.length < limit) {
hasMore.value = false
} else {
hasMore.value = true
}
currentPage.value = page
} catch (err) {
console.error('加载交易记录异常:', err)
} finally {
@@ -452,21 +443,22 @@ const goToCoupons = () => {
// 跳转到红包
const goToRedPackets = () => {
uni.navigateTo({
url: '/pages/mall/consumer/red-packets'
url: '/pages/mall/consumer/red-packets/index'
})
}
// 跳转到积分
const goToPoints = () => {
// 使用统一的积分页面
uni.navigateTo({
url: '/pages/mall/consumer/points'
url: '/pages/mall/consumer/points/index'
})
}
// 跳转到银行卡
const goToBankCards = () => {
uni.navigateTo({
url: '/pages/mall/consumer/bank-cards'
url: '/pages/mall/consumer/bank-cards/index'
})
}
@@ -494,12 +486,32 @@ const confirmRecharge = async () => {
const amount = parseFloat(rechargeAmount.value)
if (isNaN(amount)) return
// 这里应该跳转到支付页面进行充值
uni.navigateTo({
url: `/pages/mall/consumer/payment?type=recharge&amount=${amount}`
})
closeRechargePopup()
uni.showLoading({ title: '处理中...' })
try {
const success = await supabaseService.rechargeBalance(amount)
if (success) {
uni.showToast({
title: '充值成功',
icon: 'success'
})
closeRechargePopup()
// 刷新数据
loadWalletData()
} else {
uni.showToast({
title: '充值失败',
icon: 'none'
})
}
} catch (e) {
console.error('充值异常:', e)
uni.showToast({
title: '系统异常,请稍后重试',
icon: 'none'
})
} finally {
uni.hideLoading()
}
}
// 关闭充值弹窗

View File

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

View File

@@ -62,8 +62,9 @@
<style>
.page {
min-height: 100vh;
background: linear-gradient(180deg, #ffffff 0%, #f5f7fa 100%);
/* min-height: 100vh; UVUE不支持vh */
flex: 1;
background-color: #f5f7fa; /* UVUE不支持渐变 */
display: flex;
align-items: center;
justify-content: center;
@@ -73,33 +74,42 @@
.splash {
width: 100%;
max-width: 640rpx;
background: #ffffff;
background-color: #ffffff;
border-radius: 24rpx;
box-shadow: 0 12rpx 48rpx rgba(0, 0, 0, 0.08);
box-shadow: 0 12rpx 48rpx rgba(0, 0, 0, 0.08); /* 可能也不支持box-shadow, 视版本而定 */
padding: 48rpx 40rpx;
display: flex;
flex-direction: column; /* 添加 flex-direction: column 以兼容 gap polyfill */
flex-direction: column;
gap: 32rpx;
/* gap: 32rpx; UVUE 不支持 gap */
justify-content: space-between;
}
.brand {
display: flex;
flex-direction: row;
align-items: center;
gap: 20rpx;
/* gap: 20rpx; */
}
.brand-mark {
width: 80rpx;
height: 80rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #ff6b6b, #ff9f43);
background-color: #ff6b6b; /* UVUE不支持CSS线性渐变 */
margin-right: 20rpx; /* 替代 gap */
}
.brand-text {
display: flex;
flex-direction: column;
gap: 8rpx;
/* gap: 8rpx; */
}
/* 替代 gap: 8rpx */
.brand-name {
margin-bottom: 8rpx;
}
.brand-name {
font-size: 36rpx;
@@ -116,23 +126,35 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
/* gap: 12rpx; */
text-align: center;
margin-top: 32rpx; /* 替代父级 gap */
margin-bottom: 32rpx;
}
/* 替代 gap: 12rpx */
.status-text {
margin-top: 12rpx;
}
.spinner {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
border: 8rpx solid #f3f4f6;
border-radius: 50%; /* 如果不支持 50%,可以用 44rpx */
border-width: 8rpx;
border-style: solid;
border-color: #f3f4f6;
border-top-color: #ff6b6b;
animation: spin 1s linear infinite;
/* animation: spin 1s linear infinite; UVUE CSS动画需要特定写法或 transform */
transform: rotate(360deg);
transition-duration: 1000ms;
/* 简单的无限旋转在原生 CSS 中可能需要写关键帧但 App-UVUE 支持有限,
这里暂时保留样式但不指望它自动动起来,或者应该用 loading 组件 */
}
.status-text {
font-size: 30rpx;
color: #111827;
font-weight: 600;
font-weight: 700; /* 600不支持 -> 700 */
}
.status-sub {
@@ -143,8 +165,12 @@
.actions {
display: flex;
flex-direction: column;
gap: 16rpx;
/* gap: 16rpx; */
}
/* 替代 gap */
.action {
margin-bottom: 16rpx;
}
.action {
width: 100%;

View File

@@ -347,7 +347,7 @@ const handleLogin = async () => {
}
uni.showToast({ title: '登录成功', icon: 'success' })
if (!IS_TEST_MODE) {
// if (!IS_TEST_MODE) {
setTimeout(() => {
const pages = getCurrentPages() as any[]
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
@@ -359,7 +359,7 @@ const handleLogin = async () => {
uni.switchTab({ url: '/pages/mall/consumer/index' })
}
}, 500)
}
// }
} catch (err) {
console.error('登录错误:', err)
let msg = '登录失败,请重试'
@@ -398,10 +398,11 @@ const handleQQLogin = () => uni.showToast({ title: 'QQ登录开发中', icon: 'n
<style scoped>
/* Base */
.page{
min-height: 100vh;
/* min-height: 100vh; */ /* UVUE 不支持 vh */
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg);
background-color: #f5f6f8; /* UVUE 暂不支持 cssVars 在 style 标签中的变量引用 */
}
/* Header */
@@ -410,11 +411,11 @@ const handleQQLogin = () => uni.showToast({ title: 'QQ登录开发中', icon: 'n
flex-direction: row;
align-items: center;
justify-content: flex-start;
padding: 30px 72px;
padding: 30px 40px; /* 调整边距 */
}
.logo{
width: 300px;
height: 80px;
width: 240px;
height: 64px;
}
/* Main */
@@ -424,46 +425,50 @@ const handleQQLogin = () => uni.showToast({ title: 'QQ登录开发中', icon: 'n
flex-direction: row;
align-items: center;
justify-content: center;
padding: 28px 18px;
padding: 20px 10px;
}
/* Card */
.card{
width: min(980px, 92vw);
min-height: 460px;
background: var(--card);
/* width: min(980px, 92vw); UVUE 不支持 min/vw */
/* min-height: 460px; */
width: 90%;
background-color: #ffffff;
border-radius: 16px;
box-shadow: var(--shadow);
padding: 40px;
/* box-shadow: var(--shadow); */
padding: 30px;
display: flex;
flex-direction: row;
gap: 32px;
flex-direction: column; /* App端改为列式布局兼容性更好或者用 row 需注意 */
/* gap: 32px; UVUE 不支持 gap */
}
/* Left */
/* Left - 暂隐藏或简化 */
/* .left{ display: none; } */
.left{
flex: 0 0 52%;
display: flex;
/* flex: 0 0 52%; UVUE flex 简写支持不全,建议用 flex-grow/basis */
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding-left: 18px;
/* padding-left: 18px; */
display: none; /* 移动端 App 暂时隐藏扫码区 */
}
.left-title{
font-size: 18px;
font-weight: 600;
color: var(--text);
font-weight: 700; /* 600 -> 700 */
color: #333333;
margin-bottom: 10px;
}
.left-hint{
display: flex;
flex-direction: row;
align-items: center;
gap: 14px;
/* gap: 14px; */
margin-bottom: 18px;
}
.hint-text{ font-size: 13px; color: var(--muted); }
.hint-link{ font-size: 13px; color: var(--brand); }
/* 替代 gap */
.hint-text{ font-size: 13px; color: #666666; margin-right: 14px; }
.hint-link{ font-size: 13px; color: #e1251b; }
.qr-wrap{
width: 100%;
@@ -484,21 +489,20 @@ const handleQQLogin = () => uni.showToast({ title: 'QQ登录开发中', icon: 'n
height: 220px;
border: 1px solid #e6e6e6;
border-radius: 8px;
background: #fff;
background-color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
/* gap: 8px; */
}
.qr-text{ font-size: 14px; color: var(--muted); }
.qr-sub{ font-size: 12px; color: var(--muted2); }
/* 替代 gap */
.qr-text{ font-size: 14px; color: #666666; margin-bottom: 8px; }
.qr-sub{ font-size: 12px; color: #999999; }
/* Divider */
.divider{
width: 1px;
background: var(--border);
flex-shrink: 0;
display: none; /* 移动端隐藏分割线 */
}
/* Right */
@@ -509,9 +513,10 @@ const handleQQLogin = () => uni.showToast({ title: 'QQ登录开发中', icon: 'n
justify-content: center;
}
.right-inner{
width: 360px; /* 京东右侧“窄列”观感 */
max-width: 100%;
margin-left: auto; /* 靠右 */
/* width: 360px; */
/* max-width: 100%; UVUE 不支持百分比 max-width */
width: 100%;
margin-left: auto;
}
/* Tabs */
@@ -519,21 +524,22 @@ const handleQQLogin = () => uni.showToast({ title: 'QQ登录开发中', icon: 'n
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 24px;
justify-content: center; /* 移动端居中 */
/* gap: 24px; */
margin-bottom: 18px;
}
.tab{
position: relative;
padding: 8px 2px;
padding: 8px 12px; /* 增加内边距替代 gap */
margin: 0 12px;
}
.tab-text{
font-size: 16px;
color: var(--muted);
color: #666666;
}
.tab.active .tab-text{
color: var(--brand);
font-weight: 600;
color: #e1251b;
font-weight: 700; /* 600 -> 700 */
}
.tab-line{
position: absolute;
@@ -541,7 +547,7 @@ const handleQQLogin = () => uni.showToast({ title: 'QQ登录开发中', icon: 'n
right: 0;
bottom: -6px;
height: 2px;
background: var(--brand);
background-color: #e1251b;
border-radius: 2px;
}
@@ -552,11 +558,11 @@ const handleQQLogin = () => uni.showToast({ title: 'QQ登录开发中', icon: 'n
width: 100%;
height: 44px;
border-radius: 10px;
background: var(--inputbg);
background-color: #f6f7f9;
padding: 0 14px;
font-size: 14px;
color: var(--text);
box-sizing: border-box;
color: #333333;
/* box-sizing: border-box; */ /* App-UVUE 默认就是 border-box */
}
/* Code row */
@@ -564,14 +570,14 @@ const handleQQLogin = () => uni.showToast({ title: 'QQ登录开发中', icon: 'n
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
/* gap: 10px; */
}
.code-input{ flex: 1; }
.code-input{ flex: 1; margin-right: 10px; }
.code-btn{
height: 44px;
padding: 0 12px;
border-radius: 10px;
background: #fff;
background-color: #fff;
border: 1px solid #eee;
display: flex;
flex-direction: row;
@@ -579,24 +585,24 @@ const handleQQLogin = () => uni.showToast({ title: 'QQ登录开发中', icon: 'n
justify-content: center;
}
.code-btn.disabled{ opacity: 0.5; }
.code-text{ font-size: 13px; color: var(--brand); }
.code-text{ font-size: 13px; color: #e1251b; }
/* Button */
.btn{
margin-top: 16px;
height: 46px;
border-radius: 10px;
background: rgba(225, 37, 27, 0.45);
background-color: rgba(225, 37, 27, 0.45); /* 注意 rgba 兼容性,建议用 hex 或 view opacity */
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.btn.disabled{ background: #d9d9d9; }
.btn.disabled{ background-color: #d9d9d9; }
.btn-text{
color: #fff;
font-size: 16px;
font-weight: 600;
font-weight: 700; /* 600 -> 700 */
}
/* Actions一行横排 */
@@ -605,27 +611,29 @@ const handleQQLogin = () => uni.showToast({ title: 'QQ登录开发中', icon: 'n
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-wrap: nowrap;
justify-content: center;
/* gap: 12px; */
flex-wrap: wrap; /* 允许换行 */
}
.action-item{
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
/* gap: 8px; */
margin: 0 6px;
}
.dot{
width: 16px;
height: 16px;
border-radius: 50%;
border-radius: 8px; /* 50% -> 8px (一半) */
margin-right: 8px;
}
.dot.wechat{ background: #19be6b; }
.dot.qq{ background: #2d8cf0; }
.dot.wechat{ background-color: #19be6b; }
.dot.qq{ background-color: #2d8cf0; }
.action-text{ font-size: 13px; color: var(--muted); }
.action-link{ font-size: 13px; color: var(--muted); }
.sep{ font-size: 13px; color: #e0e0e0; }
.action-text{ font-size: 13px; color: #666666; }
.action-link{ font-size: 13px; color: #666666; margin: 0 6px; }
.sep{ font-size: 13px; color: #e0e0e0; margin: 0 6px; }
/* Footer */
.footer{
@@ -634,9 +642,10 @@ const handleQQLogin = () => uni.showToast({ title: 'QQ登录开发中', icon: 'n
flex-direction: row;
justify-content: center;
}
.footer-text{ font-size: 12px; color: var(--muted2); }
.footer-text{ font-size: 12px; color: #999999; }
/* ===== 自适应:断点全部用 px避免 rpx 在宽屏放大) ===== */
/* ===== 自适应:移除复杂 Media Query使用简单流式布局 ===== */
/*
@media screen and (max-width: 1024px){
.header{ padding: 24px 20px; }
.logo{ width: 240px; height: 68px; }
@@ -653,4 +662,5 @@ const handleQQLogin = () => uni.showToast({ title: 'QQ登录开发中', icon: 'n
@media screen and (max-width: 520px){
.sep{ display: none; }
}
*/
</style>