consumer模块完成95%,在和商家端对接聊天购物闭环
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="page" @click.self="closeMoreMenu">
|
||||
<view class="page">
|
||||
<!-- 固定顶部导航栏 -->
|
||||
<AnalyticsTopBar
|
||||
:title="'优惠券效果分析'"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="page" @click.self="closeMoreMenu">
|
||||
<view class="page">
|
||||
<!-- 固定顶部导航栏 -->
|
||||
<AnalyticsTopBar
|
||||
:title="'数据分析中心'"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="page" @click.self="closeMoreMenu">
|
||||
<view class="page">
|
||||
<!-- 固定顶部导航栏 -->
|
||||
<AnalyticsTopBar
|
||||
:title="'市场趋势'"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="page" @click.self="closeMoreMenu">
|
||||
<view class="page">
|
||||
<!-- 固定顶部导航栏 -->
|
||||
<AnalyticsTopBar
|
||||
:title="'商品洞察'"
|
||||
|
||||
@@ -1013,8 +1013,6 @@ function goToFeedback() {
|
||||
font-size: 22rpx;
|
||||
color: #8c929b;
|
||||
}
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.trend-legend {
|
||||
display: flex;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="page" @click.self="closeMoreMenu">
|
||||
<view class="page">
|
||||
<!-- 固定顶部导航栏 -->
|
||||
<AnalyticsTopBar
|
||||
:title="'销售报表'"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="page" @click.self="closeMoreMenu">
|
||||
<view class="page">
|
||||
<!-- 固定顶部导航栏 -->
|
||||
<AnalyticsTopBar
|
||||
:title="'用户分析'"
|
||||
|
||||
152
pages/mall/consumer/bank-cards/add.uvue
Normal file
152
pages/mall/consumer/bank-cards/add.uvue
Normal 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>
|
||||
236
pages/mall/consumer/bank-cards/index.uvue
Normal file
236
pages/mall/consumer/bank-cards/index.uvue
Normal 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>
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
618
pages/mall/consumer/chat_new.uvue
Normal file
618
pages/mall/consumer/chat_new.uvue
Normal 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
@@ -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) => {
|
||||
|
||||
155
pages/mall/consumer/doc/CHAT_SHOPPING_SUPABASE_ARCH.md
Normal file
155
pages/mall/consumer/doc/CHAT_SHOPPING_SUPABASE_ARCH.md
Normal 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,支持多类型消息渲染(特别是商品卡片和订单卡片)。
|
||||
192
pages/mall/consumer/doc/CONSUMER_DB_DOC.md
Normal file
192
pages/mall/consumer/doc/CONSUMER_DB_DOC.md
Normal 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` 等于自己账号的数据。
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}×tamp=${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 {
|
||||
|
||||
@@ -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)}`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
|
||||
228
pages/mall/consumer/points/index.uvue
Normal file
228
pages/mall/consumer/points/index.uvue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
263
pages/mall/consumer/red-packets/index.uvue
Normal file
263
pages/mall/consumer/red-packets/index.uvue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
214
pages/mall/consumer/subscription/followed-shops.uvue
Normal file
214
pages/mall/consumer/subscription/followed-shops.uvue
Normal 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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭充值弹窗
|
||||
|
||||
334
pages/mall/consumer/withdraw.uvue
Normal file
334
pages/mall/consumer/withdraw.uvue
Normal 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>
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user