consumer模块完成度95%,实现数据库多端注册登录,优化安卓端小程序bug

This commit is contained in:
cyh666666
2026-03-10 17:08:20 +08:00
parent 2262d1bfd9
commit 5517c93666
1010 changed files with 1688 additions and 4919 deletions

View File

@@ -4,7 +4,7 @@
<!-- 聊天头部 -->
<view class="chat-header" :style="{ paddingTop: navPaddingTop }">
<view class="header-back" @click="goBack">
<text class="back-icon"></text>
<text class="back-icon"></text>
</view>
<view class="header-info">
<view class="header-info-text-wrapper">
@@ -566,7 +566,7 @@ const goBack = () => {
}
.back-icon {
font-size: 24px;
font-size: 20px;
color: #333;
}

View File

@@ -0,0 +1,85 @@
# 多端用户注册与身份识别实现指南 (Multi-Terminal Registration Guide)
本档说明了如何在当前的 Supabase 架构下实现“消费者端”、“商家端”及“管理端”的统一注册逻辑并确保用户身份Role在入库时能够自动、准确地被识别。
## 1. 核心架构原理
系统采用 **“前端声明意图 + 后端自动触发”** 的模式:
1. **前端 App**:在调用接口注册时,通过 `raw_user_meta_data` 声明用户的目标角色(如 `consumer``merchant`)。
2. **Supabase Auth**:接收并存储这些元数据。
3. **数据库触发器 (Trigger)**:在 `auth.users` 产生新记录的一瞬间,由数据库自动读取元数据,并将用户信息连带其正确的角色属性同步到业务表 `ak_users` 中。
---
## 2. 前端实现步骤 (代码参考)
在各端 App 的注册逻辑中,需在调用 `signUp` 接口时传递 `options.data`
### 消费者端 (Consumer App)
`pages/user/register.uvue` 中:
```typescript
const options = new UTSJSONObject()
const metaData = new UTSJSONObject()
metaData.set('user_role', 'consumer') // 核心:声明为消费者
options.set('data', metaData)
const result = await supa.signUp(email, password, options)
```
### 商家端 (Merchant App)
在商家端的注册页面中,只需修改 `user_role` 的值:
```typescript
metaData.set('user_role', 'merchant') // 核心:声明为商家
```
---
## 3. 数据库实现步骤 (SQL 设置)
为了让数据库能够“看碟下菜”,必须在 Supabase SQL Editor 中运行以下脚本,安装/更新智能触发器:
```sql
-- 1. 创建或更新处理函数
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger AS $$
BEGIN
-- 向业务表插入数据,并智能识别角色
INSERT INTO public.ak_users (id, auth_id, email, role, nickname, status)
VALUES (
NEW.id,
NEW.id, -- 统一使用 Auth ID
NEW.email,
-- 核心逻辑:读取 metadata 中的 user_role如果没有传则默认为 'consumer'
COALESCE(NEW.raw_user_meta_data->>'user_role', 'consumer'),
-- 默认昵称取邮箱前缀
split_part(NEW.email, '@', 1),
1 -- 默认激活状态
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- 2. 绑定触发器
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
```
---
## 4. 三端互不干扰的优势
* **全自动入库**:一旦 SQL 触发器设置完成,前端不再需要手动调用 `ensureUserProfile``insert` 接口,减少了网络请求和前端报错几率。
* **物理隔离与 RLS 安全**:通过 `ak_users` 表的 RLS 策略(`auth.uid() = id`),确保即使用户通过 API 尝试修改他人数据,也会被数据库直接拦截。
* **统一维护**:所有端的注册逻辑在数据库层面是统一的,未来若需增加新角色(如 `admin_manager`),只需修改触发器逻辑即可,无需大规模重构代码。
---
## 5. 开发建议
* **强制校验**:在生产环境下,可以在触发器内增加校验逻辑,防止普通用户通过伪造元数据获得 `admin` 角色。
* **日志排查**:如果新用户注册后 `ak_users` 表没有数据,请检查 Supabase 控制台的 `Database -> Logs`,查看触发器执行是否有报错(通常是唯一索引冲突导致)。
---
*最后更新时间2026-03-10*

View File

@@ -0,0 +1,76 @@
# 电商平台生产环境上线准备清单 (Production Readiness Checklist)
本指南旨在指导开发者在将“消费者端”和“商家端”推向实际生产环境时,所需准备的各项核心服务申请、资质认定及技术对接点。
---
## 1. 身份认证与登录系统 (Identity & Login)
为了提供丝滑的登录体验,需申请以下第三方服务:
### A. 微信登录 (WeChat Login)
* **主体要求**:必须是企业或个体工商户(个人主体无法开通部分权限)。
* **准备工作**
* 在 [微信开放平台](https://open.weixin.qq.com/) 注册账号并完成 **开发者资质认证** (300元/年)。
* 创建一个“移动应用”获取 `AppID``AppSecret`
* 在 [Supabase 控制台](https://supabase.com/dashboard/project/_/auth/providers) 启用 **WeChat Provider** 并填入上述秘钥。
* **注意**:如果是微信小程序环境,需在 [微信公众平台](https://mp.weixin.qq.com/) 另外申请小程序账号。
### B. 手机短信验证码 (SMS Authentication)
* **推荐方案**:阿里云短信、腾讯云短信 或 Twilio。
* **准备工作**
* **签名申请**如“【XX商城】”需提交营业执照审核。
* **模板申请**:如“验证码${code}您正在进行登录操作5分钟内有效。”
* **Supabase 对接**:在 Auth -> Providers -> Phone 开启,并配置短信服务商提供的 API 秘钥。
---
## 2. 支付与结算系统 (Payments & Settlement)
支付是电商的命脉,涉及非常严格的合规性审核。
### A. 消费者端:支付方式与钱包提醒
* **资质准备**
* **微信支付**:在 [微信支付商户平台](https://pay.weixin.qq.com/) 申请“商户号”,需关联上述微信 AppID。
* **支付宝**:在 [支付宝开放平台](https://open.alipay.com/) 申请“APP支付”或“小程序支付”能力。
* **钱包逻辑实现**
* **余额系统**:在数据库中建立 `user_wallets` 表,记录 `balance`(余额)和 `points`(积分)。
* **支付拦截器**用户下单时需通过后端逻辑Edge Functions校验余额是否足够不足时弹出“余额不足请充值”或“引导去微信支付”。
* **变动提醒**:每当发生消费或退款,需通过 **微信模板消息****App 推送** 发送钱包变动实时通知。
### B. 商家端:收款账号与提现
* **资金流控制**:生产环境下,资金通常先进入平台大账户(二清合规性建议)。
* **提现准备**
* **实名认证**:商家入驻时必须上传 **身份证**、**营业执照**、**开户许可证**。
* **打款接口**:需要申请“微信支付-企业付款到零钱”或“支付宝-单笔转账到账户”能力。
* **手续费逻辑**:需确定平台抽成比例(佣金),在商家点击提现时自动计算并扣除。
---
## 3. 服务器与合规性 (Infrastructure & Compliance)
* **域名备案**:在中国境内运营,必须完成 **ICP 备案**
* **HTTPS 证书**:所有生产接口必须使用 SSL 证书(不能使用 HTTP
* **隐私政策与用户协议**:必须在 App 登录页面显著位置提供,且符合 GDPR 或国内网络安全法规定。
* **CDN 存储**:商品图片和视频应存放在对象存储(如 Supabase Storage + 自定义 CDN 域名),以保证加载速度。
---
## 4. 商家入驻所需材料模板
建议在商家端后台提供以下材料的上传入口:
1. **负责人身份证正反面**
2. **统一社会信用代码证**
3. **银行结算账户信息(开户行、支行名称、账号、持卡人姓名)**
4. **行业特许经营许可**(如卖食品需要《食品经营许可证》)。
---
## 5. 技术架构建议 (Production Stack)
* **数据库保护**:开启 Supabase 的 **RLS (行级安全策略)**,防止数据越权读取。
* **日志记录**:接入 **Sentry****阿里云日志服务**,记录生产环境中的所有前端报错(尤其是支付环节)。
* **压力测试**:上线前需进行接口压力测试,确保在促销活动期间数据库连接数不会爆满。
---
*生成日期2026-03-10*

View File

@@ -97,7 +97,7 @@
<view class="log-item" v-for="log in logs" :key="log.id">
<view class="log-left">
<text class="log-change">{{ getLevelName(log.old_level) }} → {{ getLevelName(log.new_level) }}</text>
<text class="log-reason">{{ log.reason || '系统升级' }}</text>
<text class="log-reason">{{ log.reason != null && log.reason != '' ? log.reason : '系统升级' }}</text>
</view>
<text class="log-time">{{ formatDate(log.created_at) }}</text>
</view>
@@ -154,7 +154,7 @@ const loadMemberInfo = async (): Promise<void> => {
try {
const result = await supabaseService.getUserMemberInfo()
memberInfo.value = {
const info: MemberInfo = {
member_level: result.getNumber('member_level') ?? 0,
level_name: result.getString('level_name') ?? '普通会员',
discount: result.getNumber('discount') ?? 1.0,
@@ -172,14 +172,16 @@ const loadMemberInfo = async (): Promise<void> => {
} else {
nextLevelObj = JSON.parse(JSON.stringify(nextLevelRaw)) as UTSJSONObject
}
memberInfo.value.next_level = {
const nextLevel: MemberLevel = {
id: nextLevelObj.getNumber('id') ?? 0,
name: nextLevelObj.getString('name') ?? '',
min_amount: nextLevelObj.getNumber('min_amount') ?? 0,
discount: 1.0,
description: null
}
info.next_level = nextLevel
}
memberInfo.value = info
} catch (e) {
console.error('加载会员信息失败:', e)
}

View File

@@ -29,6 +29,7 @@
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { supabaseService } from '@/utils/supabaseService.uts'
type MessageType = {
@@ -60,31 +61,14 @@ const message = ref<MessageType>({
const extraInfo = ref<ExtraInfoItem[]>([])
const loadMessage = async (id: string) => {
try {
const notifications = await supabaseService.getUserNotifications(null)
const found = notifications.find(n => n.id === id)
if (found != null) {
message.value = {
id: found.id,
type: found.type,
title: found.title,
content: found.content,
icon_url: found.icon_url,
link_url: found.link_url,
extra_data: found.extra_data,
created_at: found.created_at
}
// 解析extra_data
if (found.extra_data != null) {
parseExtraData(found.extra_data)
}
}
} catch (e) {
console.error('加载消息失败:', e)
}
const formatLabel = (key: string): string => {
if (key === 'share_code') return '分享码'
if (key === 'product_name') return '商品名称'
if (key === 'reward_amount') return '奖励金额'
if (key === 'order_no') return '订单号'
if (key === 'buyer_name') return '购买者'
if (key === 'quantity') return '数量'
return key
}
const parseExtraData = (data: any) => {
@@ -93,21 +77,29 @@ const parseExtraData = (data: any) => {
if (data == null) return
try {
let dataObj: any = data
let dataObj: UTSJSONObject | null = null
if (typeof data === 'string') {
dataObj = JSON.parse(data)
const parsed = JSON.parse(data as string)
if (parsed != null) {
dataObj = parsed as UTSJSONObject
}
} else if (data instanceof UTSJSONObject) {
dataObj = data
} else {
dataObj = JSON.parse(JSON.stringify(data)) as UTSJSONObject
}
if (typeof dataObj === 'object') {
const keys = Object.keys(dataObj)
if (dataObj != null) {
const keys = UTSJSONObject.keys(dataObj)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const value = dataObj[key]
const key = keys[i] as string
const value = dataObj.get(key)
if (value != null) {
extraInfo.value.push({
const item: ExtraInfoItem = {
label: formatLabel(key),
value: String(value)
})
value: `${value}`
}
extraInfo.value.push(item)
}
}
}
@@ -116,16 +108,32 @@ const parseExtraData = (data: any) => {
}
}
const formatLabel = (key: string): string => {
const labelMap: Record<string, string> = {
'share_code': '分享码',
'product_name': '商品名称',
'reward_amount': '奖励金额',
'order_no': '订单号',
'buyer_name': '购买者',
'quantity': '数量'
const loadMessage = async (id: string) => {
try {
const notifications = await supabaseService.getUserNotifications(null)
const found = notifications.find(n => n.id === id)
if (found != null) {
const extraData = found.extra_data
const msg: MessageType = {
id: found.id,
type: found.type,
title: found.title,
content: found.content,
icon_url: found.icon_url,
link_url: found.link_url,
extra_data: extraData,
created_at: found.created_at ?? ''
}
message.value = msg
if (extraData != null) {
parseExtraData(extraData)
}
}
} catch (e) {
console.error('加载消息失败:', e)
}
return labelMap[key] ?? key
}
const formatTime = (timeStr: string): string => {
@@ -155,13 +163,11 @@ const goToLink = () => {
}
}
onMounted(() => {
const pages = getCurrentPages()
if (pages.length > 0) {
const currentPage = pages[pages.length - 1]
const options = (currentPage as any).options
if (options != null && options.id != null) {
loadMessage(options.id as string)
onLoad((options) => {
if (options != null) {
const idVal = options['id']
if (idVal != null) {
loadMessage(idVal as string)
}
}
})

View File

@@ -220,7 +220,7 @@ const loadReviews = async (): Promise<void> => {
const loadPendingItems = async (): Promise<void> => {
loading.value = true
try {
const orders = await supabaseService.getOrders('completed')
const orders = await supabaseService.getOrders(4)
const pending: PendingItem[] = []
for (let i = 0; i < orders.length; i++) {
@@ -329,18 +329,6 @@ const submitAppend = async (): Promise<void> => {
}
}
const confirmDelete = (review: MyReviewItem): void => {
uni.showModal({
title: '提示',
content: '确定要删除这条评价吗?',
success: (res) => {
if (res.confirm) {
doDelete(review)
}
}
})
}
const doDelete = async (review: MyReviewItem): Promise<void> => {
uni.showLoading({ title: '删除中...' })
@@ -363,6 +351,18 @@ const doDelete = async (review: MyReviewItem): Promise<void> => {
}
}
const confirmDelete = (review: MyReviewItem): void => {
uni.showModal({
title: '提示',
content: '确定要删除这条评价吗?',
success: (res) => {
if (res.confirm) {
doDelete(review)
}
}
})
}
const previewImage = (images: string[], index: number): void => {
uni.previewImage({
urls: images,

View File

@@ -118,10 +118,10 @@
</view>
<!-- 分享免单入口 (已付款订单显示: 待发货、待收货、已完成,且商家开启了分享免单) -->
<view v-if="order.status >= 2 && order.status <= 4 && merchantShareFreeEnabled[order.merchant_id]" class="share-free-row" @click.stop="shareForFree(order)">
<view v-if="order.status >= 2 && order.status <= 4 && isShareFreeEnabled(order.merchant_id)" class="share-free-row" @click.stop="shareForFree(order)">
<text class="share-free-icon">🎁</text>
<text class="share-free-text">分享免单</text>
<text class="share-free-tip">分享给好友,{{ merchantRequiredCount[order.merchant_id] != null ? merchantRequiredCount[order.merchant_id] : 4 }}人购买即可免单</text>
<text class="share-free-tip">分享给好友,{{ getRequiredCount(order.merchant_id) }}人购买即可免单</text>
<text class="share-free-arrow"></text>
</view>
@@ -235,8 +235,8 @@ const statusBarHeight = ref<number>(0)
const searchKeyword = ref<string>('')
// 商家推销配置缓存
const merchantShareFreeEnabled = ref<Record<string, boolean>>({})
const merchantRequiredCount = ref<Record<string, number>>({})
const merchantShareFreeEnabled = ref<UTSJSONObject>(new UTSJSONObject())
const merchantRequiredCount = ref<UTSJSONObject>(new UTSJSONObject())
// 订单标签页 - 使用 ref 以便整体替换
const orderTabs = ref<OrderTabItem[]>([
@@ -376,6 +376,62 @@ const filterOrdersByTab = () => {
}
}
// 检查商家是否开启分享免单
const isShareFreeEnabled = (merchantId: string): boolean => {
const val = merchantShareFreeEnabled.value.get(merchantId)
return val === true
}
// 获取商家要求的购买人数
const getRequiredCount = (merchantId: string): number => {
const val = merchantRequiredCount.value.get(merchantId)
if (val != null && typeof val === 'number') {
return val as number
}
return 4
}
// 加载商家推销配置
const loadMerchantPromotionConfigs = async (orderList: OrderItem[]) => {
// 收集所有唯一的商家ID
const merchantIds = new Set<string>()
for (let i = 0; i < orderList.length; i++) {
const merchantId = orderList[i].merchant_id
const existingVal = merchantShareFreeEnabled.value.get(merchantId)
if (merchantId != null && merchantId !== '' && existingVal == null) {
merchantIds.add(merchantId)
}
}
// 批量加载商家配置
const merchantIdArray = Array.from(merchantIds)
for (let i = 0; i < merchantIdArray.length; i++) {
const merchantIdRaw = merchantIdArray[i]
const merchantId = merchantIdRaw as string
try {
const config = await supabaseService.getMerchantPromotionConfig(merchantId)
const promotionEnabled = config.get('promotion_enabled')
const shareFreeEnabled = config.get('share_free_enabled')
const requiredCount = config.get('required_count')
const isEnabled: any =
(promotionEnabled === true || promotionEnabled === 'true') &&
(shareFreeEnabled === true || shareFreeEnabled === 'true')
merchantShareFreeEnabled.value.set(merchantId, isEnabled)
if (requiredCount != null) {
merchantRequiredCount.value.set(merchantId, requiredCount)
} else {
merchantRequiredCount.value.set(merchantId, 4 as any)
}
} catch (e) {
console.error('加载商家推销配置失败:', merchantId, e)
merchantShareFreeEnabled.value.set(merchantId, false as any)
merchantRequiredCount.value.set(merchantId, 4 as any)
}
}
}
// 加载订单数据
const loadOrders = async () => {
loading.value = true
@@ -547,39 +603,6 @@ onShow(() => {
loadOrders()
})
// 加载商家推销配置
const loadMerchantPromotionConfigs = async (orderList: OrderItem[]) => {
// 收集所有唯一的商家ID
const merchantIds = new Set<string>()
for (let i = 0; i < orderList.length; i++) {
const merchantId = orderList[i].merchant_id
if (merchantId != null && merchantId !== '' && !merchantShareFreeEnabled.value.hasOwnProperty(merchantId)) {
merchantIds.add(merchantId)
}
}
// 批量加载商家配置
const merchantIdArray = Array.from(merchantIds)
for (let i = 0; i < merchantIdArray.length; i++) {
const merchantId = merchantIdArray[i]
try {
const config = await supabaseService.getMerchantPromotionConfig(merchantId)
const promotionEnabled = config.get('promotion_enabled')
const shareFreeEnabled = config.get('share_free_enabled')
const requiredCount = config.get('required_count')
merchantShareFreeEnabled.value[merchantId] =
(promotionEnabled === true || promotionEnabled === 'true') &&
(shareFreeEnabled === true || shareFreeEnabled === 'true')
merchantRequiredCount.value[merchantId] = (requiredCount as number) ?? 4
} catch (e) {
console.error('加载商家推销配置失败:', merchantId, e)
merchantShareFreeEnabled.value[merchantId] = false
merchantRequiredCount.value[merchantId] = 4
}
}
}
const formatDate = (isoString: string): string => {
if (isoString == '') return ''
const date = new Date(isoString)

View File

@@ -36,7 +36,7 @@
<text class="sales-text">销量: {{ merchant.total_sales }}</text>
</view>
</view>
<text class="enter-shop" @click.stop="goToShop">进店 ></text>
<text class="enter-shop" @click.stop="goToShop">进店 </text>
</view>
<!-- 功能主治(药品功能) -->
@@ -53,7 +53,7 @@
{{ coupon.name }}
</text>
</view>
<text class="cell-arrow">领券 ></text>
<text class="cell-arrow">领券 </text>
</view>
<!-- 商品参数 -->
@@ -62,7 +62,7 @@
<view class="cell-content">
<text class="params-summary-text">{{ getParamsSummary() }}</text>
</view>
<text class="cell-arrow">></text>
<text class="cell-arrow"></text>
</view>
<!-- 规格选择 -->
@@ -71,7 +71,7 @@
<view class="cell-content">
<text class="spec-selected">{{ selectedSpec ?? '请选择规格' }}</text>
</view>
<text class="cell-arrow">></text>
<text class="cell-arrow"></text>
</view>
<!-- 数量选择 -->
@@ -283,13 +283,13 @@ export default {
},
onLoad(options: any) {
const opts = options as UTSJSONObject
const productId = (opts.getString('productId') ?? opts.getString('id')) as string
const priceStr = opts.getString('price')
const productId = (opts['productId'] ?? opts['id']) as string | null
const priceStr = opts['price'] as string | null
const productPrice = priceStr != null ? parseFloat(priceStr) : null
const originalPriceStr = opts.getString('originalPrice')
const originalPriceStr = opts['originalPrice'] as string | null
const productOriginalPrice = originalPriceStr != null ? parseFloat(originalPriceStr) : null
let productName = opts.getString('name') as string | null
let productName = opts['name'] as string | null
if (productName != null) {
try {
const decodedName = decodeURIComponent(productName)
@@ -299,7 +299,7 @@ export default {
}
}
let productImage = opts.getString('image') as string | null
let productImage = opts['image'] as string | null
if (productImage != null) {
try {
const decodedImage = decodeURIComponent(productImage)

View File

@@ -157,6 +157,7 @@
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { supabaseService } from '@/utils/supabaseService.uts'
type ReviewItem = {
@@ -222,18 +223,19 @@ const loadStats = async (): Promise<void> => {
}
}
stats.value = {
const statsData: StatsType = {
total_count: result.getNumber('total_count') ?? 0,
avg_rating: result.getNumber('avg_rating') ?? 0,
good_rate: result.getNumber('good_rate') ?? 0,
rating_distribution: distMap
}
stats.value = statsData
} catch (e) {
console.error('加载统计失败:', e)
}
}
const loadReviews = async (pageNum: number = 1): Promise<void> => {
const loadReviews = async (pageNum: number): Promise<void> => {
loading.value = true
try {
@@ -371,13 +373,11 @@ const formatTime = (timeStr: string | null): string => {
}
}
onMounted(() => {
const pages = getCurrentPages()
if (pages.length > 0) {
const currentPage = pages[pages.length - 1]
const options = (currentPage as any).options
if (options != null && options.product_id != null) {
productId.value = options.product_id as string
onLoad((options) => {
if (options != null) {
const idVal = options['product_id']
if (idVal != null) {
productId.value = idVal as string
loadStats()
loadReviews(1)
}

View File

@@ -3,7 +3,7 @@
<view class="refund-page">
<!-- 顶部栏 -->
<view class="refund-header">
<text class="back-btn" @click="goBack"></text>
<text class="back-btn" @click="goBack"></text>
<text class="header-title">退款/售后</text>
</view>
@@ -543,7 +543,7 @@ const goBack = () => {
}
.back-btn {
font-size: 24px;
font-size: 20px;
color: #333333;
padding: 5px;
margin-right: 15px;

View File

@@ -3,9 +3,9 @@
<!-- 搜索头部 -->
<view class="search-header" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="search-bar-container">
<!-- 返回按钮:小于号加粗 -->
<!-- 返回按钮:使用转义字符的直接形式 -->
<view class="back-btn" @click="goBack">
<text class="back-icon">&lt;</text>
<text class="back-icon"></text>
</view>
<!-- 搜索框 -->
@@ -935,10 +935,8 @@ const goBack = () => {
}
.back-icon {
font-size: 24px;
font-size: 20px;
color: #333;
font-weight: bold;
font-family: monospace;
}
.search-input-container {

View File

@@ -3,11 +3,11 @@
<view class="settings-page">
<!-- 顶部栏 -->
<!--<view class="settings-header" :style="{ paddingTop: statusBarHeight + 'px' }">
<text class="back-btn" @click="goBack"></text>
<text class="back-btn" @click="goBack"></text>
<text class="header-title">设置</text>
</view>-->
<scroll-view class="settings-content" scroll-y>
<scroll-view class="settings-content" direction="vertical">
<!-- 账户设置 -->
<view class="settings-section">
<text class="section-title">账户设置</text>
@@ -714,6 +714,7 @@ const deleteAccount = () => {
.settings-page {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background-color: #f5f5f5;
}
@@ -722,6 +723,7 @@ const deleteAccount = () => {
background-color: #ffffff;
padding: 15px;
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid #e5e5e5;
}
@@ -741,6 +743,8 @@ const deleteAccount = () => {
.settings-content {
flex: 1;
width: 100%;
height: 100px;
}
.settings-section {

View File

@@ -93,6 +93,7 @@
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { supabaseService } from '@/utils/supabaseService.uts'
type ShareRecordType = {
@@ -151,7 +152,7 @@ const loadShareDetail = async (): Promise<void> => {
recordObj = JSON.parse(JSON.stringify(recordRaw)) as UTSJSONObject
}
shareRecord.value = {
const record: ShareRecordType = {
id: recordObj.getString('id') ?? '',
product_name: recordObj.getString('product_name') ?? '',
product_image: recordObj.getString('product_image'),
@@ -164,6 +165,7 @@ const loadShareDetail = async (): Promise<void> => {
created_at: recordObj.getString('created_at') ?? '',
completed_at: recordObj.getString('completed_at')
}
shareRecord.value = record
}
const purchasesRaw = result.get('secondary_purchases')
@@ -251,13 +253,11 @@ const formatTime = (timeStr: string | null): string => {
return `${y}-${m}-${d} ${hh}:${mm}`
}
onMounted(() => {
const pages = getCurrentPages()
if (pages.length > 0) {
const currentPage = pages[pages.length - 1]
const options = (currentPage as any).options
if (options != null && options.id != null) {
shareId.value = options.id as string
onLoad((options) => {
if (options != null) {
const idVal = options['id']
if (idVal != null) {
shareId.value = idVal as string
loadShareDetail()
}
}

View File

@@ -207,6 +207,10 @@ const loadShopProducts = async (id: string) => {
const rawList = res.data
if (rawList != null && Array.isArray(rawList) && rawList.length > 0) {
// 过滤掉已经在列表中的重复商品 (防止分页计算错误导致的重复)
const newItems: ProductType[] = []
const existingIds = products.value.map(p => p.id)
const list = rawList.map((item: any): ProductType => {
// 解析图片数组
let images: string[] = []
@@ -267,11 +271,20 @@ const loadShopProducts = async (id: string) => {
} as ProductType
})
// 只有在 currentPage > 1 时才需要过滤currentPage = 1 时直接替换
if (currentPage.value === 1) {
products.value = list
} else {
products.value.push(...list)
for (let i = 0; i < list.length; i++) {
if (existingIds.indexOf(list[i].id) === -1) {
newItems.push(list[i])
}
}
if (newItems.length > 0) {
products.value.push(...newItems)
}
}
currentPage.value++
hasMore.value = list.length >= pageSize.value
} else {
@@ -514,11 +527,15 @@ const goToProduct = (id: string) => {
background-color: #fff;
padding-bottom: 20px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
align-items: center; /* 使 PC 端内容居中 */
}
.shop-banner {
width: 100%;
height: 150px;
max-width: 1200px;
height: 200px; /* PC 端稍微加高一点 */
background-color: #eee;
}
@@ -527,32 +544,35 @@ const goToProduct = (id: string) => {
flex-direction: row;
align-items: center;
padding: 0 15px;
margin-top: -30px; /* Logo 向上重叠 banner */
margin-top: -30px;
position: relative;
z-index: 1;
width: 100%;
max-width: 1200px;
box-sizing: border-box;
}
.shop-logo {
width: 60px;
height: 60px;
width: 80px; /* PC 端稍微加大 */
height: 80px;
border-radius: 8px;
border: 2px solid #fff;
background-color: #fff;
margin-right: 12px;
margin-right: 15px;
}
.shop-basic-info {
flex: 1;
display: flex;
flex-direction: column;
padding-top: 30px; /* 给 logo 上浮留空间 */
padding-top: 30px;
}
.shop-name {
font-size: 18px;
font-size: 22px; /* PC 端字体加大 */
font-weight: bold;
color: #333;
margin-bottom: 4px;
margin-bottom: 8px;
}
.shop-stats {
@@ -561,11 +581,11 @@ const goToProduct = (id: string) => {
}
.stat-item {
font-size: 12px;
font-size: 14px;
color: #666;
margin-right: 12px;
margin-right: 15px;
background-color: #f0f0f0;
padding: 2px 6px;
padding: 4px 10px;
border-radius: 4px;
}
@@ -577,14 +597,14 @@ const goToProduct = (id: string) => {
}
.action-btn {
/* Common Button Styles */
border-radius: 20px;
margin-left: 10px;
margin-left: 15px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 6px 16px;
padding: 8px 24px; /* PC 端按钮加大 */
cursor: pointer;
}
.action-text {
@@ -614,39 +634,45 @@ const goToProduct = (id: string) => {
}
.shop-desc {
color: #666;
padding: 10px 15px 0;
line-height: 1.4;
padding: 15px 15px 0;
line-height: 1.6;
width: 100%;
max-width: 1200px;
box-sizing: border-box;
}
/* Coupon Styles */
.shop-coupons {
margin-top: 15px;
margin-top: 20px;
padding: 0 15px;
width: 100%;
max-width: 1200px;
box-sizing: border-box;
}
.coupon-scroll {
width: 100%;
white-space: nowrap;
flex-direction: row; /* Ensure flex direction for scroll view */
flex-direction: row;
}
.coupon-wrapper {
display: flex;
flex-direction: row;
flex-wrap: nowrap; /* Prevent wrapping */
flex-wrap: nowrap;
align-items: center;
}
.coupon-card {
display: flex; /* Changed from inline-flex to flex */
display: flex;
flex-direction: row;
background-color: #fff5f5;
border: 1px solid #ffccc7;
border-radius: 4px;
margin-right: 10px;
width: 150px; /* Slight increase */
height: 64px;
margin-right: 15px;
width: 180px; /* PC 端优惠券加宽 */
height: 70px;
overflow: hidden;
flex-shrink: 0; /* Critical for horizontal scroll */
flex-shrink: 0;
cursor: pointer;
}
.coupon-left {
flex: 1;
@@ -655,44 +681,47 @@ const goToProduct = (id: string) => {
justify-content: center;
align-items: center;
border-right: 1px dashed #ffccc7;
padding: 0 5px;
padding: 0 10px;
}
.coupon-amount {
color: #ff5000;
font-weight: bold;
font-size: 18px;
font-size: 20px;
}
.coupon-cond {
color: #999;
font-size: 10px;
font-size: 12px;
}
.coupon-right {
width: 40px;
width: 50px;
display: flex;
justify-content: center;
align-items: center;
background-color: #ff5000;
flex-direction: column;
}
.coupon-btn-label {
color: #fff;
font-size: 12px;
width: 14px; /* Force vertical flow by width constraint if needed, or just let it stack naturally if char by char */
text-align: center;
line-height: 1.2;
}
flex-direction: column;
}
.coupon-btn-label {
color: #fff;
font-size: 14px;
text-align: center;
line-height: 1.2;
}
.product-section {
padding: 15px;
padding: 20px;
width: 100%;
max-width: 1200px;
margin: 0 auto;
box-sizing: border-box;
}
.section-title {
font-size: 16px;
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 15px;
padding-left: 8px;
border-left: 4px solid #ff5000;
margin-bottom: 20px;
padding-left: 10px;
border-left: 5px solid #ff5000;
}
.product-grid {
@@ -700,7 +729,6 @@ const goToProduct = (id: string) => {
flex-direction: row;
flex-wrap: wrap;
width: 100%;
justify-content: space-between;
}
.product-item {
@@ -709,27 +737,38 @@ const goToProduct = (id: string) => {
background: #fff;
border-radius: 8px;
overflow: hidden;
width: 48%;
margin-bottom: 12px;
width: calc(50% - 10px); /* 默认两列 */
margin-right: 20px;
margin-bottom: 20px;
cursor: pointer;
transition: transform 0.2s;
}
.product-item:nth-child(2n) {
margin-right: 0;
}
.product-item:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.product-image {
width: 100%;
height: 170px;
border-radius: 8px;
margin-bottom: 8px;
height: 200px;
background: #f5f5f5;
object-fit: cover;
}
.product-name {
font-size: 13px;
font-size: 14px;
color: #333;
margin-bottom: 5px;
margin: 10px 0;
line-height: 1.4;
height: 36px;
height: 40px;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 8px;
padding: 0 10px;
}
.product-bottom {
@@ -737,20 +776,20 @@ const goToProduct = (id: string) => {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 8px 8px;
padding: 0 10px 12px;
}
.product-price {
font-size: 15px;
font-size: 18px;
color: #ff5000;
font-weight: bold;
}
.product-add-btn {
width: 24px;
height: 24px;
width: 28px;
height: 28px;
background-color: #ff5000;
border-radius: 12px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
@@ -758,27 +797,49 @@ const goToProduct = (id: string) => {
.add-icon {
color: #fff;
font-size: 16px;
font-size: 18px;
font-weight: bold;
}
/* PC/Tablet Responsive */
@media (min-width: 768px) {
.product-item {
width: 32% !important;
width: calc(33.33% - 14px) !important;
}
.product-item:nth-child(2n) {
margin-right: 20px !important;
}
.product-item:nth-child(3n) {
margin-right: 0 !important;
}
}
@media (min-width: 1024px) {
.product-item {
width: 16% !important; /* PC: 6 items */
width: calc(20% - 16px) !important; /* 五列 */
}
.product-item:nth-child(3n) {
margin-right: 20px !important;
}
.product-item:nth-child(5n) {
margin-right: 0 !important;
}
.shop-info-card, .shop-header, .product-section {
/* Limit max width on PC to avoid overly stretched content */
max-width: 1200px;
margin-left: auto;
margin-right: auto;
.shop-banner {
height: 300px; /* 大屏加宽 Banner */
border-radius: 0 0 20px 20px;
}
}
@media (min-width: 1440px) {
.product-item {
width: calc(16.66% - 17px) !important; /* 六列 */
}
.product-item:nth-child(5n) {
margin-right: 20px !important;
}
.product-item:nth-child(6n) {
margin-right: 0 !important;
}
}
</style>

View File

@@ -3,7 +3,7 @@
<view class="wallet-page">
<!-- 顶部栏 -->
<!--<view class="wallet-header">
<text class="back-btn" @click="goBack"></text>
<text class="back-btn" @click="goBack"></text>
</view>-->
<scroll-view class="wallet-content" scroll-y>