consumer模块完成90%,完善店铺商品优惠券领取
This commit is contained in:
@@ -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'
|
||||
|
||||
// 备用配置(已注释,如需切换可取消注释)
|
||||
// 开发环境 - 其他内网地址
|
||||
@@ -49,4 +37,4 @@ export const HOME_REDIRECT: string = '/pages/mall/consumer/index'
|
||||
export const TABORPAGE: string = '/pages/mall/consumer/index'
|
||||
|
||||
// 测试模式:放开任意跳转(禁用启动页/登录/401 的强制重定向)
|
||||
export const IS_TEST_MODE: boolean = true
|
||||
export const IS_TEST_MODE: boolean = true
|
||||
@@ -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'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
57
check_db_coupons.py
Normal 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
34
check_db_schema.py
Normal 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
39
check_products_schema.py
Normal 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)
|
||||
135
doc_mall/consumer/sql/add_coupons_for_existing_shops.sql
Normal file
135
doc_mall/consumer/sql/add_coupons_for_existing_shops.sql
Normal 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 $$;
|
||||
2
main.uts
2
main.uts
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
618
mall/pages/mall/consumer/chat_new.uvue
Normal file
618
mall/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
155
mall/pages/mall/consumer/doc/CHAT_SHOPPING_SUPABASE_ARCH.md
Normal file
155
mall/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,支持多类型消息渲染(特别是商品卡片和订单卡片)。
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 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({
|
||||
sender_id: userId,
|
||||
content: content,
|
||||
msg_type: type,
|
||||
is_from_user: true,
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
|
||||
14
pages.json
14
pages.json
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || ''
|
||||
}
|
||||
|
||||
@@ -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
26
verify_coupons.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user