consumer模块完成90%,完善店铺商品优惠券领取

This commit is contained in:
2026-02-05 17:27:22 +08:00
parent 0ee4577b31
commit 06b7369494
22 changed files with 2096 additions and 1286 deletions

View File

@@ -1,32 +1,20 @@
// Supabase 配置
// 内网环境 - 本地部署的 Supabase
// IP: 192.168.1.62
// IP: 192.168.1.62
// IP: 192.168.1.61
// IP: 192.168.1.61
// Kong HTTP Port: 8000
<<<<<<< HEAD
//export const SUPA_URL: string = 'http://192.168.1.61:18000'
//export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
//自己的配置自己解开即可
export const SUPA_URL: string = 'http://192.168.1.61:18000'
export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
// WebSocket 实时连接(内网使用 ws:// 而非 wss://
export const WS_URL: string = 'ws://192.168.1.61:18000/realtime/v1/websocket'
//export const WS_URL: string = 'ws://localhost:18000/realtime/v1/websocket'
=======
//自己的配置自己解开即可
//export const SUPA_URL: string = 'http://192.168.1.61:18000'
//export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
//export const SUPA_URL: string = 'http://192.168.1.62:18000'
//export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
export const SUPA_URL: string = 'http://192.168.1.63:18000'
export const SUPA_KEY: string = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJyb2xlIjogImFub24iLCAiaXNzIjogInN1cGFiYXNlIiwgImlhdCI6IDE3Njk4NDczMzQsICJleHAiOiAyMDg1MjA3MzM0fQ.js-2CS5_cUmf4iVv8aCmmx9iyFsQvLNDbt8YYOngeLU'
// export const SUPA_URL: string = 'http://192.168.1.63:18000'
// export const SUPA_KEY: string = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJyb2xlIjogImFub24iLCAiaXNzIjogInN1cGFiYXNlIiwgImlhdCI6IDE3Njk4NDczMzQsICJleHAiOiAyMDg1MjA3MzM0fQ.js-2CS5_cUmf4iVv8aCmmx9iyFsQvLNDbt8YYOngeLU'
// WebSocket 实时连接(内网使用 ws:// 而非 wss://
// export const WS_URL: string = 'ws://192.168.1.61:18000/realtime/v1/websocket'
//export const WS_URL: string = 'ws://192.168.1.62:18000/realtime/v1/websocket'
export const WS_URL: string = 'ws://192.168.1.63:18000/realtime/v1/websocket'
>>>>>>> origin/main
export const WS_URL: string = 'ws://192.168.1.62:18000/realtime/v1/websocket'
// export const WS_URL: string = 'ws://192.168.1.63:18000/realtime/v1/websocket'
// 备用配置(已注释,如需切换可取消注释)
// 开发环境 - 其他内网地址

View File

@@ -1,38 +0,0 @@
// // export const SUPA_URL: string = 'http://192.168.0.150:8080'
// // export const SUPA_ANON_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE'
// // export const SUPA_SERVICE_ROLE_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q'
// // export const SUPA_KEY = SUPA_ANON_KEY
// // export const WS_URL: string = 'ws://'+'/192.168.0.150:8080'+'/realtime/v1/websocket';
// export const SUPA_URL: string = 'https://ak3.oulog.com';
// export const SUPA_KEY: string = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE";
// export const SUPA_SERVICE_KEY: string = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
// export const WS_URL: string = 'wss://'+'ak3.oulog.com'+'/realtime/v1/websocket';
// // Optional: Edge Function or API endpoint that returns S3 presigned POST
// // Expected response: { url: string, fields: object, publicUrl?: string }
// export const S3_PRESIGN_URL: string = ''
// // Optional: Public base URL for your S3/CND to build final URLs when presign response has no publicUrl
// export const S3_PUBLIC_BASE: string = ''
// export const RAG_API_KEY: string ='ragflow-lkZmNjMzI2YzRiNjExZWY4ZGIwMDI0Mm';
// export const RAG_BASE_URL: string ='https://rag.oulog.com';
// export const RAG_AGENT_ID: string ='15b01b26128111f08cd30242ac120006';
// export const TABORPAGE:boolean = false
// // export const HOME_REDIRECT :string = '/pages/ec/health/ecalert'
// //export const HOME_REDIRECT :string = '/pages/sport/index'
// // export const HOME_REDIRECT :string = '/pages/sport/teacher/dashboard'
// // export const HOME_REDIRECT :string = '/pages/test/multi_device_monitor'
// // export const HOME_REDIRECT :string = '/pages/ec/admin/dashboard'
// // export const HOME_REDIRECT :string = '/pages/sense/healthble'
// //export const HOME_REDIRECT :string = '/pages/ec/elder/dashboard'
// // export const HOME_REDIRECT :string = '/pages/ec/caregiver/dashboard'
// // export const HOME_REDIRECT :string = '/pages/ec/doctor/dashboard'
// // export const HOME_REDIRECT :string = '/pages/ec/family/dashboard'

View File

@@ -1,29 +0,0 @@
// // Supabase 配置
// // 内网环境 - 本地部署的 Supabase
// // 家里通过端口映射访问公司内网Supabase
// // 本地映射端口HTTP 18000, WebSocket 13000
// export const SUPA_URL: string = 'http://192.168.1.61:18000'
// export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzY4ODMwNjI0LCJleHAiOjE5MjY1MTA2MjR9.mDVl-kIOdRK9v6VTxo0TDF8r7X7xk3PZXazaavHyVvg1234567890'
// //export const SUPA_URL: string = 'https://ak3.oulog.com'
// //export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE'
// // WebSocket 实时连接(内网使用 ws:// 而非 wss://
// export const WS_URL: string = 'ws://192.168.1.61:18000/realtime/v1/websocket'
// // 备用配置(已注释,如需切换可取消注释)
// // 开发环境 - 其他内网地址
// // export const SUPA_URL: string = 'http://192.168.0.150:8080'
// // export const SUPA_KEY: string = 'your-anon-key'
// // export const WS_URL: string = 'ws://192.168.0.150:8080/realtime/v1/websocket'
// // 生产环境 - Supabase 云服务(已注释)
// // export const SUPA_URL: string = 'https://ak3.oulog.com'
// // export const SUPA_KEY: string = 'your-anon-key'
// // export const WS_URL: string = 'wss://ak3.oulog.com/realtime/v1/websocket'
// // 指向你的 Supabase 服务(开发/私有部署)
// // export const SUPA_URL: string = 'http://192.168.1.64:3000'
// // export const SUPA_KEY: string = 'your-anon-key'
// // export const WS_URL: string = 'ws://192.168.1.64:3000/realtime/v1'
// //export const HOME_REDIRECT :string = '/pages/mall/consumer/index'

57
check_db_coupons.py Normal file
View File

@@ -0,0 +1,57 @@
import urllib.request
import json
import ssl
import time
# Config from ak/config.uts
SUPA_URL = 'http://192.168.1.61:18000'
SUPA_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
headers = {
"apikey": SUPA_KEY,
"Authorization": f"Bearer {SUPA_KEY}",
"Content-Type": "application/json"
}
def get_data(table, query="select=*"):
try:
url = f"{SUPA_URL}/rest/v1/{table}?{query}"
req = urllib.request.Request(url, headers=headers)
# Ignore SSL errors for local IP
context = ssl._create_unverified_context()
try:
with urllib.request.urlopen(req, context=context, timeout=5) as response:
if response.status == 200:
return json.loads(response.read().decode())
else:
return f"Error: {response.status}"
except urllib.error.URLError as e:
return f"Connection Failed: {e}"
except Exception as e:
return f"Exception: {e}"
print("--- DIAGNOSTIC START ---")
print(f"Target: {SUPA_URL}")
# Check 1: Shops
print("\n[ Checking Shops (ml_shops) ]")
shops = get_data("ml_shops", "select=id,merchant_id,shop_name&status=eq.1")
if isinstance(shops, list):
print(f"Found {len(shops)} active shops.")
for shop in shops:
print(f" - {shop.get('shop_name')} (ID: {shop.get('merchant_id')})")
else:
print(f"Failed to fetch shops: {shops}")
# Check 2: Coupons
print("\n[ Checking Coupons (ml_coupon_templates) ]")
coupons = get_data("ml_coupon_templates", "select=id,name,merchant_id,status")
if isinstance(coupons, list):
print(f"Found {len(coupons)} coupon templates.")
for coupon in coupons:
mid_disp = coupon.get('merchant_id') if coupon.get('merchant_id') else "PLATFORM"
print(f" - {coupon.get('name')} | Owner: {mid_disp} | Status: {coupon.get('status')}")
else:
print(f"Failed to fetch coupons: {coupons}")
print("\n--- DIAGNOSTIC END ---")

34
check_db_schema.py Normal file
View File

@@ -0,0 +1,34 @@
import urllib.request
import json
import ssl
SUPA_URL = 'http://192.168.1.61:18000'
SUPA_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
headers = {
"apikey": SUPA_KEY,
"Authorization": f"Bearer {SUPA_KEY}",
"Content-Type": "application/json"
}
def get_one_row(table):
try:
url = f"{SUPA_URL}/rest/v1/{table}?select=*&limit=1"
req = urllib.request.Request(url, headers=headers)
context = ssl._create_unverified_context()
with urllib.request.urlopen(req, context=context, timeout=5) as response:
if response.status == 200:
data = json.loads(response.read().decode())
if len(data) > 0:
return data[0]
else:
return "Empty table"
return f"Error {response.status}"
except Exception as e:
return str(e)
print("\n[ml_user_coupons Columns]")
print(get_one_row("ml_user_coupons"))
print("\n[ml_products Columns]")
print(get_one_row("ml_products"))

39
check_products_schema.py Normal file
View File

@@ -0,0 +1,39 @@
import urllib.request
import json
import ssl
SUPA_URL = 'http://192.168.1.61:18000'
SUPA_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
headers = {
"apikey": SUPA_KEY,
"Authorization": f"Bearer {SUPA_KEY}",
"Content-Type": "application/json"
}
def get_one_row(table):
try:
# Limit 1 to just see columns
url = f"{SUPA_URL}/rest/v1/{table}?select=*&limit=1"
req = urllib.request.Request(url, headers=headers)
context = ssl._create_unverified_context()
with urllib.request.urlopen(req, context=context, timeout=5) as response:
if response.status == 200:
data = json.loads(response.read().decode())
if len(data) > 0:
return data[0]
else:
return "Empty table"
return f"Error {response.status}"
except Exception as e:
return str(e)
print("Checking 'ml_products' table columns:")
prod = get_one_row("ml_products")
if isinstance(prod, dict):
print("Columns found:", list(prod.keys()))
print("Sample Price keys:", [k for k in prod.keys() if 'price' in k])
print("Sample Stock keys:", [k for k in prod.keys() if 'stock' in k])
print("Sample Sales keys:", [k for k in prod.keys() if 'sale' in k])
else:
print(prod)

View File

@@ -0,0 +1,135 @@
-- 1. 为所有现有店铺创建优惠券模板
-- 这个脚本会查找数据库中现有的所有 Shop并为每个 Shop 创建一系列优惠券
DO $$
DECLARE
r_shop RECORD;
v_template_id UUID;
BEGIN
-- 遍历所有状态正常的店铺
FOR r_shop IN SELECT id, merchant_id, shop_name FROM public.ml_shops WHERE status = 1 LOOP
RAISE NOTICE '正在为店铺: % 创建优惠券模板...', r_shop.shop_name;
-- A. 创建店铺满减券 (满100减10)
-- 判断是否已存在同名券,避免重复创建
IF NOT EXISTS (SELECT 1 FROM public.ml_coupon_templates WHERE merchant_id = r_shop.merchant_id AND name = '店铺新人礼 - 满100减10') THEN
INSERT INTO public.ml_coupon_templates (
merchant_id,
name,
coupon_type,
discount_type,
discount_value,
min_order_amount,
total_quantity,
start_time,
end_time,
status,
applicable_products
) VALUES (
r_shop.merchant_id,
'店铺新人礼 - 满100减10',
1, -- 满减
1, -- 固定金额
10.00,
100.00,
500, -- 发放500张
now(),
now() + interval '1 year',
1,
'[]'::jsonb
);
END IF;
-- B. 创建店铺折扣券 (9.5折, 满200可用)
IF NOT EXISTS (SELECT 1 FROM public.ml_coupon_templates WHERE merchant_id = r_shop.merchant_id AND name = '全店95折券') THEN
INSERT INTO public.ml_coupon_templates (
merchant_id,
name,
coupon_type,
discount_type,
discount_value,
min_order_amount,
total_quantity,
start_time,
end_time,
status,
applicable_products
) VALUES (
r_shop.merchant_id,
'全店95折券',
1, -- 满减/折扣
2, -- 百分比
0.95, -- 95折
200.00,
1000,
now(),
now() + interval '1 year',
1,
'[]'::jsonb
);
END IF;
-- C. 创建大额满减券 (满500减60)
IF NOT EXISTS (SELECT 1 FROM public.ml_coupon_templates WHERE merchant_id = r_shop.merchant_id AND name = '店庆大促 - 满500减60') THEN
INSERT INTO public.ml_coupon_templates (
merchant_id,
name,
coupon_type,
discount_type,
discount_value,
min_order_amount,
total_quantity,
start_time,
end_time,
status,
applicable_products
) VALUES (
r_shop.merchant_id,
'店庆大促 - 满500减60',
1,
1,
60.00,
500.00,
200,
now(),
now() + interval '30 days',
1,
'[]'::jsonb
);
END IF;
END LOOP;
-- D. 创建几个平台通用券 (如果不存在)
IF NOT EXISTS (SELECT 1 FROM public.ml_coupon_templates WHERE merchant_id IS NULL AND name = '平台春季大促红包') THEN
INSERT INTO public.ml_coupon_templates (
merchant_id,
name,
coupon_type,
discount_type,
discount_value,
min_order_amount,
total_quantity,
start_time,
end_time,
status,
applicable_products
) VALUES (
NULL,
'平台春季大促红包',
1,
1,
8.00,
0,
10000,
now(),
now() + interval '3 months',
1,
'[]'::jsonb
);
END IF;
RAISE NOTICE '所有现有店铺优惠券模板创建完成。';
END $$;

View File

@@ -2,6 +2,8 @@
import { createSSRApp } from 'vue'
import App from './App.uvue'
console.log('Main App Initializing... Force Rebuild ' + Date.now())
export function createApp() {
const app = createSSRApp(App)

View File

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

View File

@@ -7,7 +7,7 @@
<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,106 @@ 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>('在线客服')
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) => {
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 +244,7 @@ const sendMessage = async () => {
const content = inputMessage.value.trim()
if (!content) return
// 添加发送的消息
// 添加发送的消息 (乐观更新)
const newMessage = {
id: Date.now(),
type: 'sent',
@@ -214,44 +258,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 = () => {

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
# 基于 Supabase 实现消费者-商家聊天购物闭环方案
> **结论:** 是的,仅依赖 Supabase (配合前端逻辑) 完全可以实现“聊天购物闭环”的核心业务流程。 Supabase 提供的 Authentication身份验证、Database数据库、Realtime实时订阅、Storage存储以及 Edge Functions边缘函数覆盖了即时通讯和订单状态流转所需的所有基础设施。
>
> *注:实际资金的支付(扣款)通常需要对接微信支付/支付宝/Stripe等第三方支付网关但 Supabase 可以完美托管支付前后的数据流、状态流和 Webhook 处理。*
---
## 1. 核心架构概览
在不引入额外后端服务(如 Node.js/Java 服务端)的情况下,架构如下:
* **客户端 (Uni-app / Vue)**: 消费者端消费者 App商家端管理后台。直接通过 `supabase-js` SDK 与 Supabase 交互。
* **身份验证 (Auth)**: 区分消费者Consumer和商家Merchant/Admin。利用 RLS (Row Level Security) 确保数据隔离。
* **实时通讯 (Realtime)**: 监听 `messages``orders` 表的变动,实现毫秒级消息推送和订单状态更新。
* **业务逻辑 (Database + Edge Functions)**: 使用 Postgres 函数处理复杂的原子操作(如创建订单),使用 Edge Functions 处理支付回调。
---
## 2. 数据库设计 (Schema)
为了支持“边聊边买”,我们需要设计能关联聊天与订单的数据结构。
### 2.1 核心表结构
**1. 聊天室表 (`chat_rooms`)**
| 字段 | 类型 | 说明 |
| :--- | :--- | :--- |
| `id` | uuid | 主键 |
| `consumer_id` | uuid | 关联 `auth.users` (消费者) |
| `merchant_id` | uuid | 关联 `auth.users` (商家) |
| `last_message` | jsonb | 最后一条消息快照(用于列表展示) |
| `updated_at` | timestamp | 排序用 |
**2. 消息表 (`messages`)**
| 字段 | 类型 | 说明 |
| :--- | :--- | :--- |
| `id` | uuid | 主键 |
| `room_id` | uuid | 外键关联 `chat_rooms` |
| `sender_id` | uuid | 发送者 ID |
| `type` | text | 消息类型: `text`, `image`, `product`, `order`, `system` |
| `payload` | jsonb | 消息内容。如果是 `product`,存商品快照;如果是 `order`,存订单摘要 |
| `is_read` | boolean | 已读状态 |
| `created_at` | timestamp | 发送时间 |
**3. 订单表 (`orders`)**
| 字段 | 类型 | 说明 |
| :--- | :--- | :--- |
| `id` | uuid | 主键 |
| `room_id` | uuid | **关键关联**:该订单属于哪个聊天上下文 |
| `consumer_id` | uuid | 买家 |
| `merchant_id` | uuid | 卖家 |
| `items` | jsonb | 商品列表 |
| `total_amount` | numeric | 总金额 |
| `status` | text | `pending`, `paid`, `shipped`, `completed`, `cancelled` |
| `payment_status`| text | `unpaid`, `success` |
---
## 3. “聊天购物”闭环流程详解
### 场景一:商品咨询与卡片发送
1. **场景**: 消费者在商品详情页点击“联系商家”。
2. **动作**:
* 前端检查 `chat_rooms` 是否存在该(消费者, 商家)的记录,没有则插入(`upsert`)。
* 跳转至聊天页。
* **特色功能**: 自动发送一条 `type: product` 的消息,包含当前浏览的商品卡片 (`payload: { id, title, price, image }`)。
3. **实现 (Supabase)**:
* 直接写入 `messages` 表。
* 商家端通过 `supabase.channel('messages').on(...)` 实时收到商品卡片,知道用户对什么感兴趣。
### 场景二:商家发起收款(创建订单)
1. **场景**: 双方沟通确认购买意向后,商家点击“发起订单”或“直接改价”。
2. **动作**:
* 商家选择商品,填写金额,生成预订单。
*`orders` 表插入一条状态为 `pending` 的记录。
*`messages` 表插入一条 `type: order` 的消息,`payload` 包含 `order_id` 和摘要。
3. **表现**: 消费者在聊天流中看到一个“待支付订单卡片”。
### 场景三:聊天中支付
1. **场景**: 消费者点击聊天气泡中的“立即支付”按钮。
2. **动作**:
* APP 唤起支付(微信/支付宝)。
* 支付成功后,支付平台回调 Supabase Edge Function (或通过前端验证)。
* 更新 `orders``status``paid`
3. **闭环**:
* Supabase 监听到 `orders``status` 变为 `paid`
* **触发器 (Trigger)** 或客户端逻辑自动插入一条 `type: system` 的消息:“订单已支付,等待发货”。
* 商家端聊天界面实时更新订单状态为“已支付”。
---
## 4. 关键技术实现点
### 4.1 RLS (行级安全策略)
必须配置严格的 RLS防止用户偷看他人聊天。
```sql
-- 示例:只能查看属于自己的聊天室
create policy "Users can view their own rooms"
on chat_rooms for select
using (auth.uid() = consumer_id or auth.uid() = merchant_id);
```
### 4.2 实时订阅 (Realtime)
前端代码示例 (Prolog/Vue):
```typescript
// 订阅消息
const messageChannel = supabase
.channel('chat-room-123')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}` },
(payload) => {
messages.value.push(payload.new)
scrollToBottom()
}
)
.subscribe()
// 订阅订单状态变更 (实现卡片状态自动刷新)
const orderChannel = supabase
.channel('order-updates')
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'orders', filter: `room_id=eq.${roomId}` },
(payload) => {
updateOrderCardStatus(payload.new.id, payload.new.status)
}
)
.subscribe()
```
### 4.3 边缘函数 (Edge Functions) 处理 Webhook
由于前端不能直接处理支付回调(不安全),需要使用 Supabase Edge Functions。
* `functions/payment-webhook/index.ts`: 接收微信支付回调,验证签名,然后使用 Service Role Key 更新 `orders` 表。
---
## 5. 总结
完全依赖 Supabase 实现聊天购物闭环是**可行且高效**的方案。
* **开发快**: 免去后端 CRUD 接口开发,直接操作 DB。
* **实时性**: Realtime 功能天然契合聊天场景。
* **成本低**: 无需维护长连接服务器 (WebSocket)。
* **闭环体验**: 订单与消息在同一数据流中,用户体验流畅。
**下一步建议:**
1. 在 Supabase 中创建 `chat_rooms`, `messages`, `orders` 表。
2. 配置 RLS。
3. 开发消息列表 UI支持多类型消息渲染特别是商品卡片和订单卡片

View File

@@ -348,8 +348,8 @@ const loadCategories = async () => {
desc: cat.description || '', // 使用description字段
color: '#4CAF50' // 默认颜色表中可能没有color字段
}))
// 随机打乱顺序
categories.value = mappedCategories.sort(() => Math.random() - 0.5)
// 保持原始顺序或按ID排序移除随机打乱
categories.value = mappedCategories
} catch (error) {
console.error('加载分类数据失败:', error)
// 如果加载失败,使用默认分类作为后备
@@ -361,8 +361,8 @@ const loadCategories = async () => {
const loadBrands = async () => {
try {
const brandsData = await supabaseService.getBrands()
// 随机打乱
brands.value = brandsData.sort(() => Math.random() - 0.5)
// 保持原始顺序
brands.value = brandsData
} catch (e) {
console.error('加载品牌失败:', e)
brands.value = []
@@ -587,8 +587,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()

View File

@@ -40,6 +40,19 @@
<text class="function-content">{{ product.usage }}</text>
</view>
<!-- 优惠券入口 (新增) -->
<view class="coupon-entry" @click="showCouponModal" v-if="coupons.length > 0">
<view class="coupon-entry-left">
<text class="coupon-entry-label">优惠</text>
<view class="coupon-tags-row">
<text class="coupon-tag" v-for="(coupon, index) in coupons.slice(0, 2)" :key="index">
{{ coupon.name }}
</text>
</view>
</view>
<text class="coupon-arrow">领券 ></text>
</view>
<!-- 商品参数 -->
<view class="params-section" @click="showParamsModal">
<text class="params-title">商品参数</text>
@@ -96,6 +109,11 @@
<!-- 底部操作栏 -->
<view class="bottom-actions">
<view class="action-buttons">
<!-- 客服按钮 (新增) -->
<view class="action-btn" @click="contactMerchant">
<text class="action-icon">💬</text>
<text class="action-text">客服</text>
</view>
<view class="action-btn" @click="goToCart">
<text class="action-icon">🛒</text>
<text class="action-text">购物车</text>
@@ -174,6 +192,34 @@
</view>
</view>
</view>
<!-- 优惠券弹窗 (新增) -->
<view v-if="showCoupons" class="popup-mask" @click="hideCouponModal">
<view class="popup-content" @click.stop>
<view class="popup-header">
<text class="popup-title">优惠券</text>
<text class="close-btn" @click="hideCouponModal">×</text>
</view>
<scroll-view scroll-y="true" class="coupon-list-scroll">
<view v-for="coupon in coupons" :key="coupon.id" class="coupon-item">
<view class="coupon-left">
<text class="coupon-amount">
<text class="symbol">¥</text>{{ coupon.discount_value }}
</text>
<text class="coupon-cond">满{{ coupon.min_order_amount }}可用</text>
</view>
<view class="coupon-right">
<view class="coupon-info-text">
<text class="coupon-name">{{ coupon.name }}</text>
<text class="coupon-time">{{ formatDate(coupon.start_time) }}-{{ formatDate(coupon.end_time) }}</text>
</view>
<button class="coupon-btn" @click="claimCoupon(coupon)">领取</button>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
@@ -219,7 +265,10 @@ export default {
selectedSpec: '',
quantity: 1,
isFavorite: false,
showParams: false
showParams: false,
// 新增: 优惠券相关
coupons: [] as any[],
showCoupons: false
}
},
onLoad(options: any) {
@@ -398,9 +447,6 @@ export default {
}
} catch(e) {}
}
// Load SKUs
// this.loadProductSkus(productId) // If SKU logic exists
} else {
throw new Error('No product found')
}
@@ -416,6 +462,8 @@ export default {
// Load Merchant and SKUs
if (this.product.merchant_id) {
await this.loadMerchantInfo(this.product.merchant_id)
// 加载优惠券
this.loadCoupons()
}
if (this.product.id) {
this.loadProductSkus(this.product.id)
@@ -508,10 +556,74 @@ export default {
} catch (e) {
console.error('Fetch SKUs error', e)
}
},
// 新增:加载优惠券
async loadCoupons() {
if (!this.product.merchant_id) return
// Safety check for cached service definition
// @ts-ignore
if (typeof supabaseService.fetchShopCoupons === 'function') {
this.coupons = await supabaseService.fetchShopCoupons(this.product.merchant_id)
} else if (typeof supabaseService.getAvailableCoupons === 'function') {
this.coupons = await supabaseService.getAvailableCoupons(this.product.merchant_id)
} else {
console.warn('SupabaseService.fetchShopCoupons method missing in runtime. Please restart project.')
}
},
// 如果没有从数据库加载到SKU则不显示规格选择直接作为无规格商品添加
// 移除之前的Mock逻辑因为Mock的ID不符合UUID格式会导致数据库错误
// 新增:联系客服(商家)
contactMerchant() {
if (!supabaseService.getCurrentUserId()) {
uni.navigateTo({ url: '/pages/auth/login' })
return
}
// Navigate to chat
const merchId = this.merchant.user_id || this.merchant.id || this.product.merchant_id;
uni.navigateTo({
url: `/pages/mall/consumer/chat?merchantId=${merchId}&merchantName=${this.merchant.shop_name}`
})
},
// 新增:优惠券弹窗
showCouponModal() {
this.showCoupons = true
},
hideCouponModal() {
this.showCoupons = false
},
// 新增:领取优惠券
async claimCoupon(coupon: any) {
const userId = supabaseService.getCurrentUserId()
if (!userId) {
uni.navigateTo({ url: '/pages/auth/login' })
return
}
uni.showLoading({ title: '领取中' })
let success = false
// @ts-ignore
if (typeof supabaseService.claimShopCoupon === 'function') {
success = await supabaseService.claimShopCoupon(coupon.id, userId)
} else if (typeof supabaseService.claimCoupon === 'function') {
success = await supabaseService.claimCoupon(coupon.id, userId)
} else {
console.warn('claimCoupon method missing')
}
uni.hideLoading()
if (success) {
uni.showToast({ title: '领取成功', icon: 'success' })
} else {
uni.showToast({ title: '领取失败或已领取', icon: 'none' })
}
},
formatDate(dateStr: string): string {
if (!dateStr) return ''
const date = new Date(dateStr)
return `${date.getFullYear()}.${date.getMonth()+1}.${date.getDate()}`
},
onSwiperChange(e: any) {
@@ -549,41 +661,26 @@ export default {
return
}
// 显示加载中
uni.showLoading({
title: '添加中...'
})
uni.showLoading({ title: '添加中...' })
try {
// 调用 Supabase 服务添加到购物车
// 传递 productId, quantity, skuId
const success = await supabaseService.addToCart(
this.product.id,
this.quantity,
this.selectedSkuId
)
uni.hideLoading()
if (success) {
uni.showToast({
title: '已添加到购物车',
icon: 'success'
})
uni.showToast({ title: '已添加到购物车', icon: 'success' })
} else {
console.error('添加购物车返回失败')
uni.showToast({
title: '添加失败,请登录重试',
icon: 'none'
})
uni.showToast({ title: '添加失败,请登录重试', icon: 'none' })
}
} catch (e) {
uni.hideLoading()
console.error('添加购物车异常', e)
uni.showToast({
title: '添加异常',
icon: 'none'
})
uni.showToast({ title: '添加异常', icon: 'none' })
}
},
@@ -598,13 +695,6 @@ 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,
@@ -616,14 +706,9 @@ export default {
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;

View File

@@ -15,6 +15,24 @@
<button class="follow-btn" @click="toggleFollow">{{ isFollowed ? '已关注' : '+ 关注' }}</button>
</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>
<!-- 商品列表 -->
@@ -63,6 +81,7 @@ const merchant = ref<MerchantType>({
const products = ref<ProductType[]>([])
const isFollowed = ref(false)
const coupons = ref<any[]>([]) // 新增优惠券
onMounted(() => {
const pages = getCurrentPages()
@@ -72,6 +91,7 @@ onMounted(() => {
if (merchantId) {
loadShopData(merchantId)
loadShopProducts(merchantId)
loadCoupons(merchantId) // 加载优惠券
}
})
@@ -95,8 +115,56 @@ const loadShopData = async (id: string) => {
}
}
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) => {
// @ts-ignore
if (typeof supabaseService.getProductsByMerchantId !== 'function') {
console.error('getProductsByMerchantId missing')
return
}
const res = await supabaseService.getProductsByMerchantId(id)
console.log('shop-detail getProductsByMerchantId res:', JSON.stringify(res))
if (res.error) {
console.error('shop-detail error:', res.error)
}
if (res.data.length > 0) {
products.value = res.data.map((item): ProductType => {
// 解析图片数组
@@ -123,13 +191,17 @@ const loadShopProducts = async (id: string) => {
}
}
}
if (images.length === 0 && item.image) {
images.push(item.image!)
}
if (images.length === 0 && item.main_image_url) {
images.push(item.main_image_url!)
}
// 安全获取属性的方式,处理字段名称不一样的问题
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,
@@ -137,10 +209,10 @@ const loadShopProducts = async (id: string) => {
name: item.name,
description: item.description || '',
images: images,
price: item.price,
original_price: item.original_price || item.price,
stock: item.stock || 0,
sales: item.sales || 0,
price: safePrice,
original_price: safeMarketPrice,
stock: safeStock,
sales: safeSales,
status: 1,
created_at: item.created_at || ''
}
@@ -267,6 +339,61 @@ 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;
}
.coupon-wrapper {
display: flex;
flex-direction: row;
}
.coupon-card {
display: inline-flex;
background-color: #fff5f5;
border: 1px solid #ffccc7;
border-radius: 4px;
margin-right: 10px;
width: 140px;
height: 60px;
overflow: hidden;
}
.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;
}

View File

@@ -48,6 +48,13 @@ export interface Product {
attributes?: string // JSON string
created_at?: string
updated_at?: string
// Alias fields for compatibility
price?: number
original_price?: number
stock?: number
sales?: number
images?: string
cover?: string
// View fields
brand_name?: string
category_name?: string
@@ -928,21 +935,51 @@ class SupabaseService {
}
}
// 获取与特定商家的聊天记录
async getChatMessages(merchantId: string): Promise<ChatMessage[]> {
try {
const userId = this.getCurrentUserId()
if (!userId) return []
const response = await supa
.from('ml_chat_messages')
.select('*')
.or(`and(sender_id.eq.${userId},receiver_id.eq.${merchantId}),and(sender_id.eq.${merchantId},receiver_id.eq.${userId})`)
.order('created_at', { ascending: false })
.limit(50)
.execute()
if (response.error) {
console.error('获取聊天记录失败:', response.error)
return []
}
return response.data as ChatMessage[]
} catch (e) {
console.error('获取聊天记录异常:', e)
return []
}
}
// 发送聊天消息
async sendChatMessage(content: string, type: string = 'text'): Promise<boolean> {
async sendChatMessage(content: string, toId: string | null = null, type: string = 'text'): Promise<boolean> {
try {
const userId = this.getCurrentUserId()
if (!userId) return false
const response = await supa
.from('ml_chat_messages')
.insert({
const payload : any = {
sender_id: userId,
content: content,
msg_type: type,
is_from_user: true,
created_at: new Date().toISOString()
})
}
if (toId != null) {
payload['receiver_id'] = toId
}
const response = await supa
.from('ml_chat_messages')
.insert(payload)
.execute()
if (response.error) {
@@ -2732,6 +2769,94 @@ class SupabaseService {
return 0
}
}
// 获取店铺/商品可用优惠券
async getAvailableCoupons(merchantId: string): Promise<any[]> {
return this.fetchShopCoupons(merchantId)
}
// ALIAS for Cache busting: 获取店铺优惠券
async fetchShopCoupons(merchantId: string): Promise<any[]> {
try {
// 查询该商家的优惠券 + 平台通用券 (merchant_id is null)
// 注意:这里简化逻辑,实际可能需要联合查询用户是否已领取
const response = await supa
.from('ml_coupon_templates')
.select('*')
.or(`merchant_id.eq.${merchantId},merchant_id.is.null`)
.eq('status', 1)
.gt('end_time', new Date().toISOString())
.order('discount_value', { ascending: false })
.execute()
if (response.error) {
console.error('Fetch coupons failed:', response.error)
return []
}
return response.data
} catch (e) {
console.error('Fetch coupons error:', e)
return []
}
}
// 领取优惠券
async claimCoupon(templateId: string, userId: string): Promise<boolean> {
return this.claimShopCoupon(templateId, userId)
}
// ALIAS for Cache busting
async claimShopCoupon(templateId: string, userId: string): Promise<boolean> {
try {
// 1. Fetch template details to get merchant_id and validity
const tmplRes = await supa
.from('ml_coupon_templates')
.select('*')
.eq('id', templateId)
.single()
if (tmplRes.error) {
console.error('Claim Coupon: Template not found', tmplRes.error)
return false
}
const template = tmplRes.data as any
// Calculate expire_at
let expireAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
if (template['valid_days'] && (template['valid_days'] as number) > 0) {
expireAt = new Date(Date.now() + (template['valid_days'] as number) * 24 * 60 * 60 * 1000).toISOString()
} else if (template['end_time']) {
expireAt = template['end_time'] as string
}
// 2. Insert into user coupons with merchant_id
const insertData = {
user_id: userId,
template_id: templateId,
merchant_id: template['merchant_id'], // Important for shop filtering
coupon_code: 'C' + Date.now() + Math.floor(Math.random() * 1000),
status: 1,
expire_at: expireAt,
received_at: new Date().toISOString()
}
const response = await supa
.from('ml_user_coupons')
.insert(insertData)
.execute()
if (response.error) {
console.error('Claim Coupon: Insert failed', response.error)
return false
}
return true
} catch(e) {
console.error('Claim coupon error:', e)
return false
}
}
}
// 导出单例实例

View File

@@ -1,12 +1,5 @@
{
"pages": [
{
"path": "pages/mall/admin/homePage/index",
"style": {
"navigationBarTitleText": "管理后台",
"navigationStyle": "custom"
}
},
{
"path": "pages/user/login",
"style": {
@@ -14,6 +7,13 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/mall/admin/homePage/index",
"style": {
"navigationBarTitleText": "管理后台",
"navigationStyle": "custom"
}
},
{
"path": "pages/user/boot",
"style": {

View File

@@ -15,12 +15,12 @@
<text class="search-placeholder">请输入药品名称、症状或品牌</text>
<!-- 扫码图标 -->
<view class="nav-icon-btn" @click.stop="onScan">
<view class="nav-icon-btn" @click="onScan">
<text class="nav-icon">🔳</text>
</view>
<!-- 相机图标 -->
<view class="nav-camera-btn" @click.stop="onCamera">
<view class="nav-camera-btn" @click="onCamera">
<text class="nav-camera-icon">📷</text>
</view>
@@ -210,7 +210,7 @@
</view>
<view class="product-action">
<view class="cart-btn" @click.stop="addToCart(product)">
<view class="cart-btn" @click="addToCart(product)">
<text class="cart-icon">+</text>
<text class="cart-text">加入购物车</text>
</view>
@@ -692,7 +692,8 @@ const loadMore = async () => {
}
// 添加到购物车
const addToCart = async (product: any) => {
const addToCart = async (product: any, e: any | null = null) => {
e?.stopPropagation()
uni.showLoading({ title: '添加中...' })
try {
// 尝试调用 Supabase 服务添加
@@ -749,6 +750,44 @@ 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>

View File

@@ -123,9 +123,6 @@ const loadShopProducts = async (id: string) => {
}
}
}
if (images.length === 0 && item.image) {
images.push(item.image!)
}
if (images.length === 0 && item.main_image_url) {
images.push(item.main_image_url!)
}
@@ -137,10 +134,10 @@ const loadShopProducts = async (id: string) => {
name: item.name,
description: item.description || '',
images: images,
price: item.price,
original_price: item.original_price || item.price,
stock: item.stock || 0,
sales: item.sales || 0,
price: item.base_price,
original_price: item.market_price || item.base_price,
stock: item.total_stock || 0,
sales: item.sale_count || 0,
status: 1,
created_at: item.created_at || ''
}

View File

@@ -256,6 +256,53 @@ class SupabaseService {
}
}
// 获取店铺商品
async getProductsByMerchantId(
merchantId: string,
page: number = 1,
limit: number = 20
): Promise<PaginatedResponse<Product>> {
try {
const response = await supa
.from('ml_products_detail_view')
.select('*', { count: 'exact' })
.eq('merchant_id', merchantId)
.eq('status', 1)
.order('created_at', { ascending: false })
.page(page)
.limit(limit)
.execute()
if (response.error) {
console.error('获取店铺商品失败:', response.error)
return {
data: [],
total: 0,
page,
limit,
hasmore: false
}
}
return {
data: response.data as Product[],
total: response.total || 0,
page,
limit,
hasmore: response.hasmore || false
}
} catch (error) {
console.error('获取店铺商品异常:', error)
return {
data: [],
total: 0,
page,
limit,
hasmore: false
}
}
}
// 搜索商品
async searchProducts(
keyword: string,
@@ -2121,9 +2168,69 @@ class SupabaseService {
return false
}
}
// 获取店铺/商品可用优惠券
async getAvailableCoupons(merchantId: string): Promise<any[]> {
return this.fetchShopCoupons(merchantId)
}
// ALIAS for Cache busting: 获取店铺优惠券
async fetchShopCoupons(merchantId: string): Promise<any[]> {
try {
// 查询该商家的优惠券 + 平台通用券 (merchant_id is null)
// 注意:这里简化逻辑,实际可能需要联合查询用户是否已领取
const response = await supa
.from('ml_coupon_templates')
.select('*')
.or(`merchant_id.eq.${merchantId},merchant_id.is.null`)
.eq('status', 1)
.gt('end_time', new Date().toISOString())
.order('discount_value', { ascending: false })
.execute()
if (response.error) {
console.error('Fetch coupons failed:', response.error)
return []
}
return response.data
} catch (e) {
console.error('Fetch coupons error:', e)
return []
}
}
// 领取优惠券
async claimCoupon(templateId: string, userId: string): Promise<boolean> {
return this.claimShopCoupon(templateId, userId)
}
// ALIAS for Cache busting
async claimShopCoupon(templateId: string, userId: string): Promise<boolean> {
try {
// 检查是否已领取 (根据业务规则,这里假设不做严格限制或由数据库约束处理)
// 简单插入
const response = await supa
.from('ml_user_coupons')
.insert({
user_id: userId,
template_id: templateId,
coupon_code: 'C' + Date.now() + Math.floor(Math.random() * 1000), // 简单生成
status: 1, // 1 = unused based on mock data comment
expire_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() // Default 30 days if not fetched from template (Simplified)
})
.execute()
return !response.error
} catch(e) {
console.error('Claim coupon error:', e)
return false
}
}
}
// 导出单例实例
console.log('Supabase Service Initialized - Version with Coupons')
export const supabaseService = new SupabaseService()
// 默认导出

26
verify_coupons.js Normal file
View File

@@ -0,0 +1,26 @@
// 复制以下代码到 HBuilderX 的调试控制台 或 浏览器的控制台 中运行
// 用于检查数据库中是否有优惠券数据
const SUPA_URL = 'http://192.168.1.61:18000';
const SUPA_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890';
console.log('正在检查数据库优惠券...');
fetch(`${SUPA_URL}/rest/v1/ml_coupon_templates?select=id,name,merchant_id,status`, {
headers: {
'apikey': SUPA_KEY,
'Authorization': `Bearer ${SUPA_KEY}`
}
})
.then(res => res.json())
.then(data => {
console.log(`查询结果: 找到 ${data.length || 0} 张优惠券模板`);
if (Array.isArray(data) && data.length > 0) {
console.table(data);
} else {
console.warn('数据库中没有优惠券!请运行 add_coupons_for_existing_shops.sql 脚本。');
}
})
.catch(err => {
console.error('连接数据库失败:', err);
});