合并merchant文件

This commit is contained in:
2026-03-20 15:43:33 +08:00
parent 29f588a2b2
commit 620ae742df
12 changed files with 3477 additions and 0 deletions

View File

@@ -1,11 +1,17 @@
<!-- 商家端 - 聊天页面 -->
<template>
<view class="chat-page">
<<<<<<< HEAD
<view class="chat-header">
=======
<!-- 聊天头部 -->
<view class="chat-header" :style="{ paddingTop: navPaddingTop }">
>>>>>>> local-backup-root-cyj
<view class="header-back" @click="goBack">
<text class="back-icon"></text>
</view>
<view class="header-info">
<<<<<<< HEAD
<text class="chat-title">{{ chatTitle }}</text>
<text class="chat-status">在线</text>
</view>
@@ -32,25 +38,141 @@
</view>
</view>
<image class="avatar me" src="/static/images/default-shop.png" mode="aspectFill" />
=======
<view class="header-info-text-wrapper">
<text class="chat-title">{{ chatTitle }}</text>
<text class="chat-status">在线</text>
</view>
</view>
<view class="header-actions">
<view class="action-icon" @click="showMoreActions">
<text class="action-icon-text">⋯</text>
</view>
</view>
</view>
<!-- 聊天内容 -->
<scroll-view
scroll-y="true"
class="chat-content"
:scroll-into-view="scrollToView"
:scroll-with-animation="true"
:show-scrollbar="false"
>
<view class="chat-messages">
<!-- 系统消息 -->
<view class="message-item system">
<text class="system-text">已接入客户对话</text>
</view>
<!-- 消息列表 -->
<view
v-for="message in chatMessages"
:key="message.id"
:class="['message-item', message.is_from_user ? 'received' : 'sent']"
:id="message.viewId"
>
<!-- 客户消息 -->
<view v-if="message.is_from_user" class="message-wrapper">
<image
class="avatar"
:src="userAvatar"
mode="aspectFill"
/>
<view class="message-content-wrapper">
<text class="sender-name">{{ chatTitle }}</text>
<view class="message-bubble received-bubble" :class="{ 'image-bubble': message.msg_type === 'image' || message.content.includes('/chat_images/') }">
<text v-if="!(message.msg_type === 'image' || message.content.includes('/chat_images/'))" class="message-text">{{ message.content }}</text>
<image v-else mode="widthFix" class="message-image" :src="message.content" @click="previewImage(message.content)" />
<text class="message-time">{{ message.time }}</text>
</view>
</view>
</view>
<!-- 我的消息 -->
<view v-else class="message-wrapper me">
<view class="message-content-wrapper">
<view class="message-bubble me" :class="{ 'image-bubble': message.msg_type === 'image' || message.content.includes('/chat_images/') }">
<text v-if="!(message.msg_type === 'image' || message.content.includes('/chat_images/'))" class="message-text">{{ message.content }}</text>
<image v-else mode="widthFix" class="message-image" :src="message.content" @click="previewImage(message.content)" />
<text class="message-time">{{ message.time }}</text>
</view>
</view>
<image
class="avatar me"
:src="shopAvatar"
mode="aspectFill"
/>
>>>>>>> local-backup-root-cyj
</view>
</view>
</view>
</scroll-view>
<<<<<<< HEAD
<view class="chat-input">
<input v-model="inputText" class="input-field" placeholder="请输入消息..." confirm-type="send" @confirm="sendMessage" />
<view class="send-btn" @click="sendMessage">
<text class="send-icon">➤</text>
</view>
</view>
=======
<!-- 聊天输入区 -->
<view class="chat-input">
<view class="input-tools">
<text class="tool-icon" @click="showEmojiPicker">😊</text>
<text class="tool-icon" @click="showImagePicker">📷</text>
</view>
<view class="input-wrapper">
<input
class="message-input"
v-model="inputText"
placeholder="请输入消息..."
:focus="inputFocus"
@confirm="sendMessage"
confirm-type="send"
/>
<button
class="send-button"
:class="{ active: inputText.trim() != '' }"
@click="sendMessage"
>
发送
</button>
</view>
</view>
<!-- 表情选择器 -->
<scroll-view v-if="showEmoji" class="emoji-picker" direction="vertical">
<view class="emoji-category">
<text
v-for="emoji in emojiList"
:key="emoji"
class="emoji-item"
@click="insertEmoji(emoji)"
>
{{ emoji }}
</text>
</view>
</scroll-view>
>>>>>>> local-backup-root-cyj
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
<<<<<<< HEAD
type ChatMessageType = {
id: string
=======
import type { AkSupaRealtimeChannel } from '@/components/supadb/aksupa.uts'
type ChatMessageType = {
id: string
viewId: string
>>>>>>> local-backup-root-cyj
session_id: string
sender_id: string
receiver_id: string
@@ -59,6 +181,10 @@
is_read: boolean
is_from_user: boolean
created_at: string
<<<<<<< HEAD
=======
time: string
>>>>>>> local-backup-root-cyj
}
export default {
@@ -70,11 +196,35 @@
inputText: '',
chatMessages: [] as ChatMessageType[],
scrollToView: '',
<<<<<<< HEAD
merchantId: ''
=======
merchantId: '',
userAvatar: '/static/images/default-avatar.png',
shopAvatar: '/static/images/default-shop.png',
navPaddingTop: '30px',
showEmoji: false,
inputFocus: false,
realtimeChannel: null as AkSupaRealtimeChannel | null
}
},
computed: {
emojiList(): string[] {
return ['😊', '😂', '🤣', '😍', '😘', '🥰', '😭', '😡', '👍', '👏', '🙏', '🎉', '❤️', '🔥', '⭐']
>>>>>>> local-backup-root-cyj
}
},
onLoad(options: any) {
<<<<<<< HEAD
=======
const sysInfo = uni.getSystemInfoSync()
const statusBarH = sysInfo.statusBarHeight
this.navPaddingTop = (statusBarH + 10) + 'px'
console.log('chat page onLoad options:', options)
>>>>>>> local-backup-root-cyj
if (options.session_id) {
this.sessionId = options.session_id
}
@@ -88,13 +238,33 @@
},
onShow() {
<<<<<<< HEAD
this.loadChatMessages()
=======
console.log('chat page onShow, chatUserId:', this.chatUserId, 'merchantId:', this.merchantId)
if (this.merchantId) {
this.loadChatMessages()
this.setupRealtimeSubscription()
} else {
setTimeout(() => {
this.loadChatMessages()
this.setupRealtimeSubscription()
}, 300)
}
},
onUnload() {
if (this.realtimeChannel != null) {
supa.removeChannel(this.realtimeChannel!)
}
>>>>>>> local-backup-root-cyj
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
<<<<<<< HEAD
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
} catch (e) {}
},
@@ -141,18 +311,195 @@
this.markAsRead()
}
=======
if (session != null && session.user != null) {
this.merchantId = session.user.getString('id') || ''
}
if (!this.merchantId) {
this.merchantId = uni.getStorageSync('user_id') || ''
}
// 加载店铺头像
this.loadShopAvatar()
} catch (e) {
console.error('获取商户ID失败:', e)
}
},
async loadShopAvatar() {
try {
const response = await supa
.from('ml_shops')
.select('shop_logo')
.eq('merchant_id', this.merchantId)
.limit(1)
.execute()
if (response.data && (response.data as any[]).length > 0) {
const shopData = (response.data as any[])[0] as UTSJSONObject
const logo = shopData.getString('shop_logo')
if (logo && logo != '') {
this.shopAvatar = logo
}
}
} catch (e) {
console.error('加载店铺头像失败:', e)
}
},
async loadChatMessages() {
if (!this.chatUserId) {
console.error('chatUserId 为空')
return
}
try {
const response = await supa
.from('ml_chat_messages')
.select('*')
.or(`sender_id.eq.${this.chatUserId},receiver_id.eq.${this.chatUserId}`)
.order('created_at', { ascending: true })
.limit(200)
.execute()
console.log('聊天记录查询结果:', response.data, 'error:', response.error)
if (response.error != null) {
console.error('查询聊天记录失败:', response.error)
return
}
if (response.data && (response.data as any[]).length > 0) {
const rawData = response.data as any[]
const messages: ChatMessageType[] = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
const senderId = item.getString('sender_id') || ''
const isFromUser = senderId === this.chatUserId
const createdAt = item.getString('created_at') || ''
const msgId = item.getString('id') || ''
const safeViewId = 'msg_' + msgId.replace(/[^a-zA-Z0-9]/g, '_')
const date = new Date(createdAt)
const timeStr = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0')
messages.push({
id: msgId,
viewId: safeViewId,
session_id: item.getString('session_id') || '',
sender_id: senderId,
receiver_id: item.getString('receiver_id') || '',
content: item.getString('content') || '',
msg_type: item.getString('msg_type') || 'text',
is_read: item.getBoolean('is_read') || false,
is_from_user: isFromUser,
created_at: createdAt,
time: timeStr
} as ChatMessageType)
}
this.chatMessages = messages
this.scrollToBottom()
this.markAsRead()
} else {
console.log('没有找到聊天记录')
this.chatMessages = []
>>>>>>> local-backup-root-cyj
}
} catch (e) {
console.error('加载聊天记录失败:', e)
}
},
<<<<<<< HEAD
=======
setupRealtimeSubscription(): void {
console.log('开始建立聊天实时订阅...')
this.realtimeChannel = supa.channel('merchant-chat-' + Date.now().toString())
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'ml_chat_messages'
}, (payload: any) => {
console.log('收到实时消息:', payload)
const payloadObj = (payload instanceof UTSJSONObject) ? (payload as UTSJSONObject) : (JSON.parse(JSON.stringify(payload ?? {})) as UTSJSONObject)
const newMsgAny = payloadObj.get('new')
if (newMsgAny == null) return
const newMsg = (newMsgAny instanceof UTSJSONObject) ? (newMsgAny as UTSJSONObject) : (JSON.parse(JSON.stringify(newMsgAny)) as UTSJSONObject)
const senderId = newMsg.getString('sender_id') ?? ''
const receiverId = newMsg.getString('receiver_id') ?? ''
const msgId = newMsg.getString('id') ?? ''
const content = newMsg.getString('content') ?? ''
// 检查是否与当前聊天相关
if (senderId != this.chatUserId && receiverId != this.chatUserId) {
return
}
// 检查消息是否已存在
for (let i = 0; i < this.chatMessages.length; i++) {
if (this.chatMessages[i].id == msgId) return
}
const isFromUser = senderId === this.chatUserId
const createdAt = newMsg.getString('created_at') ?? new Date().toISOString()
const date = new Date(createdAt)
const timeStr = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0')
const safeViewId = 'msg_' + msgId.replace(/[^a-zA-Z0-9]/g, '_')
this.chatMessages.push({
id: msgId,
viewId: safeViewId,
session_id: newMsg.getString('session_id') || '',
sender_id: senderId,
receiver_id: receiverId,
content: content,
msg_type: newMsg.getString('msg_type') || 'text',
is_read: false,
is_from_user: isFromUser,
created_at: createdAt,
time: timeStr
} as ChatMessageType)
this.scrollToBottom()
if (isFromUser) {
this.markAsRead()
}
})
.subscribe((status: string, err: any | null) => {
console.log('订阅状态:', status)
if (err != null) {
console.log('订阅错误:', err)
}
})
},
scrollToBottom(): void {
if (this.chatMessages.length === 0) return
const lastMsg = this.chatMessages[this.chatMessages.length - 1]
const targetId = lastMsg.viewId
this.scrollToView = ''
setTimeout(() => {
this.scrollToView = targetId
}, 100)
},
>>>>>>> local-backup-root-cyj
async markAsRead() {
try {
await supa
.from('ml_chat_messages')
.update({ is_read: true })
.eq('receiver_id', this.merchantId)
<<<<<<< HEAD
.eq('is_read', false)
.execute()
} catch (e) {}
@@ -163,6 +510,22 @@
const content = this.inputText.trim()
this.inputText = ''
=======
.eq('sender_id', this.chatUserId)
.eq('is_read', false)
.execute()
} catch (e) {
console.error('标记已读失败:', e)
}
},
async sendMessage() {
const content = this.inputText.trim()
if (content == '') return
this.inputText = ''
this.showEmoji = false
>>>>>>> local-backup-root-cyj
try {
const newMessage = {
@@ -180,6 +543,7 @@
.insert([newMessage])
.execute()
<<<<<<< HEAD
if (!response.error) {
this.loadChatMessages()
}
@@ -198,12 +562,73 @@
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
=======
if (response.error != null) {
console.error('发送消息失败:', response.error)
uni.showToast({ title: '发送失败', icon: 'none' })
}
} catch (e) {
console.error('发送消息异常:', e)
uni.showToast({ title: '发送失败', icon: 'none' })
}
},
showEmojiPicker(): void {
this.showEmoji = !this.showEmoji
if (this.showEmoji) {
uni.hideKeyboard()
}
},
insertEmoji(emoji: string): void {
this.inputText += emoji
this.showEmoji = false
this.inputFocus = true
},
showImagePicker(): void {
uni.chooseImage({
count: 1,
success: (res) => {
console.log('选择图片:', res.tempFilePaths)
}
})
},
showMoreActions(): void {
uni.showActionSheet({
itemList: ['结束对话', '清除记录'],
success: (res) => {
switch (res.tapIndex) {
case 0:
uni.navigateBack()
break
case 1:
this.chatMessages = []
break
}
}
})
},
previewImage(url : string) {
if (url == '') return
uni.previewImage({
urls: [url],
current: 0
})
},
goBack() {
uni.navigateBack()
>>>>>>> local-backup-root-cyj
}
}
}
</script>
<style>
<<<<<<< HEAD
.chat-page { display: flex; flex-direction: column; height: 100vh; background-color: #f5f5f5; }
.chat-header { display: flex; align-items: center; padding: 20rpx 30rpx; background-color: #fff; border-bottom: 1rpx solid #eee; }
.header-back { padding: 10rpx 20rpx 10rpx 0; }
@@ -229,4 +654,51 @@
.input-field { flex: 1; height: 72rpx; background-color: #f5f5f5; border-radius: 36rpx; padding: 0 30rpx; font-size: 28rpx; }
.send-btn { margin-left: 20rpx; width: 72rpx; height: 72rpx; background-color: #007AFF; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.send-icon { font-size: 32rpx; color: #fff; }
=======
.chat-page { width: 100%; flex: 1; background-color: #f5f5f5; display: flex; flex-direction: column; overflow: hidden; }
.chat-header { background-color: white; padding-left: 15px; padding-right: 15px; padding-bottom: 10px; display: flex; flex-direction: row; align-items: center; justify-content: space-between; border-bottom-width: 1px; border-bottom-style: solid; border-bottom-color: #eee; flex-shrink: 0; }
.header-back { width: 40px; }
.back-icon { font-size: 24px; color: #333; }
.header-info { flex: 1; }
.header-info-text-wrapper { display: flex; flex-direction: column; align-items: center; }
.chat-title { font-size: 16px; font-weight: bold; color: #333; margin-bottom: 2px; }
.chat-status { font-size: 12px; color: #34c759; }
.header-actions .action-icon { font-size: 20px; color: #333; width: 40px; }
.action-icon-text { text-align: right; width: 100%; }
.chat-content { flex: 1; height: 0; padding: 10px; padding-bottom: 20px; }
.chat-messages { display: flex; flex-direction: column; padding-bottom: 80px; }
.message-item.system { display: flex; flex-direction: row; justify-content: center; margin-bottom: 20px; }
.system-text { font-size: 12px; color: #999; background-color: #f0f0f0; padding-top: 5px; padding-bottom: 5px; padding-left: 15px; padding-right: 15px; border-radius: 15px; text-align: center; }
.message-wrapper { display: flex; flex-direction: row; margin-bottom: 15px; }
.message-wrapper.me { justify-content: flex-end; }
.avatar { width: 40px; height: 40px; border-radius: 20px; margin-right: 10px; flex-shrink: 0; background-color: #e0e0e0; }
.avatar.me { margin-right: 0; margin-left: 10px; }
.message-content-wrapper { width: 260px; display: flex; flex-direction: column; }
.message-bubble { background-color: white; padding-top: 10px; padding-bottom: 10px; padding-left: 15px; padding-right: 15px; border-radius: 12px; }
.received-bubble { align-self: flex-start; border-top-left-radius: 2px; }
.message-bubble.me { background-color: #95ec69; align-self: flex-end; border-top-right-radius: 2px; }
.sender-name { font-size: 11px; color: #999; margin-bottom: 2px; align-self: flex-start; }
.message-text { font-size: 15px; color: #333; line-height: 1.4; margin-bottom: 5px; }
.message-time { font-size: 11px; color: #999; text-align: right; }
.chat-input { background-color: white; border-top-width: 1px; border-top-style: solid; border-top-color: #eee; padding-top: 10px; padding-bottom: 20px; padding-left: 15px; padding-right: 15px; position: fixed; bottom: 0; left: 0; right: 0; flex-shrink: 0; }
.input-tools { display: flex; flex-direction: row; margin-bottom: 10px; }
.tool-icon { font-size: 20px; margin-right: 15px; color: #666; }
.input-wrapper { display: flex; flex-direction: row; align-items: center; }
.message-input { flex: 1; background-color: #f5f5f5; border-radius: 20px; padding-top: 10px; padding-bottom: 10px; padding-left: 15px; padding-right: 15px; font-size: 15px; margin-right: 10px; min-height: 40px; max-height: 100px; }
.send-button { background-color: #ccc; color: white; border-radius: 20px; padding-top: 8px; padding-bottom: 8px; padding-left: 20px; padding-right: 20px; font-size: 14px; min-width: 60px; }
.send-button.active { background-color: #ff5000; }
.emoji-picker { background-color: white; border-top-width: 1px; border-top-style: solid; border-top-color: #eee; padding: 10px; height: 200px; position: fixed; bottom: 80px; left: 0; right: 0; }
.emoji-category { display: flex; flex-direction: row; flex-wrap: wrap; }
.emoji-item { font-size: 24px; padding: 8px; width: 45px; height: 45px; display: flex; align-items: center; justify-content: center; }
.image-bubble { padding: 0 !important; background-color: transparent !important; }
.image-bubble .message-time { margin-top: 5px; text-align: right; }
.message-image { width: 150px; border-radius: 8px; }
>>>>>>> local-backup-root-cyj
</style>

View File

@@ -0,0 +1,544 @@
<template>
<view class="exclusive-discounts-page">
<view class="header">
<text class="title">由于您的需求,为 {{ userName }} 配置专属商品折扣</text>
<text class="subtitle">专属打折商品的折扣不受全场默认 VIP 折扣影响。</text>
</view>
<view class="action-bar">
<button class="add-btn" @click="openProductSelect"> 添加更多覆盖商品</button>
</view>
<scroll-view scroll-y class="list-container">
<view v-if="discounts.length === 0" class="empty-tip">该客户暂无专属折扣商品</view>
<view v-for="item in discounts" :key="item.id" class="discount-item">
<view class="product-info">
<image :src="item.main_image_url || '/static/images/default-product.png'" class="product-image" mode="aspectFill" />
<view class="product-details">
<text class="product-name">{{ item.product_name }}</text>
<view class="price-row">
<text class="original-price">原价 ¥{{ item.base_price }}</text>
<text class="current-discount">当前设置: {{ parseFloat(item.discount_rate) * 10 }} 折</text>
</view>
</view>
</view>
<view class="actions">
<button class="edit-btn" @click="editDiscount(item)">改价</button>
<button class="del-btn" @click="removeDiscount(item.id)">移除</button>
</view>
</view>
</scroll-view>
<!-- 修改折扣弹窗 -->
<view class="modal" v-if="showEditModal">
<view class="modal-content">
<view class="modal-header">设置该商品的折扣倍数</view>
<view class="modal-body">
<input type="digit" class="input" v-model="editForm.rate" placeholder="示例:填写 0.85 代表 85折" />
</view>
<view class="modal-footer">
<button class="btn cancel" @click="showEditModal = false">取消</button>
<button class="btn confirm" @click="saveDiscount">确定</button>
</view>
</view>
</view>
<!-- 选择商品弹窗 -->
<view class="modal" v-if="showProductSelect">
<view class="modal-content product-modal-content">
<view class="modal-header">选择要设置折扣的商品</view>
<scroll-view scroll-y class="product-scroll">
<view class="product-p-item" v-for="p in allProducts" :key="p.id" @click="selectProductForDiscount(p)">
<image :src="p.main_image_url || '/static/images/default-product.png'" class="p-img" mode="aspectFill" />
<view class="p-info">
<text class="p-name">{{p.name}}</text>
<text class="p-price">¥{{p.base_price}}</text>
</view>
</view>
<view v-if="allProducts.length === 0" class="empty-tip">暂无可选商品</view>
</scroll-view>
<view class="modal-footer">
<button class="btn cancel" @click="showProductSelect = false">关闭</button>
</view>
</view>
</view>
</view>
</template>
<script lang="ts">
import supa from '@/components/supadb/aksupainstance.uts'
type DiscountDoc = {
id: string
user_id: string
product_id: string
discount_rate: string
product_name: string
main_image_url: string
base_price: string
}
export default {
data() {
return {
userId: '',
userName: '',
discounts: [] as DiscountDoc[],
allProducts: [] as any[], // 所有商品列表
showProductSelect: false,
showEditModal: false,
editForm: {
id: '',
product_id: '',
rate: ''
}
}
},
onLoad(options: any) {
if (options['user_id']) {
this.userId = String(options['user_id'])
} else if (options.user_id) {
this.userId = String(options.user_id)
}
if (options['user_name']) {
this.userName = decodeURIComponent(String(options['user_name']))
} else if (options.user_name) {
this.userName = decodeURIComponent(String(options.user_name))
}
if (this.userId !== '') {
this.loadDiscounts()
}
},
methods: {
async openProductSelect() {
this.showProductSelect = true
if (this.allProducts.length === 0) {
uni.showLoading({ title: '获取商品中' })
try {
let merchantId = ''
const session = supa.getSession()
if (session != null && session.user != null) {
merchantId = session.user.getString('id') || ''
}
if (merchantId === '') {
const storageId = uni.getStorageSync('user_id')
if (storageId != null) {
merchantId = String(storageId)
}
}
const res = await supa.from('ml_products')
.select('id, name, main_image_url, base_price')
.eq('status', 1)
.eq('merchant_id', merchantId)
.execute()
uni.hideLoading()
if (res.data) {
this.allProducts = res.data as any[]
}
} catch (e) {
uni.hideLoading()
console.error(e)
}
}
},
selectProductForDiscount(p: any) {
this.showProductSelect = false
// Reset form and ID when selecting a new product
this.editForm.id = ''
this.editForm.product_id = String(p['id'])
this.editForm.rate = '1.0'
this.showEditModal = true
},
async loadDiscounts() {
uni.showLoading({ title: '加载中' })
try {
// 1. 获取折扣记录(不依赖数据库外键,避免报错)
const response = await supa
.from('ml_user_product_discounts')
.select('*')
.eq('user_id', this.userId)
.execute()
if (response.error != null) {
uni.hideLoading()
const errMsg = response.error['message'] != null ? String(response.error['message']) : '加载失败'
uni.showToast({ title: '加载失败: ' + errMsg, icon: 'none', duration: 3000 })
console.error('加载折扣异常', response.error)
return
}
const rawData = response.data as any[]
if (rawData == null || rawData.length === 0) {
uni.hideLoading()
this.discounts = []
return
}
// 2. 收集所有相关的 product_id
const productIds: string[] = []
for (let i = 0; i < rawData.length; i++) {
const pid = rawData[i]['product_id']
if (pid != null) {
productIds.push(String(pid))
}
}
// 3. 查出对应商品的详情信息
let productsMap = {} as UTSJSONObject
if (productIds.length > 0) {
const prodRes = await supa
.from('ml_products')
.select('id, name, main_image_url, base_price')
.in('id', productIds)
.execute()
if (prodRes.data != null) {
const pData = prodRes.data as any[]
for (let j = 0; j < pData.length; j++) {
const p = pData[j] as any
if (p['id'] != null) {
productsMap[String(p['id'])] = p
}
}
}
}
// 4. 组装数据
this.discounts = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as any
const pid = item['product_id'] != null ? String(item['product_id']) : ''
const prod = productsMap[pid] as any
this.discounts.push({
id: item['id'] != null ? String(item['id']) : '',
user_id: item['user_id'] != null ? String(item['user_id']) : '',
product_id: pid,
discount_rate: item['discount_rate'] != null ? String(item['discount_rate']) : '1.0',
product_name: prod != null && prod['name'] != null ? String(prod['name']) : '未知商品',
main_image_url: prod != null && prod['main_image_url'] != null ? String(prod['main_image_url']) : '',
base_price: prod != null && prod['base_price'] != null ? String(prod['base_price']) : '0'
} as DiscountDoc)
}
uni.hideLoading()
} catch (e) {
uni.hideLoading()
console.error(e)
}
},
editDiscount(item: DiscountDoc) {
this.editForm.id = item.id
this.editForm.product_id = item.product_id
this.editForm.rate = item.discount_rate
this.showEditModal = true
},
async saveDiscount() {
if (!this.editForm.rate) {
uni.showToast({ title: '请输入折扣', icon: 'none' })
return
}
const rate = parseFloat(this.editForm.rate)
if (isNaN(rate) || rate <= 0 || rate > 1) {
uni.showToast({ title: '折扣应当在0~1之间', icon: 'none' })
return
}
uni.showLoading({ title: '保存中' })
try {
const payload = {
user_id: this.userId,
product_id: this.editForm.product_id,
discount_rate: rate,
updated_at: new Date().toISOString()
} as UTSJSONObject
let res: any = null
if (this.editForm.id !== '') {
res = await supa.from('ml_user_product_discounts').update(payload).eq('id', this.editForm.id).execute()
} else {
// Check if actually modifying an existing one they just didn't click "edit" on
const existing = this.discounts.find(d => d.product_id === this.editForm.product_id)
if (existing != null && existing.id !== '') {
res = await supa.from('ml_user_product_discounts').update(payload).eq('id', existing.id).execute()
} else {
res = await supa.from('ml_user_product_discounts').insert([payload]).execute()
}
}
uni.hideLoading()
if (res.error != null) {
const errMsg = res.error['message'] != null ? String(res.error['message']) : '未知错误'
uni.showToast({ title: '保存失败: ' + errMsg, icon: 'none', duration: 3000 })
console.error('保存折扣失败:', res.error)
return
}
this.showEditModal = false
uni.showToast({ title: '保存成功', icon: 'success', duration: 1500 })
setTimeout(() => {
this.loadDiscounts()
}, 1500)
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '网络异常或请求中断', icon: 'none' })
console.error(e)
}
},
async removeDiscount(id: string) {
const that = this
uni.showModal({
title: '提醒',
content: '确定移除此商品的打折?移除后将恢复通常价格',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '移除中' })
try {
const s_res = await supa.from('ml_user_product_discounts').eq('id', id).delete().execute()
uni.hideLoading()
if (s_res.error == null) {
uni.showToast({ title: '移除成功', icon: 'success' })
setTimeout(() => {
that.loadDiscounts()
}, 1500)
} else {
const errMsg = s_res.error['message'] != null ? String(s_res.error['message']) : '未知错误'
uni.showToast({ title: '移除失败: ' + errMsg, icon: 'none' })
}
} catch (e: any) {
uni.hideLoading()
const errMsg = e instanceof Error ? e.message : String(e)
uni.showToast({ title: '移除异常: ' + errMsg, icon: 'none', duration: 4000 })
console.error('移除折扣报错:', e)
}
}
}
})
}
}
}
</script>
<style>
.exclusive-discounts-page {
padding: 20rpx;
background: #f5f5f5;
min-height: 100vh;
}
.header {
padding: 30rpx;
background: #fff;
border-radius: 12rpx;
margin-bottom: 20rpx;
}
.title {
font-size: 32rpx;
font-weight: bold;
color: #333;
display: block;
}
.subtitle {
font-size: 24rpx;
color: #999;
margin-top: 10rpx;
display: block;
}
.action-bar {
margin-bottom: 20rpx;
}
.add-btn {
background: #2196F3;
color: #fff;
border-radius: 8rpx;
font-size: 28rpx;
}
.list-container {
height: calc(100vh - 260rpx);
}
.empty-tip {
text-align: center;
color: #999;
padding: 60rpx;
font-size: 28rpx;
}
.discount-item {
background: #fff;
border-radius: 12rpx;
padding: 20rpx;
margin-bottom: 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.product-info {
display: flex;
flex: 1;
}
.product-image {
width: 100rpx;
height: 100rpx;
border-radius: 8rpx;
margin-right: 20rpx;
}
.product-details {
flex: 1;
}
.product-name {
font-size: 28rpx;
color: #333;
display: block;
}
.price-row {
margin-top: 10rpx;
display: flex;
align-items: center;
gap: 16rpx;
}
.original-price {
font-size: 24rpx;
color: #999;
text-decoration: line-through;
}
.current-discount {
font-size: 24rpx;
color: #F44336;
font-weight: bold;
}
.actions {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.edit-btn {
font-size: 24rpx;
padding: 4rpx 16rpx;
background: #4CAF50;
color: #fff;
line-height: normal;
}
.del-btn {
font-size: 24rpx;
padding: 4rpx 16rpx;
background: #f44336;
color: #fff;
line-height: normal;
}
/* Modal */
.modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
}
.modal-content {
background: #fff;
width: 600rpx;
border-radius: 16rpx;
overflow: hidden;
}
.modal-header {
padding: 30rpx;
font-size: 32rpx;
font-weight: bold;
text-align: center;
border-bottom: 1rpx solid #eee;
}
.modal-body {
padding: 40rpx;
}
.input {
background: #f5f5f5;
padding: 20rpx;
border-radius: 8rpx;
font-size: 28rpx;
width: 100%;
}
.modal-footer {
display: flex;
border-top: 1rpx solid #eee;
}
.modal-footer .btn {
flex: 1;
background: none;
border: none;
font-size: 32rpx;
padding: 30rpx 0;
}
.btn.cancel {
color: #999;
border-right: 1rpx solid #eee;
}
.btn.confirm {
color: #2196F3;
font-weight: bold;
}
/* Product Selection Modal Styles */
.product-modal-content {
display: flex;
flex-direction: column;
max-height: 80vh;
}
.product-scroll {
flex: 1;
overflow-y: auto;
padding: 20rpx;
min-height: 400rpx;
}
.product-p-item {
display: flex;
align-items: center;
padding: 20rpx;
border-bottom: 1rpx solid #eee;
}
.product-p-item:active {
background: #f9f9f9;
}
.p-img {
width: 100rpx;
height: 100rpx;
border-radius: 8rpx;
margin-right: 20rpx;
}
.p-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.p-name {
font-size: 28rpx;
color: #333;
margin-bottom: 10rpx;
}
.p-price {
font-size: 26rpx;
color: #F44336;
font-weight: bold;
}
.empty-tip {
text-align: center;
padding: 40rpx;
color: #999;
font-size: 28rpx;
}
</style>

View File

@@ -1,3 +1,4 @@
<<<<<<< HEAD
<!-- 商家端首页 - UTS Android 兼容 -->
<template>
<view class="merchant-container">
@@ -134,6 +135,245 @@
</view>
</view>
</view>
=======
<!-- 商家端首页 -->
<template>
<view class="merchant-container">
<scroll-view scroll-y class="main-scroll" :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<!-- 头部区域 -->
<view class="header">
<view class="header-bg"></view>
<view class="header-content">
<view class="shop-info">
<image :src="shopInfo.shop_logo || '/static/images/default-shop.png'" class="shop-logo" mode="aspectFill" @click="goToSettings" />
<view class="shop-details">
<text class="shop-name">{{ shopInfo.shop_name || '我的店铺' }}</text>
<view class="shop-meta">
<view class="meta-item">
<text class="meta-icon">⭐</text>
<text class="meta-value">{{ shopInfo.rating_avg || 5.0 }}</text>
</view>
<view class="meta-divider"></view>
<view class="meta-item">
<text class="meta-icon">📦</text>
<text class="meta-value">{{ shopInfo.total_sales || 0 }}销量</text>
</view>
</view>
</view>
<view class="header-actions">
<view class="action-btn" @click="goToMessages">
<text class="action-icon">🔔</text>
<view v-if="unreadCount > 0" class="action-badge"><text>{{ unreadCount > 99 ? '99+' : unreadCount }}</text></view>
</view>
<view class="action-btn" @click="goToSettings">
<text class="action-icon">⚙️</text>
</view>
</view>
</view>
</view>
</view>
<view class="content-area">
<!-- 今日数据卡片 -->
<view class="stats-card">
<view class="stats-header">
<view class="stats-title-row">
<text class="stats-title">📊 今日数据</text>
<text class="stats-date">{{ currentDate }}</text>
</view>
</view>
<view class="stats-grid">
<view class="stats-item">
<view class="stats-icon-wrap blue">
<text class="stats-icon">📋</text>
</view>
<text class="stats-value">{{ todayStats.orders || 0 }}</text>
<text class="stats-label">订单数</text>
</view>
<view class="stats-item">
<view class="stats-icon-wrap green">
<text class="stats-icon">💰</text>
</view>
<text class="stats-value">¥{{ formatNumber(todayStats.sales) }}</text>
<text class="stats-label">销售额</text>
</view>
<view class="stats-item">
<view class="stats-icon-wrap orange">
<text class="stats-icon">👥</text>
</view>
<text class="stats-value">{{ todayStats.visitors || 0 }}</text>
<text class="stats-label">访客数</text>
</view>
<view class="stats-item">
<view class="stats-icon-wrap purple">
<text class="stats-icon">📈</text>
</view>
<text class="stats-value">{{ todayStats.conversion || 0 }}%</text>
<text class="stats-label">转化率</text>
</view>
</view>
</view>
<!-- 待处理事项 -->
<view class="section-card">
<view class="section-header">
<text class="section-title">🔔 待处理事项</text>
</view>
<view class="pending-grid">
<view class="pending-item" @click="goToOrders('pending')">
<view class="pending-icon-wrap orange">
<text class="pending-icon">📦</text>
</view>
<view class="pending-info">
<text class="pending-count" v-if="pendingCounts.pending_shipment > 0">{{ pendingCounts.pending_shipment }}</text>
<text class="pending-text">待发货</text>
</view>
</view>
<view class="pending-item" @click="goToOrders('refund')">
<view class="pending-icon-wrap red">
<text class="pending-icon">↩️</text>
</view>
<view class="pending-info">
<text class="pending-count" v-if="pendingCounts.refund_requests > 0">{{ pendingCounts.refund_requests }}</text>
<text class="pending-text">退款</text>
</view>
</view>
<view class="pending-item" @click="goToInventory">
<view class="pending-icon-wrap yellow">
<text class="pending-icon">⚠️</text>
</view>
<view class="pending-info">
<text class="pending-count" v-if="pendingCounts.low_stock > 0">{{ pendingCounts.low_stock }}</text>
<text class="pending-text">库存预警</text>
</view>
</view>
<view class="pending-item" @click="goToReviews">
<view class="pending-icon-wrap blue">
<text class="pending-icon">💬</text>
</view>
<view class="pending-info">
<text class="pending-count" v-if="pendingCounts.pending_reviews > 0">{{ pendingCounts.pending_reviews }}</text>
<text class="pending-text">待回复</text>
</view>
</view>
</view>
</view>
<!-- 常用功能 -->
<view class="section-card">
<view class="section-header">
<text class="section-title">🚀 常用功能</text>
</view>
<view class="shortcuts-grid">
<view class="shortcut-item" @click="goToProducts('add')">
<view class="shortcut-icon-wrap gradient-blue">
<text class="shortcut-icon"></text>
</view>
<text class="shortcut-text">发布商品</text>
</view>
<view class="shortcut-item" @click="goToOrders('all')">
<view class="shortcut-icon-wrap gradient-orange">
<text class="shortcut-icon">📋</text>
</view>
<text class="shortcut-text">订单管理</text>
</view>
<view class="shortcut-item" @click="goToProducts('manage')">
<view class="shortcut-icon-wrap gradient-green">
<text class="shortcut-icon">📦</text>
</view>
<text class="shortcut-text">商品管理</text>
</view>
<view class="shortcut-item" @click="goToInventory">
<view class="shortcut-icon-wrap gradient-purple">
<text class="shortcut-icon">📊</text>
</view>
<text class="shortcut-text">库存管理</text>
</view>
<view class="shortcut-item" @click="goToPromotions">
<view class="shortcut-icon-wrap gradient-red">
<text class="shortcut-icon">🎯</text>
</view>
<text class="shortcut-text">营销活动</text>
</view>
<view class="shortcut-item" @click="goToStatistics">
<view class="shortcut-icon-wrap gradient-cyan">
<text class="shortcut-icon">📈</text>
</view>
<text class="shortcut-text">数据统计</text>
</view>
<view class="shortcut-item" @click="goToFinance">
<view class="shortcut-icon-wrap gradient-yellow">
<text class="shortcut-icon">💰</text>
</view>
<text class="shortcut-text">财务结算</text>
</view>
<view class="shortcut-item" @click="goToMembers">
<view class="shortcut-icon-wrap gradient-pink">
<text class="shortcut-icon">VIP</text>
</view>
<text class="shortcut-text">会员管理</text>
</view>
<view class="shortcut-item" @click="goToSettings">
<view class="shortcut-icon-wrap gradient-pink">
<text class="shortcut-icon">🏪</text>
</view>
<text class="shortcut-text">店铺设置</text>
</view>
</view>
</view>
<!-- 最新订单 -->
<view class="section-card">
<view class="section-header">
<text class="section-title">🛒 最新订单</text>
<text class="section-more" @click="goToOrders('all')">查看全部 </text>
</view>
<view v-if="recentOrders.length === 0" class="empty-orders">
<text class="empty-icon">📭</text>
<text class="empty-text">暂无订单</text>
<text class="empty-hint">有新订单时会在这里显示</text>
</view>
<view v-else class="orders-list">
<view v-for="order in recentOrders" :key="order.id" class="order-card" @click="goToOrderDetail(order.id)">
<view class="order-header">
<view class="order-no-wrap">
<text class="order-label">订单号</text>
<text class="order-no">{{ order.order_no }}</text>
</view>
<text class="order-status" :class="getOrderStatusClass(order.order_status)">{{ getOrderStatusText(order.order_status) }}</text>
</view>
<view class="order-goods">
<view v-for="item in order.items.slice(0, 3)" :key="item.id" class="goods-item">
<image :src="item.image_url || '/static/images/default-product.png'" class="goods-image" mode="aspectFill" />
<view class="goods-info">
<text class="goods-name">{{ item.product_name }}</text>
<view class="goods-bottom">
<text class="goods-spec" v-if="item.sku_name">{{ item.sku_name }}</text>
<text class="goods-qty">×{{ item.quantity }}</text>
</view>
</view>
<text class="goods-price">¥{{ item.price }}</text>
</view>
<view v-if="order.items.length > 3" class="goods-more">
<text>还有{{ order.items.length - 3 }}件商品</text>
</view>
</view>
<view class="order-footer">
<text class="order-time">{{ formatTime(order.created_at) }}</text>
<view class="order-amount-wrap">
<text class="amount-label">合计</text>
<text class="amount-value">¥{{ order.total_amount.toFixed(2) }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 底部安全区域 -->
<view class="safe-bottom"></view>
</view>
</scroll-view>
>>>>>>> local-backup-root-cyj
</view>
</template>
@@ -219,7 +459,20 @@
low_stock: 0,
pending_reviews: 0
} as PendingCountsType,
<<<<<<< HEAD
recentOrders: [] as OrderType[]
=======
recentOrders: [] as OrderType[],
unreadCount: 0,
refreshing: false
}
},
computed: {
currentDate(): string {
const now = new Date()
return `${now.getMonth() + 1}月${now.getDate()}日`
>>>>>>> local-backup-root-cyj
}
},
@@ -229,6 +482,7 @@
onShow() {
if (this.merchantId) {
<<<<<<< HEAD
this.loadMerchantData()
this.loadTodayStats()
this.loadPendingCounts()
@@ -239,16 +493,36 @@
this.loadTodayStats()
this.loadPendingCounts()
this.loadRecentOrders()
=======
this.loadAllData()
this.startRealtimeSubscription()
} else {
setTimeout(() => {
this.loadAllData()
this.startRealtimeSubscription()
>>>>>>> local-backup-root-cyj
}, 500)
}
},
<<<<<<< HEAD
methods: {
formatNumber(value: number | null): string {
if (value == null) return '0.00'
return value.toFixed(2)
},
=======
onHide() {
this.stopRealtimeSubscription()
},
onUnload() {
this.stopRealtimeSubscription()
},
methods: {
>>>>>>> local-backup-root-cyj
async initMerchantId() {
try {
const session = supa.getSession()
@@ -263,6 +537,56 @@
}
},
<<<<<<< HEAD
=======
startRealtimeSubscription() {
if (!this.merchantId) return
// 监听订单表的变化
try {
supa.channel('ml_orders_realtime')
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'ml_orders',
filter: `merchant_id=eq.${this.merchantId}`
}, (payload) => {
console.log('收到订单实时更新:', payload)
// 延迟一下再刷新,避免连续变动导致频繁请求
setTimeout(() => {
this.loadTodayStats()
this.loadPendingCounts()
this.loadRecentOrders()
}, 500)
})
.subscribe()
} catch (e) {
console.error('订阅实时更新失败:', e)
}
},
stopRealtimeSubscription() {
try {
supa.channel('ml_orders_realtime').unsubscribe()
} catch (e) {
console.error('取消订阅失败:', e)
}
},
async loadAllData() {
await this.loadMerchantData()
await this.loadTodayStats()
await this.loadPendingCounts()
await this.loadRecentOrders()
await this.loadUnreadCount()
},
formatNumber(value: number | null): string {
if (value == null) return '0.00'
return value.toFixed(2)
},
>>>>>>> local-backup-root-cyj
async loadMerchantData() {
try {
const response = await supa
@@ -272,7 +596,12 @@
.limit(1)
.execute()
<<<<<<< HEAD
if (response.error != null || !response.data || (response.data as any[]).length === 0) {
=======
if (response.error != null) { console.error('ml_shops请求500报错', response.error) }
if (response.error != null || !response.data || (response.data as any[]).length === 0) {
>>>>>>> local-backup-root-cyj
this.shopInfo = {
id: null,
merchant_id: this.merchantId,
@@ -291,7 +620,11 @@
const rawData = (response.data as any[])[0] as UTSJSONObject
this.shopInfo = {
<<<<<<< HEAD
id: rawData.getString('id') || null,
=======
id: rawData.getString('id') || null,
>>>>>>> local-backup-root-cyj
merchant_id: rawData.getString('merchant_id') || null,
shop_name: rawData.getString('shop_name') || '我的店铺',
shop_logo: rawData.getString('shop_logo') || null,
@@ -303,6 +636,43 @@
total_sales: rawData.getNumber('total_sales') || 0,
status: rawData.getNumber('status') || 1
}
<<<<<<< HEAD
=======
// 重新动态查询并计算该店铺下所有商品的真实销量总和
try {
const salesRes = await supa
.from('ml_products')
.select('sale_count')
.eq('merchant_id', this.merchantId)
.execute()
if (salesRes.error != null) { console.error('ml_products sale_count报错', salesRes.error) }
if (salesRes.data != null) {
let calcTotalSales: number = 0
const salesData = salesRes.data as any[]
for (let i = 0; i < salesData.length; i++) {
const productInfo = salesData[i] as UTSJSONObject
const currentSale = productInfo.getNumber('sale_count')
if (currentSale != null) {
calcTotalSales += currentSale
}
}
let baseSales: number = 0
if (this.shopInfo.total_sales != null) {
baseSales = Number(this.shopInfo.total_sales)
}
if (calcTotalSales > baseSales) {
this.shopInfo.total_sales = calcTotalSales
}
}
} catch (e) {
console.error('获取店铺真实销量失败:', e)
}
>>>>>>> local-backup-root-cyj
} catch (e) {
console.error('加载店铺信息失败:', e)
}
@@ -310,6 +680,7 @@
async loadTodayStats() {
try {
<<<<<<< HEAD
const response = await supa
.from('ml_orders')
.select('total_amount, order_status', { count: 'exact' })
@@ -323,24 +694,89 @@
let totalOrders = 0
let totalSales = 0
=======
// 1. 获取所有订单
const response = await supa
.from('ml_orders')
.select(`
total_amount,
order_status,
created_at,
order_items (quantity)
`)
.eq('merchant_id', this.merchantId)
.execute()
if (response.error != null) { console.error('ml_orders stats报错', response.error); return }
let todayOrders = 0
let todaySales = 0
let allTimeSalesVolume = 0 // 总销量(件数)
const now = new Date()
// 获取今日0点的毫秒数 (本地时间)
const todayStartMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
>>>>>>> local-backup-root-cyj
const rawData = response.data as any[]
if (rawData != null) {
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
const status = item.getNumber('order_status')
<<<<<<< HEAD
if (status >= 2) {
totalOrders++
totalSales += item.getNumber('total_amount') || 0
=======
// 有效订单(已支付、已发货、已完成) >= 2
// 如果是退款(0)或取消(5),可能不计入今日销售额,这里按需调整
if (status != null && status >= 2 && status < 5) {
// 计算总销量(即售出的商品总件数)
const itemsObj = item.get('order_items')
if (itemsObj != null && Array.isArray(itemsObj)) {
const itemsArr = itemsObj as any[]
for (let j = 0; j < itemsArr.length; j++) {
const orderItem = itemsArr[j] as UTSJSONObject
allTimeSalesVolume += Math.floor(orderItem.getNumber('quantity') || 1)
}
} else {
allTimeSalesVolume += 1
}
// 判断是否是今日数据
const createdAtStr = item.getString('created_at') || ''
if (createdAtStr.length > 0) {
const orderDateMs = new Date(createdAtStr).getTime()
if (orderDateMs >= todayStartMs) {
todayOrders++
todaySales += item.getNumber('total_amount') || 0
}
}
>>>>>>> local-backup-root-cyj
}
}
}
<<<<<<< HEAD
this.todayStats = {
orders: totalOrders,
sales: totalSales,
visitors: Math.floor(totalOrders * 3),
conversion: totalOrders > 0 ? 15 : 0
=======
// 更新店铺总销量显示
let currentShopSales = Number(this.shopInfo.total_sales || 0)
if (allTimeSalesVolume > currentShopSales) {
this.shopInfo.total_sales = allTimeSalesVolume
}
this.todayStats = {
orders: todayOrders,
sales: todaySales,
visitors: Math.floor(todayOrders * (2.5 + Math.random())) + 5, // 模拟访客数
conversion: todayOrders > 0 ? (12 + Math.floor(Math.random() * 8)) : 0 // 模拟转化率
>>>>>>> local-backup-root-cyj
}
} catch (e) {
console.error('获取今日统计异常:', e)
@@ -356,22 +792,39 @@
.eq('order_status', 2)
.execute()
<<<<<<< HEAD
const refundRes = await supa
=======
if (pendingShipmentRes.error != null) { console.error('pendingShipment报错', pendingShipmentRes.error) }
const refundRes = await supa
>>>>>>> local-backup-root-cyj
.from('ml_orders')
.select('id', { count: 'exact' })
.eq('merchant_id', this.merchantId)
.eq('order_status', 0)
.execute()
<<<<<<< HEAD
const lowStockRes = await supa
=======
if (refundRes.error != null) { console.error('refundRes报错', refundRes.error) }
const lowStockRes = await supa
>>>>>>> local-backup-root-cyj
.from('ml_products')
.select('id', { count: 'exact' })
.eq('merchant_id', this.merchantId)
.lte('total_stock', 10)
<<<<<<< HEAD
.gte('total_stock', 0)
.execute()
this.pendingCounts = {
=======
.execute()
if (lowStockRes.error != null) { console.error('lowStockRes报错', lowStockRes.error) }
this.pendingCounts = {
>>>>>>> local-backup-root-cyj
pending_shipment: pendingShipmentRes.total || 0,
refund_requests: refundRes.total || 0,
low_stock: lowStockRes.total || 0,
@@ -388,7 +841,11 @@
.from('ml_orders')
.select(`
*,
<<<<<<< HEAD
order_items!inner (
=======
order_items (
>>>>>>> local-backup-root-cyj
id,
product_id,
product_name,
@@ -403,10 +860,15 @@
.limit(5)
.execute()
<<<<<<< HEAD
if (response.error != null || !response.data) {
this.recentOrders = []
return
}
=======
if (response.error != null) { console.error('recentOrders报错', response.error) }
if (response.error != null || !response.data) { this.recentOrders = []; return; }
>>>>>>> local-backup-root-cyj
const rawData = response.data as any[]
const ordersData: OrderType[] = []
@@ -447,6 +909,7 @@
this.recentOrders = ordersData
} catch (e) {
<<<<<<< HEAD
console.error('加载最新订单异常:', e)
}
},
@@ -472,6 +935,51 @@
case 0: return '退款中'
default: return '未知状态'
}
=======
console.error('加载最新订单异常:', e); uni.showModal({title: '最新订单报错', content: e.toString()})
}
},
async loadUnreadCount() {
try {
const response = await supa
.from('ml_chat_messages')
.select('id', { count: 'exact' })
.eq('receiver_id', this.merchantId)
.eq('is_read', false)
.execute()
if (response.error != null) { uni.showModal({title: 'ml_chat_messages报错', content: JSON.stringify(response.error)}) }
this.unreadCount = response.total || 0
} catch (e) {
console.error('获取未读消息数失败:', e)
}
},
onRefresh() {
this.refreshing = true
this.loadAllData().then(() => {
this.refreshing = false
})
},
getOrderStatusClass(status: number): string {
if (status === 1) return 'status-pending'
if (status === 2) return 'status-paid'
if (status === 3) return 'status-shipped'
if (status === 4) return 'status-completed'
if (status === 0) return 'status-refund'
return 'status-default'
},
getOrderStatusText(status: number): string {
if (status === 1) return '待付款'
if (status === 2) return '待发货'
if (status === 3) return '已发货'
if (status === 4) return '已完成'
if (status === 0) return '退款中'
return '未知'
>>>>>>> local-backup-root-cyj
},
formatTime(timeStr: string): string {
@@ -480,6 +988,7 @@
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / (1000 * 60))
<<<<<<< HEAD
if (minutes < 60) {
return `${minutes}分钟前`
@@ -542,12 +1051,67 @@
uni.navigateTo({
url: `/pages/mall/merchant/order-detail?id=${orderId}`
})
=======
if (minutes < 60) return `${minutes}分钟前`
if (minutes < 1440) return `${Math.floor(minutes / 60)}小时前`
return `${date.getMonth() + 1}-${date.getDate()}`
},
goToMessages() {
uni.navigateTo({ url: '/pages/mall/merchant/messages' })
},
goToSettings() {
uni.navigateTo({ url: '/pages/mall/merchant/shop-edit' })
},
goToOrders(type: string) {
uni.navigateTo({ url: `/pages/mall/merchant/orders?type=${type}` })
},
goToProducts(type: string) {
if (type === 'add') {
uni.navigateTo({ url: '/pages/mall/merchant/product-edit' })
} else {
uni.navigateTo({ url: '/pages/mall/merchant/products' })
}
},
goToPromotions() {
uni.navigateTo({ url: '/pages/mall/merchant/promotions' })
},
goToStatistics() {
uni.navigateTo({ url: '/pages/mall/merchant/statistics' })
},
goToFinance() {
uni.navigateTo({ url: '/pages/mall/merchant/finance' })
},
goToReviews() {
uni.navigateTo({ url: '/pages/mall/merchant/reviews' })
},
goToInventory() {
uni.navigateTo({ url: '/pages/mall/merchant/inventory' })
},
goToMembers() {
uni.navigateTo({ url: '/pages/mall/merchant/members' })
},
goToOrderDetail(orderId: string) {
uni.navigateTo({ url: `/pages/mall/merchant/order-detail?id=${orderId}` })
>>>>>>> local-backup-root-cyj
}
}
}
</script>
<style>
<<<<<<< HEAD
.merchant-container {
background-color: #f5f5f5;
min-height: 100vh;
@@ -872,3 +1436,115 @@
color: #999;
}
</style>
=======
.merchant-container { background-color: #f5f7fa; min-height: 100vh; }
.main-scroll { height: 100vh; }
.header { position: relative; padding-bottom: 30rpx; }
.header-bg { position: absolute; top: 0; left: 0; right: 0; height: 300rpx; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 0 0 40rpx 40rpx; }
.header-content { position: relative; padding: 40rpx 30rpx 0; }
.shop-info { display: flex; flex-direction: row; align-items: center; }
.shop-logo { width: 110rpx; height: 110rpx; border-radius: 20rpx; border-width: 4rpx; border-style: solid; border-color: rgba(255,255,255,0.8); margin-right: 24rpx; background-color: #fff; }
.shop-details { flex: 1; }
.shop-name { font-size: 40rpx; font-weight: bold; color: #fff; margin-bottom: 12rpx; }
.shop-meta { display: flex; flex-direction: row; align-items: center; }
.meta-item { display: flex; flex-direction: row; align-items: center; }
.meta-icon { font-size: 24rpx; margin-right: 6rpx; }
.meta-value { font-size: 26rpx; color: rgba(255,255,255,0.9); }
.meta-divider { width: 2rpx; height: 24rpx; background-color: rgba(255,255,255,0.3); margin-left: 20rpx; margin-right: 20rpx; }
.header-actions { display: flex; flex-direction: row; }
.action-btn { position: relative; margin-left: 24rpx; width: 72rpx; height: 72rpx; background-color: rgba(255,255,255,0.2); border-radius: 36rpx; display: flex; align-items: center; justify-content: center; }
.action-icon { font-size: 36rpx; }
.action-badge { position: absolute; top: -4rpx; right: -4rpx; min-width: 36rpx; height: 36rpx; background-color: #FF3B30; border-radius: 18rpx; display: flex; align-items: center; justify-content: center; padding-left: 8rpx; padding-right: 8rpx; border-width: 2rpx; border-style: solid; border-color: #fff; }
.action-badge-text { font-size: 20rpx; color: #fff; font-weight: bold; }
.content-area { padding-left: 24rpx; padding-right: 24rpx; padding-bottom: 30rpx; margin-top: 10rpx; }
.stats-card { background-color: #fff; border-radius: 24rpx; padding: 28rpx; margin-bottom: 24rpx; }
.stats-header { margin-bottom: 24rpx; }
.stats-title-row { display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.stats-title { font-size: 32rpx; font-weight: bold; color: #333; }
.stats-date { font-size: 24rpx; color: #999; background-color: #f5f7fa; padding-top: 6rpx; padding-bottom: 6rpx; padding-left: 16rpx; padding-right: 16rpx; border-radius: 12rpx; }
.stats-grid { display: flex; flex-direction: row; justify-content: space-between; }
.stats-item { width: 160rpx; display: flex; flex-direction: column; align-items: center; }
.stats-icon-wrap { width: 64rpx; height: 64rpx; border-radius: 16rpx; display: flex; align-items: center; justify-content: center; margin-bottom: 12rpx; }
.stats-icon-wrap.blue { background-color: #E3F2FD; }
.stats-icon-wrap.green { background-color: #E8F5E9; }
.stats-icon-wrap.orange { background-color: #FFF3E0; }
.stats-icon-wrap.purple { background-color: #F3E5F5; }
.stats-icon { font-size: 28rpx; }
.stats-value { font-size: 36rpx; font-weight: bold; color: #333; margin-bottom: 4rpx; }
.stats-label { font-size: 24rpx; color: #999; }
.section-card { background-color: #fff; border-radius: 24rpx; padding: 28rpx; margin-bottom: 24rpx; }
.section-header { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 24rpx; }
.section-title { font-size: 32rpx; font-weight: bold; color: #333; }
.section-more { font-size: 26rpx; color: #007AFF; padding-top: 8rpx; padding-bottom: 8rpx; padding-left: 16rpx; padding-right: 16rpx; background-color: #E3F2FD; border-radius: 12rpx; }
.pending-grid { display: flex; flex-direction: row; justify-content: space-between; }
.pending-item { width: 160rpx; display: flex; flex-direction: column; align-items: center; padding-top: 16rpx; padding-bottom: 16rpx; }
.pending-icon-wrap { width: 88rpx; height: 88rpx; border-radius: 24rpx; display: flex; align-items: center; justify-content: center; margin-bottom: 12rpx; }
.pending-icon-wrap.orange { background-color: #FFF3E0; }
.pending-icon-wrap.red { background-color: #FFEBEE; }
.pending-icon-wrap.yellow { background-color: #FFFDE7; }
.pending-icon-wrap.blue { background-color: #E3F2FD; }
.pending-icon { font-size: 40rpx; }
.pending-info { display: flex; flex-direction: column; align-items: center; }
.pending-count { font-size: 32rpx; font-weight: bold; color: #FF6B35; margin-bottom: 4rpx; }
.pending-text { font-size: 24rpx; color: #666; }
.shortcuts-grid { display: flex; flex-direction: row; flex-wrap: wrap; }
.shortcut-item { width: 25%; display: flex; flex-direction: column; align-items: center; padding-top: 20rpx; padding-bottom: 20rpx; }
.shortcut-icon-wrap { width: 88rpx; height: 88rpx; border-radius: 24rpx; display: flex; align-items: center; justify-content: center; margin-bottom: 12rpx; }
.shortcut-icon-wrap.gradient-blue { background-color: #667eea; }
.shortcut-icon-wrap.gradient-orange { background-color: #f093fb; }
.shortcut-icon-wrap.gradient-green { background-color: #4facfe; }
.shortcut-icon-wrap.gradient-purple { background-color: #a18cd1; }
.shortcut-icon-wrap.gradient-red { background-color: #ff9a9e; }
.shortcut-icon-wrap.gradient-cyan { background-color: #a1c4fd; }
.shortcut-icon-wrap.gradient-yellow { background-color: #f6d365; }
.shortcut-icon-wrap.gradient-pink { background-color: #ffecd2; }
.shortcut-icon { font-size: 40rpx; }
.shortcut-text { font-size: 24rpx; color: #666; }
.empty-orders { display: flex; flex-direction: column; align-items: center; justify-content: center; padding-top: 80rpx; padding-bottom: 80rpx; }
.empty-icon { font-size: 100rpx; margin-bottom: 20rpx; }
.empty-text { font-size: 30rpx; color: #666; margin-bottom: 8rpx; }
.empty-hint { font-size: 24rpx; color: #999; }
.orders-list { display: flex; flex-direction: column; }
.order-card { background-color: #f9fafb; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; border-width: 1rpx; border-style: solid; border-color: #eee; }
.order-header { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
.order-no-wrap { display: flex; flex-direction: row; align-items: center; }
.order-label { font-size: 22rpx; color: #999; background-color: #eee; padding-top: 4rpx; padding-bottom: 4rpx; padding-left: 12rpx; padding-right: 12rpx; border-radius: 8rpx; margin-right: 12rpx; }
.order-no { font-size: 26rpx; color: #333; font-weight: 500; }
.order-status { font-size: 24rpx; padding-top: 8rpx; padding-bottom: 8rpx; padding-left: 20rpx; padding-right: 20rpx; border-radius: 20rpx; font-weight: 500; }
.status-pending { background-color: #FFF3E0; color: #FF9800; }
.status-paid { background-color: #E3F2FD; color: #2196F3; }
.status-shipped { background-color: #E8F5E9; color: #4CAF50; }
.status-completed { background-color: #F3E5F5; color: #9C27B0; }
.status-refund { background-color: #FFEBEE; color: #F44336; }
.order-goods { margin-bottom: 16rpx; }
.goods-item { display: flex; flex-direction: row; align-items: center; margin-bottom: 16rpx; background-color: #fff; padding: 16rpx; border-radius: 12rpx; }
.goods-image { width: 100rpx; height: 100rpx; border-radius: 12rpx; margin-right: 16rpx; background-color: #f5f5f5; }
.goods-info { flex: 1; }
.goods-name { font-size: 28rpx; color: #333; margin-bottom: 8rpx; font-weight: 500; }
.goods-bottom { display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.goods-spec { font-size: 22rpx; color: #999; }
.goods-qty { font-size: 24rpx; color: #999; }
.goods-price { font-size: 28rpx; color: #FF6B35; font-weight: bold; }
.goods-more { text-align: center; padding-top: 12rpx; padding-bottom: 12rpx; font-size: 24rpx; color: #999; background-color: #fff; border-radius: 12rpx; }
.order-footer { display: flex; flex-direction: row; justify-content: space-between; align-items: center; padding-top: 16rpx; border-top-width: 1rpx; border-top-style: solid; border-top-color: #eee; }
.order-time { font-size: 24rpx; color: #999; }
.order-amount-wrap { display: flex; flex-direction: row; align-items: center; }
.amount-label { font-size: 24rpx; color: #999; margin-right: 8rpx; }
.amount-value { font-size: 32rpx; font-weight: bold; color: #FF6B35; }
.safe-bottom { height: 30rpx; }
</style>
>>>>>>> local-backup-root-cyj

View File

@@ -57,14 +57,36 @@
<text class="label">当前库存</text>
<text class="value">{{ currentProduct?.total_stock }}</text>
</view>
<<<<<<< HEAD
<view class="form-item">
<text class="label">新库存</text>
<input class="input" type="number" v-model="newStock" placeholder="请输入新库存"/>
=======
<view class="adjust-type">
<view class="type-btn" :class="{ active: adjustType === 'set' }" @click="adjustType = 'set'">直接设为</view>
<view class="type-btn" :class="{ active: adjustType === 'add' }" @click="adjustType = 'add'">增加</view>
<view class="type-btn" :class="{ active: adjustType === 'sub' }" @click="adjustType = 'sub'">减少</view>
</view>
<view class="form-item">
<text class="label">{{ adjustType === 'set' ? '新库存数量' : '调整数值' }}</text>
<input class="input" type="number" v-model="newStock" :placeholder="adjustType === 'set' ? '请输入新库存' : '请输入数值'"/>
</view>
<view class="form-item">
<text class="label">备注 (可选)</text>
<input class="input" v-model="stockRemark" placeholder="如:入库、损耗等"/>
>>>>>>> local-backup-root-cyj
</view>
</view>
<view class="modal-footer">
<view class="modal-btn cancel" @click="closeStockModal">取消</view>
<<<<<<< HEAD
<view class="modal-btn confirm" @click="saveStock">保存</view>
=======
<view class="modal-btn confirm" @click="saveStock">确认提交</view>
>>>>>>> local-backup-root-cyj
</view>
</view>
</view>
@@ -96,7 +118,13 @@
stats: { totalProducts: 0, lowStock: 0, outOfStock: 0 },
showStockModal: false,
currentProduct: null as ProductType | null,
<<<<<<< HEAD
newStock: ''
=======
newStock: '',
adjustType: 'set', // 'set', 'add', 'sub'
stockRemark: ''
>>>>>>> local-backup-root-cyj
}
},
@@ -105,6 +133,10 @@
},
onShow() {
<<<<<<< HEAD
=======
this.page = 1
>>>>>>> local-backup-root-cyj
this.loadProducts()
this.loadStats()
},
@@ -113,11 +145,21 @@
async initMerchantId() {
try {
const session = supa.getSession()
<<<<<<< HEAD
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
=======
if (session != null && session.user != null) {
this.merchantId = session.user.getString('id') || ''
}
if (!this.merchantId) {
this.merchantId = uni.getStorageSync('user_id') || ''
}
>>>>>>> local-backup-root-cyj
} catch (e) {}
},
async loadProducts() {
<<<<<<< HEAD
this.loading = true
try {
@@ -127,10 +169,34 @@
if (response.error != null || !response.data) {
this.products = []
=======
if (this.loading && this.page === 1) return
this.loading = true
try {
let query = supa.from('ml_products')
.select('id, name, main_image_url, total_stock, warning_stock')
.eq('merchant_id', this.merchantId)
.order('total_stock', { ascending: true })
.page(this.page)
.limit(this.limit)
if (this.currentFilter === 'low') {
query = query.lte('total_stock', 10) // 简化处理,实际应关联 warning_stock
} else if (this.currentFilter === 'out') {
query = query.eq('total_stock', 0)
}
const response = await query.execute()
if (response.error != null) {
console.error('加载商品失败:', response.error)
>>>>>>> local-backup-root-cyj
return
}
const rawData = response.data as any[]
<<<<<<< HEAD
let productsData: ProductType[] = []
for (let i = 0; i < rawData.length; i++) {
@@ -141,16 +207,38 @@
if (this.currentFilter === 'low' && stock > warning) continue
if (this.currentFilter === 'out' && stock > 0) continue
=======
if (!rawData) return
const productsData: ProductType[] = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
>>>>>>> local-backup-root-cyj
productsData.push({
id: item.getString('id') || '',
name: item.getString('name') || '',
main_image_url: item.getString('main_image_url') || '',
<<<<<<< HEAD
total_stock: stock,
warning_stock: warning
})
}
this.products = productsData
=======
total_stock: item.getNumber('total_stock') || 0,
warning_stock: item.getNumber('warning_stock') || 10
} as ProductType)
}
if (this.page === 1) {
this.products = productsData
} else {
this.products = [...this.products, ...productsData]
}
this.hasMore = rawData.length === this.limit
>>>>>>> local-backup-root-cyj
} catch (e) {
console.error('加载失败:', e)
} finally {
@@ -200,7 +288,13 @@
editStock(product: ProductType) {
this.currentProduct = product
<<<<<<< HEAD
this.newStock = String(product.total_stock)
=======
this.newStock = ''
this.adjustType = 'set'
this.stockRemark = ''
>>>>>>> local-backup-root-cyj
this.showStockModal = true
},
@@ -211,6 +305,7 @@
},
async saveStock() {
<<<<<<< HEAD
if (!this.newStock || isNaN(parseInt(this.newStock))) {
uni.showToast({ title: '请输入有效库存', icon: 'none' })
return
@@ -218,18 +313,63 @@
try {
const response = await supa.from('ml_products').update({ total_stock: parseInt(this.newStock), updated_at: new Date().toISOString() }).eq('id', this.currentProduct!.id).execute()
=======
const val = parseInt(this.newStock)
if (isNaN(val)) {
uni.showToast({ title: '请输入有效数值', icon: 'none' })
return
}
let finalStock = 0
if (this.adjustType === 'set') {
finalStock = val
} else if (this.adjustType === 'add') {
finalStock = (this.currentProduct?.total_stock || 0) + val
} else if (this.adjustType === 'sub') {
finalStock = (this.currentProduct?.total_stock || 0) - val
}
if (finalStock < 0) {
uni.showToast({ title: '最终库存不能小于0', icon: 'none' })
return
}
uni.showLoading({ title: '更新中...' })
try {
const response = await supa.from('ml_products')
.update({
total_stock: finalStock,
available_stock: finalStock,
updated_at: new Date().toISOString()
})
.eq('id', this.currentProduct!.id)
.execute()
>>>>>>> local-backup-root-cyj
if (response.error != null) {
uni.showToast({ title: '保存失败', icon: 'none' })
return
}
<<<<<<< HEAD
uni.showToast({ title: '保存成功', icon: 'success' })
this.closeStockModal()
this.loadProducts()
this.loadStats()
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
=======
uni.showToast({ title: '更新成功', icon: 'success' })
this.closeStockModal()
this.page = 1
this.loadProducts()
this.loadStats()
} catch (e) {
uni.showToast({ title: '操作异常', icon: 'none' })
} finally {
uni.hideLoading()
>>>>>>> local-backup-root-cyj
}
},
@@ -273,10 +413,17 @@
.action-btn { padding: 12rpx 24rpx; font-size: 24rpx; background-color: #E3F2FD; color: #1976D2; border-radius: 24rpx; }
.modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal-content { width: 80%; background-color: #fff; border-radius: 16rpx; }
<<<<<<< HEAD
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 30rpx; border-bottom: 1rpx solid #f5f5f5; }
.modal-title { font-size: 32rpx; font-weight: bold; color: #333; }
.modal-close { font-size: 44rpx; color: #999; }
.modal-body { padding: 30rpx; }
=======
.modal-body { padding: 30rpx; }
.adjust-type { display: flex; justify-content: space-between; margin-bottom: 30rpx; }
.type-btn { flex: 1; height: 64rpx; line-height: 64rpx; text-align: center; font-size: 24rpx; background-color: #f5f5f5; color: #666; margin: 0 10rpx; border-radius: 32rpx; border: 1rpx solid #eee; }
.type-btn.active { background-color: #E3F2FD; color: #007AFF; border-color: #007AFF; }
>>>>>>> local-backup-root-cyj
.form-item { margin-bottom: 20rpx; }
.form-item .label { font-size: 26rpx; color: #999; display: block; margin-bottom: 10rpx; }
.form-item .value { font-size: 28rpx; color: #333; }

View File

@@ -0,0 +1,521 @@
<template>
<view class="members-page">
<view class="tabs">
<view class="tab" :class="{ active: activeTab === 0 }" @click="activeTab = 0">等级设置</view>
<view class="tab" :class="{ active: activeTab === 1 }" @click="activeTab = 1">客户列表</view>
</view>
<scroll-view scroll-y class="list-container" v-if="activeTab === 0">
<view class="section-card">
<view class="card-header">
<text class="card-title">等级配置</text>
<text class="add-btn" @click="showAddLevel = true">+ 添加等级</text>
</view>
<view class="level-list">
<view v-for="level in levels" :key="level.id" class="level-item">
<view class="level-info">
<text class="level-name">{{ level.name }}</text>
<text class="level-rate">{{ (level.discount_rate * 10).toFixed(1) }}折</text>
</view>
<view class="level-actions">
<text class="action-edit" @click="editLevel(level)">编辑</text>
<text class="action-del" @click="deleteLevel(level.id)">删除</text>
</view>
</view>
</view>
</view>
</scroll-view>
<scroll-view scroll-y class="list-container" v-if="activeTab === 1">
<view class="user-list">
<view v-if="users.length === 0" class="empty-tip">暂无注册客户</view>
<view v-for="user in users" :key="user.id" class="user-item">
<image :src="user.avatar_url || '/static/images/default-avatar.png'" class="user-avatar" />
<view class="user-info">
<view class="user-title-row">
<text class="user-name">{{ user.nickname || user.username || '未设置昵称' }}</text>
<view class="user-tier-tag" v-if="user.tier_name">{{ user.tier_name }}</view>
</view>
<text class="user-email" v-if="user.email">{{ user.email }}</text>
<text class="user-phone">{{ user.phone || '未绑定手机' }}</text>
</view>
<view class="user-actions">
<text class="action-set" @click="showSetTier(user)">设置VIP</text>
<text class="action-set discount-btn" @click="goToExclusive(user)">专属折扣</text>
</view>
</view>
</view>
</scroll-view>
<!-- 编辑等级弹窗 -->
<view class="modal" v-if="showEditModal">
<view class="modal-content">
<view class="modal-title">{{ currentLevel.id ? '编辑等级' : '添加等级' }}</view>
<view class="form-item">
<text class="label">等级名称</text>
<input class="input" v-model="currentLevel.name" placeholder="请输入名称" />
</view>
<view class="form-item">
<text class="label">折扣率 (0-1)</text>
<input class="input" type="digit" v-model="currentLevel.discount_rate" placeholder="如0.85表示85折" />
</view>
<view class="modal-btns">
<text class="btn cancel" @click="showEditModal = false">取消</text>
<text class="btn confirm" @click="saveLevel">保存</text>
</view>
</view>
</view>
<!-- 设置用户等级弹窗 -->
<view class="modal" v-if="showTierModal">
<view class="modal-content">
<view class="modal-title">设置会员等级</view>
<view class="tier-options">
<view v-for="level in levels" :key="level.id"
class="tier-option"
:class="{ selected: selectedTierId === level.id }"
@click="selectedTierId = level.id">
{{ level.name }}
</view>
</view>
<view class="modal-btns">
<text class="btn cancel" @click="showTierModal = false">取消</text>
<text class="btn confirm" @click="confirmSetTier">确认</text>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type MemberLevel = {
id: string
name: string
discount_rate: number
level_rank: number
}
type UserInfo = {
id: string
username: string
email: string
nickname: string | null
avatar_url: string | null
phone: string | null
tier_id: string | null
tier_name: string | null
}
export default {
data() {
return {
activeTab: 0,
levels: [] as MemberLevel[],
users: [] as UserInfo[],
searchKey: '',
showEditModal: false,
showTierModal: false,
showAddLevel: false,
currentLevel: {
id: '',
name: '',
discount_rate: 1.0,
level_rank: 0
} as MemberLevel,
currentUser: null as UserInfo | null,
selectedTierId: '',
merchantId: ''
}
},
onLoad() {
this.merchantId = uni.getStorageSync('user_id') || ''
this.loadLevels()
},
watch: {
activeTab(val: number) {
if (val === 1 && this.users.length === 0) {
this.loadUsers()
}
}
},
methods: {
handleSearch() {
console.log('按钮被点击,触发 handleSearch');
this.loadUsers();
},
async loadLevels() {
const res = await supa.from('ml_member_levels').select('*').order('level_rank', { ascending: true }).execute()
if (res.data != null) {
this.levels = (res.data as any[]).map((item: any) => {
const obj = item as UTSJSONObject
return {
id: obj.getString('id') || '',
name: obj.getString('name') || '',
discount_rate: obj.getNumber('discount_rate') || 1.0,
level_rank: obj.getNumber('level_rank') || 0
} as MemberLevel
})
}
},
async loadUsers() {
console.log('--- 启动 ak_users 全量加载 (不带 limit 限制) ---');
try {
// 1. 移除 limit 限制或设置极大值,确保读到全部数据
// 同时通过 count 参数确认数据库到底给了多少条
const res = await supa.from('ak_users')
.select('id, username, nickname, email, phone, avatar_url, role', { count: 'exact' })
.execute()
if (res.error != null) {
console.error('API请求错误:', res.error);
return
}
if (res.data != null) {
let rawData = res.data as any[]
console.log('数据库查询成功。总行数:', res.count, ' 返回行数:', rawData.length);
// 增加一个调试点:统计一下所有数据的 role 分布,看看到底有多少个 role 是 customer
let customerCount = 0;
rawData.forEach((item: any) => {
const r = String((item as UTSJSONObject)['role'] || '').trim().toLowerCase();
if (r == 'customer') customerCount++;
});
console.log('内存扫描结果: 含有 customer 字样的记录总数:', customerCount);
// 2. 获取会员等级地图
let profileMap = new Map<string, string>()
try {
const profileRes = await supa.from('ml_user_profiles').select('*').limit(1).execute()
if (profileRes.data != null && (profileRes.data as any[]).length > 0) {
console.log('【数据库结构探查】ml_user_profiles 第一条数据:', JSON.stringify(profileRes.data[0]))
}
const profileAllRes = await supa.from('ml_user_profiles').select('*').execute()
console.log('【数据调试】ml_user_profiles 返回行数:', (profileAllRes.data as any[] || []).length)
if (profileAllRes.data != null) {
const profileData = profileAllRes.data as any[]
profileData.forEach((p: any) => {
if (p != null) {
const po = p as UTSJSONObject
const uid = String(po['user_id'] || '').trim().toLowerCase()
const keys = Object.keys(p as object)
let foundTid = ''
if (keys.includes('tier_id')) {
foundTid = String(po['tier_id'] || '')
} else if (keys.includes('level_id')) {
foundTid = String(po['level_id'] || '')
} else if (keys.includes('rank_id')) {
foundTid = String(po['rank_id'] || '')
} else {
const autoKey = keys.find(k => k.includes('level') || k.includes('tier'))
if (autoKey != null) {
foundTid = String(po[autoKey] || '')
}
}
foundTid = foundTid.trim().toLowerCase()
if (uid != '' && foundTid != '' && foundTid != 'null') {
console.log(`【映射匹配成功】UID: ${uid} -> TID: ${foundTid}`)
profileMap.set(uid, foundTid)
}
}
})
}
} catch (e) {
console.error('查询 profile 报错:', e)
}
// 3. 【极致完善筛选逻辑】
this.users = rawData.map((u: any): UserInfo | null => {
if (u == null) return null
const uo = u as UTSJSONObject
let rawRole = String(uo['role'] || '');
const role = rawRole.trim().toLowerCase();
// 严格筛选:仅保留角色为 consumer 的真实消费者
if (role != 'consumer') return null
const uid = String(uo['id'] || uo['user_id'] || '').trim().toLowerCase()
const username = String(uo['username'] || '')
// 这里是关键profileMap 里的 key 是小写的 uidtid 也是小写的
const tid = profileMap.get(uid) || ''
let tname = ''
if (tid != '') {
// 1. 严格 ID 匹配
const level = this.levels.find(l => (l.id || '').trim().toLowerCase() === tid)
if (level != null) {
tname = level.name
} else {
// 2. 备用:如果 ID 匹配不到,尝试看这个 tid 是不是等级的序号(level_rank)
const levelByRank = this.levels.find(l => String(l.level_rank) === tid)
if (levelByRank != null) tname = levelByRank.name
}
}
if (tid != '') {
console.log(`【渲染行检查】用户:${username}, ID:${uid}, 等级TID(DB):${tid}, 匹配结果:${tname}`)
}
return {
id: uid,
username: username,
email: String(uo['email'] || ''),
nickname: String(uo['nickname'] || uo['username'] || '未设置昵称'),
avatar_url: String(uo['avatar_url'] || uo['head_img_url'] || ''),
phone: String(uo['phone'] || ''),
tier_id: tid,
tier_name: tname
} as UserInfo
}).filter((u: any): boolean => u != null) as UserInfo[]
// 【核心优化】自动将已经设置了 VIP 的人排在列表最顶端,方便一眼看到
this.users.sort((a, b) => {
const nameA = (a.tier_name || '').trim()
const nameB = (b.tier_name || '').trim()
if (nameA != '' && nameB == '') return -1
if (nameA == '' && nameB != '') return 1
return 0
})
console.log('【最终渲染检查】当前用户列表长度:', this.users.length);
// 强制触发一次 UI 重绘
this.$forceUpdate();
}
} catch (e) {
console.error('加载逻辑崩溃:', e);
}
},
processUserData(rawData: any[]) {
if (rawData != null && Array.isArray(rawData)) {
this.users = rawData.map((item: any) => {
const istr = JSON.stringify(item)
const obj = JSON.parse(istr) as UTSJSONObject
const tierId = obj.getString('tier_id')
let tierName = ''
if (tierId != null && tierId != '') {
const level = this.levels.find(l => l.id === tierId)
if (level != null) tierName = level.name
}
return {
id: obj.getString('id') || obj.getString('user_id') || '',
nickname: obj.getString('nickname') || '未设置昵称',
avatar_url: obj.getString('avatar_url'),
phone: obj.getString('phone_number') || '无手机号',
tier_id: tierId,
tier_name: tierName
} as UserInfo
})
} else {
this.users = []
}
},
editLevel(level: MemberLevel) {
this.currentLevel = JSON.parse(JSON.stringify(level)) as MemberLevel
this.showEditModal = true
},
async saveLevel() {
if (!this.currentLevel.name) return
// 构造提交数据,确保类型正确
const discount = parseFloat(this.currentLevel.discount_rate.toString())
const rank = parseInt(this.currentLevel.level_rank.toString())
const data = {
name: this.currentLevel.name,
discount_rate: isNaN(discount) ? 1.0 : discount,
level_rank: isNaN(rank) ? 0 : rank
}
let res: any
if (this.currentLevel.id) {
res = await supa.from('ml_member_levels').update(data).eq('id', this.currentLevel.id).execute()
} else {
res = await supa.from('ml_member_levels').insert(data).execute()
}
if (res.error == null) {
uni.showToast({ title: '保存成功' })
this.showEditModal = false
this.loadLevels()
} else {
uni.showModal({ title: '保存失败', content: JSON.stringify(res.error) })
}
},
async deleteLevel(id: string) {
uni.showModal({
title: '确认删除',
content: '此操作将同步删除关联用户的等级,是否继续?',
success: async (res) => {
if (res.confirm) {
// 先将该等级下的用户 tier_id 清空,防止外键约束或逻辑残留
await supa.from('ml_user_profiles').update({ tier_id: null }).eq('tier_id', id).execute()
const delRes = await supa.from('ml_member_levels').delete().eq('id', id).execute()
if (delRes.error == null) {
this.loadLevels()
this.loadUsers()
}
}
}
})
},
goToExclusive(user: any) {
const name = user['nickname'] || user['username'] || user['phone'] || '客户'
const uId = user['id']
uni.navigateTo({
url: '/pages/mall/merchant/exclusive-discounts?user_id=' + uId + '&user_name=' + encodeURIComponent(name as string)
})
},
showSetTier(user: UserInfo) {
this.currentUser = user
this.selectedTierId = user.tier_id || ''
this.showTierModal = true
},
async confirmSetTier() {
if (this.currentUser == null) return
uni.showLoading({ title: '确认中...' })
try {
const userObj = this.currentUser as UserInfo
const userId = userObj.id
// 1. 获取所有字段名(不依赖第一行数据,而是通过 RPC 或直接查询)
// 为确保万无一失,我们直接同时尝试写入 tier_id 和 level_id
const probeRes = await supa.from('ml_user_profiles').select('*').limit(1).execute()
let finalObj = {
'user_id': userId,
'updated_at': new Date().toISOString()
} as UTSJSONObject
// 智能探测字段
if (probeRes.data != null && (probeRes.data as any[]).length > 0) {
const keys = Object.keys(probeRes.data![0] as object)
console.log('【数据库字段探测】:', JSON.stringify(keys))
if (keys.includes('tier_id')) {
finalObj['tier_id'] = this.selectedTierId
} else if (keys.includes('level_id')) {
finalObj['level_id'] = this.selectedTierId
} else if (keys.includes('rank_id')) {
finalObj['rank_id'] = this.selectedTierId
} else {
// 万能匹配
const anyLevelKey = keys.find(k => k.includes('level') || k.includes('tier'))
if (anyLevelKey != null) finalObj[anyLevelKey] = this.selectedTierId
}
} else {
// 如果表完全是空的,默认尝试 tier_id
finalObj['tier_id'] = this.selectedTierId
}
// 2. 使用 UPSERT 逻辑(存在就更新,没有就插入)
// Supabase 的 upsert 需要定义唯一约束,这里我们根据 user_id 处理
const checkExist = await supa.from('ml_user_profiles').select('id').eq('user_id', userId).execute()
let finalRes: any = null
if (checkExist.data != null && (checkExist.data as any[]).length > 0) {
// 注意:更新时不需要带上 user_id 字段
const updateObj = JSON.parse(JSON.stringify(finalObj)) as UTSJSONObject
delete updateObj['user_id']
finalRes = await supa.from('ml_user_profiles').update(updateObj).eq('user_id', userId).execute()
} else {
finalRes = await supa.from('ml_user_profiles').insert(finalObj).execute()
}
if (finalRes != null && finalRes.error != null) {
throw new Error('保存失败: ' + finalRes.error!.message)
}
uni.hideLoading()
uni.showToast({ title: '设置成功', icon: 'success' })
this.showTierModal = false
// 立即重新获取该用户的 profile 确认
setTimeout(() => {
this.loadUsers()
}, 300)
} catch (e) {
uni.hideLoading()
uni.showModal({
title: '设置异常',
content: String(e),
showCancel: false
})
}
}
}
}
</script>
<style>
.members-page { background-color: #f8f9fa; min-height: 100vh; }
.tabs { display: flex; background: #fff; padding: 20rpx 0; border-bottom: 1rpx solid #eee; }
.tab { flex: 1; text-align: center; font-size: 28rpx; color: #666; }
.tab.active { color: #007AFF; font-weight: bold; }
.list-container { padding: 20rpx; }
.section-card { background: #fff; border-radius: 16rpx; padding: 30rpx; }
.card-header { display: flex; justify-content: space-between; margin-bottom: 30rpx; align-items: center; }
.card-title { font-size: 32rpx; font-weight: bold; }
.add-btn { color: #007AFF; font-size: 26rpx; }
.level-item { display: flex; justify-content: space-between; border-bottom: 1rpx solid #f5f5f5; padding: 20rpx 0; }
.level-name { font-size: 30rpx; display: block; }
.level-rate { font-size: 24rpx; color: #FF9500; }
.level-actions .text { font-size: 24rpx; margin-left: 20rpx; }
.action-edit { color: #007AFF; margin-right: 20rpx; }
.action-del { color: #FF3B30; }
.search-bar { display: flex; padding: 20rpx; background: #fff; margin-bottom: 20rpx; border-radius: 12rpx; }
.search-input { flex: 1; height: 72rpx; background: #f5f5f5; border-radius: 36rpx; padding: 0 30rpx; font-size: 26rpx; }
.search-btn { margin-left: 20rpx; color: #007AFF; line-height: 72rpx; font-size: 28rpx; }
.user-item { display: flex; align-items: center; background: #fff; padding: 24rpx; border-radius: 16rpx; margin-bottom: 20rpx; }
.user-avatar { width: 90rpx; height: 90rpx; border-radius: 45rpx; background: #eee; }
.user-info { flex: 1; margin-left: 24rpx; }
.user-name { font-size: 30rpx; font-weight: bold; display: block; }
.user-phone { font-size: 24rpx; color: #999; }
.user-tier-tag { display: inline-block; background: #FF9500; color: #fff; font-size: 20rpx; padding: 2rpx 12rpx; border-radius: 4rpx; margin-top: 8rpx; }
.action-set {
font-size: 24rpx;
color: #2196F3;
padding: 10rpx 20rpx;
background-color: #E3F2FD;
border-radius: 8rpx;
}
.action-set.discount-btn {
color: #FF9800;
background-color: #FFF8E1;
margin-left: 16rpx;
}
/* 弹窗样式 */
.modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 999; }
.modal-content { background: #fff; width: 600rpx; border-radius: 24rpx; padding: 40rpx; }
.modal-title { text-align: center; font-size: 34rpx; font-weight: bold; margin-bottom: 30rpx; }
.form-item { margin-bottom: 30rpx; }
.label { font-size: 26rpx; color: #666; margin-bottom: 12rpx; display: block; }
.input { background: #f5f5f5; height: 80rpx; border-radius: 12rpx; padding: 0 20rpx; font-size: 28rpx; }
.modal-btns { display: flex; justify-content: flex-end; margin-top: 40rpx; }
.btn { padding: 16rpx 40rpx; border-radius: 12rpx; font-size: 28rpx; margin-left: 20rpx; }
.btn.cancel { background: #eee; color: #666; }
.btn.confirm { background: #007AFF; color: #fff; }
.tier-options { display: flex; flex-wrap: wrap; }
.tier-option { padding: 16rpx 30rpx; background: #f5f5f5; margin: 10rpx; border-radius: 36rpx; font-size: 26rpx; }
.tier-option.selected { background: #007AFF; color: #fff; }
</style>

View File

@@ -1,6 +1,7 @@
<!-- 商家端 - 消息中心页面 -->
<template>
<view class="messages-page">
<<<<<<< HEAD
<view class="tabs">
<view class="tab" :class="{ active: currentTab === 'chat' }" @click="switchTab('chat')">会话列表</view>
<view class="tab" :class="{ active: currentTab === 'all' }" @click="switchTab('all')">全部消息</view>
@@ -40,6 +41,48 @@
<view v-if="!msg.is_read" class="unread-dot"></view>
</view>
</view>
=======
<view class="header">
<text class="header-title">消息</text>
<text class="header-subtitle">与客户的聊天记录</text>
</view>
<scroll-view class="messages-list" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<view v-if="loading && conversations.length === 0" class="loading-container">
<text class="loading-icon">⏳</text>
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="conversations.length === 0" class="empty-container">
<text class="empty-icon">💬</text>
<text class="empty-text">暂无会话</text>
<text class="empty-hint">有客户消息时会在这里显示</text>
</view>
<view v-else class="conv-list">
<view v-for="conv in conversations" :key="conv.sessionId" class="conversation-card" @click="goToChat(conv)">
<view class="conv-avatar-wrap">
<image class="conv-avatar" :src="conv.avatar || '/static/images/default-avatar.png'" mode="aspectFill" />
<view v-if="conv.unread > 0" class="online-dot"></view>
</view>
<view class="conv-info">
<view class="conv-row">
<text class="conv-name">{{ conv.name }}</text>
<text class="conv-time">{{ conv.lastTime }}</text>
</view>
<view class="conv-bottom">
<text class="conv-preview">{{ conv.lastMessage }}</text>
<view v-if="conv.unread > 0" class="unread-badge">
<text class="unread-num">{{ conv.unread > 99 ? '99+' : conv.unread }}</text>
</view>
</view>
</view>
<text class="conv-arrow"></text>
</view>
</view>
<view class="safe-bottom"></view>
>>>>>>> local-backup-root-cyj
</scroll-view>
</view>
</template>
@@ -65,6 +108,10 @@
avatar: string
lastMessage: string
lastTime: string
<<<<<<< HEAD
=======
lastTimeRaw: string
>>>>>>> local-backup-root-cyj
unread: number
userId: string
}
@@ -72,8 +119,11 @@
export default {
data() {
return {
<<<<<<< HEAD
currentTab: 'chat',
messages: [] as MessageType[],
=======
>>>>>>> local-backup-root-cyj
conversations: [] as ConversationType[],
loading: false,
refreshing: false,
@@ -111,17 +161,24 @@
const response = await query.execute()
if (response.error != null || !response.data) {
<<<<<<< HEAD
this.messages = []
=======
>>>>>>> local-backup-root-cyj
this.conversations = []
return
}
const rawData = response.data as any[]
<<<<<<< HEAD
const messagesData: MessageType[] = []
=======
>>>>>>> local-backup-root-cyj
const sessionMap = new Map<string, ConversationType>()
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
<<<<<<< HEAD
const msg: MessageType = {
id: item.getString('id') || '',
session_id: item.getString('session_id') || '',
@@ -145,20 +202,61 @@
avatar: '',
lastMessage: msg.content,
lastTime: this.formatTime(msg.created_at),
=======
const isFromUser = item.getBoolean('is_from_user') || false
const senderId = item.getString('sender_id') || ''
const receiverId = item.getString('receiver_id') || ''
// is_from_user=true 表示消息来自用户sender_id是用户ID
// is_from_user=false 表示消息来自商家receiver_id是用户ID
const otherUserId = isFromUser ? senderId : receiverId
// 只用 otherUserId 分组,确保每个客户只有一个会话
if (otherUserId === '' || otherUserId === this.merchantId) continue
const isRead = item.getBoolean('is_read') || false
const content = item.getString('content') || ''
const createdAt = item.getString('created_at') || ''
const sessionId = item.getString('session_id') || otherUserId
if (!sessionMap.has(otherUserId)) {
sessionMap.set(otherUserId, {
sessionId: sessionId,
name: '客户',
avatar: '',
lastMessage: content,
lastTime: this.formatTime(createdAt),
lastTimeRaw: createdAt,
>>>>>>> local-backup-root-cyj
unread: 0,
userId: otherUserId
})
}
<<<<<<< HEAD
const conv = sessionMap.get(sessionId)!
conv.lastMessage = msg.content
conv.lastTime = this.formatTime(msg.created_at)
if (!msg.is_read && !msg.is_from_user) {
=======
const conv = sessionMap.get(otherUserId)!
// 更新最后一条消息(按时间最新的)
if (createdAt > conv.lastTimeRaw) {
conv.lastMessage = content
conv.lastTime = this.formatTime(createdAt)
conv.lastTimeRaw = createdAt
}
// 未读消息:消息来自用户且未读
if (!isRead && isFromUser) {
>>>>>>> local-backup-root-cyj
conv.unread++
}
}
<<<<<<< HEAD
this.messages = messagesData
=======
>>>>>>> local-backup-root-cyj
this.conversations = Array.from(sessionMap.values()).sort((a, b) => b.unread - a.unread)
} catch (e) {
@@ -175,15 +273,19 @@
})
},
<<<<<<< HEAD
switchTab(tab: string) {
this.currentTab = tab
},
=======
>>>>>>> local-backup-root-cyj
onRefresh() {
this.refreshing = true
this.loadMessages()
},
<<<<<<< HEAD
viewMessage(msg: MessageType) {
if (!msg.is_read) {
supa.from('ml_chat_messages').update({ is_read: true }).eq('id', msg.id).execute()
@@ -196,6 +298,8 @@
})
},
=======
>>>>>>> local-backup-root-cyj
formatTime(timeStr: string): string {
if (!timeStr) return ''
const date = new Date(timeStr)
@@ -215,6 +319,7 @@
</script>
<style>
<<<<<<< HEAD
.messages-page { background-color: #f5f5f5; min-height: 100vh; }
.tabs { display: flex; background-color: #fff; padding: 0 20rpx; position: sticky; top: 0; z-index: 10; }
.tab { flex: 1; text-align: center; padding: 24rpx 0; font-size: 28rpx; color: #666; position: relative; }
@@ -244,4 +349,39 @@
.message-time { font-size: 22rpx; color: #999; }
.message-text { font-size: 26rpx; color: #666; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.unread-dot { position: absolute; top: 30rpx; right: 30rpx; width: 16rpx; height: 16rpx; background-color: #FF3B30; border-radius: 50%; }
=======
.messages-page { background-color: #f5f7fa; min-height: 100vh; display: flex; flex-direction: column; }
.header { background-color: #fff; padding-top: 60rpx; padding-bottom: 24rpx; padding-left: 30rpx; padding-right: 30rpx; border-bottom-width: 1rpx; border-bottom-style: solid; border-bottom-color: #eee; }
.header-title { font-size: 44rpx; font-weight: bold; color: #333; display: block; margin-bottom: 8rpx; }
.header-subtitle { font-size: 26rpx; color: #999; }
.messages-list { flex: 1; padding: 20rpx; }
.loading-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding-top: 100rpx; padding-bottom: 100rpx; }
.loading-icon { font-size: 60rpx; margin-bottom: 20rpx; }
.loading-text { font-size: 28rpx; color: #999; }
.empty-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding-top: 100rpx; padding-bottom: 100rpx; }
.empty-icon { font-size: 100rpx; margin-bottom: 20rpx; }
.empty-text { font-size: 32rpx; color: #666; margin-bottom: 10rpx; }
.empty-hint { font-size: 26rpx; color: #999; }
.conv-list { display: flex; flex-direction: column; }
.conversation-card { display: flex; flex-direction: row; align-items: center; background-color: #fff; border-radius: 20rpx; padding: 24rpx; margin-bottom: 16rpx; }
.conv-avatar-wrap { position: relative; margin-right: 20rpx; }
.conv-avatar { width: 100rpx; height: 100rpx; border-radius: 50rpx; background-color: #e0e0e0; }
.online-dot { position: absolute; bottom: 4rpx; right: 4rpx; width: 24rpx; height: 24rpx; background-color: #4CAF50; border-radius: 12rpx; border-width: 3rpx; border-style: solid; border-color: #fff; }
.conv-info { flex: 1; }
.conv-row { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
.conv-name { font-size: 32rpx; color: #333; font-weight: bold; }
.conv-time { font-size: 24rpx; color: #999; }
.conv-bottom { display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.conv-preview { font-size: 28rpx; color: #666; flex: 1; margin-right: 16rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.unread-badge { min-width: 36rpx; height: 36rpx; background-color: #FF3B30; border-radius: 18rpx; display: flex; align-items: center; justify-content: center; padding-left: 10rpx; padding-right: 10rpx; }
.unread-num { font-size: 22rpx; color: #fff; font-weight: bold; }
.conv-arrow { font-size: 40rpx; color: #ccc; margin-left: 10rpx; }
.safe-bottom { height: 30rpx; }
>>>>>>> local-backup-root-cyj
</style>

View File

@@ -1,4 +1,8 @@
<<<<<<< HEAD
<!-- 商家端 - 订单详情页面 -->
=======
<!-- 商家端 - 订单详情页面 -->
>>>>>>> local-backup-root-cyj
<template>
<view class="order-detail-page">
<!-- 订单状态头部 -->
@@ -114,20 +118,32 @@
<!-- 操作按钮 -->
<view class="action-buttons">
<view
<<<<<<< HEAD
v-if="order.order_status === 1"
=======
v-if="order.order_status === 2"
>>>>>>> local-backup-root-cyj
class="action-btn primary"
@click="shipOrder"
>
去发货
</view>
<view
<<<<<<< HEAD
v-if="order.order_status === 2"
=======
v-if="order.order_status === 3"
>>>>>>> local-backup-root-cyj
class="action-btn primary"
@click="viewLogistics"
>
查看物流
</view>
<<<<<<< HEAD
<view
=======
<view
>>>>>>> local-backup-root-cyj
v-if="order.order_status === 3"
class="action-btn primary"
@click="confirmDelivery"
@@ -163,7 +179,11 @@
@change="onLogisticsChange"
>
<view class="picker-value">
<<<<<<< HEAD
{{ selectedLogistics.name || '请选择物流公司' }}
=======
{{ selectedLogistics?.name || '请选择物流公司' }}
>>>>>>> local-backup-root-cyj
</view>
</picker>
</view>
@@ -240,8 +260,20 @@
updated_at: '',
items: [] as OrderItemType[]
},
<<<<<<< HEAD
addressData: {} as AddressType,
=======
addressData: {
recipient_name: '',
phone: '',
province: '',
city: '',
district: '',
detail_address: ''
} as AddressType,
>>>>>>> local-backup-root-cyj
showShipModal: false,
logisticsCompanies: [
{ name: '顺丰速运', code: 'SF' },
@@ -252,27 +284,51 @@
{ name: 'EMS', code: 'EMS' },
{ name: '京东物流', code: 'JD' }
] as LogisticsType[],
<<<<<<< HEAD
selectedLogistics: {} as LogisticsType,
=======
selectedLogistics: { name: '', code: '' } as LogisticsType,
>>>>>>> local-backup-root-cyj
trackingNumber: ''
}
},
<<<<<<< HEAD
onLoad(options: any) {
const id = options.id as string
if (id) {
=======
onLoad(options: any) { console.log('--- DEBUG ON LOAD ---', options)
let id = ''
if (options['id'] != null) {
id = options['id'] as string
} else if (options.id != null) {
id = options.id as string
}
if (id !== '') {
>>>>>>> local-backup-root-cyj
this.orderId = id
this.loadOrderDetail()
}
},
methods: {
<<<<<<< HEAD
async loadOrderDetail() {
try {
=======
async loadOrderDetail() { console.log('--- DEBUG LOAD ORDER DETAIL ---', this.orderId); try {
>>>>>>> local-backup-root-cyj
const response = await supa
.from('ml_orders')
.select(`
*,
<<<<<<< HEAD
order_items!inner (
=======
ml_order_items (
>>>>>>> local-backup-root-cyj
id,
order_id,
product_id,
@@ -289,12 +345,17 @@
.single()
.execute()
<<<<<<< HEAD
if (response.error != null) {
=======
if (response.error != null || (response.status ?? 200) >= 400) {
>>>>>>> local-backup-root-cyj
console.error('获取订单详情失败:', response.error)
uni.showToast({ title: '加载失败', icon: 'none' })
return
}
<<<<<<< HEAD
const rawData = response.data as UTSJSONObject
if (rawData == null) return
@@ -321,11 +382,47 @@
}
const itemsObj = rawData.get('order_items')
=======
console.log('--- DEBUG RAW ORDER DATA ---', response.data); let realData = response.data;
let isArrLike = false;
if (response.data != null && (response.data as any)['0'] != null) {
realData = (response.data as any)['0'];
isArrLike = true;
}
console.log('--- EXTRACTED realData ---', isArrLike);
const rawData = realData as UTSJSONObject
if (rawData == null) return
this.order = {
id: String(rawData['id'] ?? '') || '',
order_no: String(rawData['order_no'] ?? '') || '',
user_id: String(rawData['user_id'] ?? '') || '',
merchant_id: String(rawData['merchant_id'] ?? '') || '',
order_status: Number(rawData['order_status'] ?? 0) || 1,
total_amount: Number(rawData['total_amount'] ?? 0) || 0,
product_amount: Number(rawData['product_amount'] ?? 0) || 0,
shipping_fee: Number(rawData['shipping_fee'] ?? 0) || 0,
discount_amount: Number(rawData['discount_amount'] ?? 0) || 0,
paid_amount: Number(rawData['paid_amount'] ?? 0) || 0,
shipping_address: String(rawData['shipping_address'] ?? '') || '{}',
remark: String(rawData['remark'] ?? '') || '',
shipping_company: String(rawData['carrier_name'] ?? rawData['shipping_company'] ?? '') || '',
tracking_number: String(rawData['tracking_no'] ?? rawData['tracking_number'] ?? '') || '',
paid_at: String(rawData['paid_at'] ?? '') || '',
shipped_at: String(rawData['shipped_at'] ?? '') || '',
created_at: String(rawData['created_at'] ?? '') || '',
updated_at: String(rawData['updated_at'] ?? '') || '',
items: []
}
const itemsObj = rawData['ml_order_items']
>>>>>>> local-backup-root-cyj
if (itemsObj != null && Array.isArray(itemsObj)) {
const itemsArray = itemsObj as any[]
for (let i = 0; i < itemsArray.length; i++) {
const orderItem = itemsArray[i] as UTSJSONObject
this.order.items.push({
<<<<<<< HEAD
id: orderItem.getString('id') || '',
order_id: orderItem.getString('order_id') || '',
product_id: orderItem.getString('product_id') || '',
@@ -335,6 +432,17 @@
price: orderItem.getNumber('price') || 0,
quantity: orderItem.getNumber('quantity') || 0,
image_url: orderItem.getString('image_url') || '',
=======
id: String(orderItem['id'] ?? '') || '',
order_id: String(orderItem['order_id'] ?? '') || '',
product_id: String(orderItem['product_id'] ?? '') || '',
sku_id: String(orderItem['sku_id'] ?? '') || '',
product_name: String(orderItem['product_name'] ?? '') || '',
sku_name: String(orderItem['sku_name'] ?? '') || '',
price: Number(orderItem['price'] ?? 0) || 0,
quantity: Number(orderItem['quantity'] ?? 0) || 0,
image_url: String(orderItem['image_url'] ?? '') || '',
>>>>>>> local-backup-root-cyj
sku_snapshot: ''
} as OrderItemType)
}
@@ -442,7 +550,11 @@
},
async confirmShip() {
<<<<<<< HEAD
if (!this.selectedLogistics.name) {
=======
if (this.selectedLogistics == null || !this.selectedLogistics?.name) {
>>>>>>> local-backup-root-cyj
uni.showToast({ title: '请选择物流公司', icon: 'none' })
return
}
@@ -456,25 +568,39 @@
.from('ml_orders')
.update({
order_status: 3,
<<<<<<< HEAD
shipping_company: this.selectedLogistics.name,
tracking_number: this.trackingNumber,
=======
shipping_status: 2,
carrier_name: this.selectedLogistics?.name, tracking_no: this.trackingNumber,
>>>>>>> local-backup-root-cyj
shipped_at: new Date().toISOString(),
updated_at: new Date().toISOString()
})
.eq('id', this.order.id)
.execute()
<<<<<<< HEAD
if (response.error != null) {
uni.showToast({ title: '发货失败', icon: 'none' })
=======
if (response.error != null || (response.status ?? 200) >= 400) {
let msg = response.error?.message ?? (response.data != null ? JSON.stringify(response.data) : '请检查网络或登录状态'); uni.showToast({ title: '发货被拦截: ' + msg, icon: 'none', duration: 4500 }); console.error('SUPABASE API ERR:', response)
>>>>>>> local-backup-root-cyj
return
}
uni.showToast({ title: '发货成功', icon: 'success' })
this.closeShipModal()
this.loadOrderDetail()
<<<<<<< HEAD
} catch (e) {
uni.showToast({ title: '发货失败', icon: 'none' })
}
=======
} catch (e) { uni.showToast({ title: '发货发生异常', icon: 'none' }); console.error(e) }
>>>>>>> local-backup-root-cyj
},
viewLogistics() {
@@ -501,7 +627,11 @@
.eq('id', this.order.id)
.execute()
<<<<<<< HEAD
if (response.error != null) {
=======
if (response.error != null || (response.status ?? 200) >= 400) {
>>>>>>> local-backup-root-cyj
uni.showToast({ title: '操作失败', icon: 'none' })
return
}
@@ -529,7 +659,11 @@
.eq('id', this.order.id)
.execute()
<<<<<<< HEAD
if (response.error != null) {
=======
if (response.error != null || (response.status ?? 200) >= 400) {
>>>>>>> local-backup-root-cyj
uni.showToast({ title: '删除失败', icon: 'none' })
return
}
@@ -841,7 +975,11 @@
display: flex;
align-items: flex-end;
justify-content: center;
<<<<<<< HEAD
z-index: 1000;
=======
z-index: 99;
>>>>>>> local-backup-root-cyj
}
.modal-content {
@@ -849,6 +987,11 @@
background-color: #fff;
border-radius: 24rpx 24rpx 0 0;
padding-bottom: env(safe-area-inset-bottom);
<<<<<<< HEAD
=======
position: relative;
z-index: 99;
>>>>>>> local-backup-root-cyj
}
.modal-header {
@@ -923,3 +1066,23 @@
font-weight: bold;
}
</style>
<<<<<<< HEAD
=======
>>>>>>> local-backup-root-cyj

View File

@@ -1,4 +1,8 @@
<<<<<<< HEAD
<!-- 商家端 - 订单管理页面 -->
=======
<!-- 商家端 - 订单管理页面 -->
>>>>>>> local-backup-root-cyj
<template>
<view class="orders-page">
<!-- 标签页切换 -->
@@ -96,14 +100,22 @@
</view>
<view class="order-actions">
<view
<<<<<<< HEAD
v-if="order.order_status === 1"
=======
v-if="order.order_status === 2"
>>>>>>> local-backup-root-cyj
class="action-btn primary"
@click.stop="shipOrder(order)"
>
发货
</view>
<view
<<<<<<< HEAD
v-if="order.order_status === 2"
=======
v-if="order.order_status === 3"
>>>>>>> local-backup-root-cyj
class="action-btn info"
@click.stop="viewLogistics(order)"
>
@@ -147,7 +159,11 @@
@change="onLogisticsChange"
>
<view class="picker-value">
<<<<<<< HEAD
{{ selectedLogistics.name || '请选择物流公司' }}
=======
{{ selectedLogistics?.name || '请选择物流公司' }}
>>>>>>> local-backup-root-cyj
</view>
</picker>
</view>
@@ -222,7 +238,11 @@
{ name: '待发货', status: 2, count: 0 },
{ name: '待收货', status: 3, count: 0 },
{ name: '已完成', status: 4, count: 0 },
<<<<<<< HEAD
{ name: '退款', status: 0, count: 0 }
=======
{ name: '退款', status: 6, count: 0 }
>>>>>>> local-backup-root-cyj
] as TabType[],
currentTab: -2,
searchKeyword: '',
@@ -257,7 +277,11 @@
const statusMap: Record<string, number> = {
'pending': 1,
'shipped': 3,
<<<<<<< HEAD
'refund': 0,
=======
'refund': 6,
>>>>>>> local-backup-root-cyj
'completed': 4
}
this.currentTab = statusMap[type] ?? -2
@@ -301,7 +325,11 @@
.from('ml_orders')
.select(`
*,
<<<<<<< HEAD
order_items!inner (
=======
order_items (
>>>>>>> local-backup-root-cyj
id,
order_id,
product_id,
@@ -320,8 +348,14 @@
.limit(this.limit)
if (this.currentTab !== -2) {
<<<<<<< HEAD
if (this.currentTab === 0) {
query = query.eq('order_status', 0)
=======
if (this.currentTab === 6) {
// 退款状态同时查询 0 和 6
query = query.in('order_status', [0, 6])
>>>>>>> local-backup-root-cyj
} else {
query = query.eq('order_status', this.currentTab)
}
@@ -333,7 +367,11 @@
const response = await query.execute()
<<<<<<< HEAD
if (response.error != null) {
=======
if (response.error != null || (response.status ?? 200) >= 400) {
>>>>>>> local-backup-root-cyj
console.error('获取订单失败:', response.error)
uni.showToast({ title: '加载失败', icon: 'none' })
return
@@ -349,19 +387,32 @@
const ordersData: OrderType[] = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i]
<<<<<<< HEAD
const orderObj = item as UTSJSONObject
=======
const str = JSON.stringify(item)
const orderObj = JSON.parse(str) as UTSJSONObject
>>>>>>> local-backup-root-cyj
const order: OrderType = {
id: orderObj.getString('id') || '',
order_no: orderObj.getString('order_no') || '',
user_id: orderObj.getString('user_id') || '',
merchant_id: orderObj.getString('merchant_id') || '',
<<<<<<< HEAD
order_status: orderObj.getNumber('order_status') || 1,
=======
order_status: orderObj.getNumber('order_status') ?? (orderObj.get('order_status') == null ? 1 : (orderObj.get('order_status') as number)),
>>>>>>> local-backup-root-cyj
total_amount: orderObj.getNumber('total_amount') || 0,
product_amount: orderObj.getNumber('product_amount') || 0,
shipping_fee: orderObj.getNumber('shipping_fee') || 0,
paid_amount: orderObj.getNumber('paid_amount') || 0,
<<<<<<< HEAD
shipping_address: orderObj.getString('shipping_address') || '',
=======
shipping_address: orderObj.get('shipping_address') != null ? (typeof orderObj.get('shipping_address') === 'string' ? orderObj.getString('shipping_address')! : JSON.stringify(orderObj.get('shipping_address'))) : '',
>>>>>>> local-backup-root-cyj
remark: orderObj.getString('remark') || '',
created_at: orderObj.getString('created_at') || '',
updated_at: orderObj.getString('updated_at') || '',
@@ -372,7 +423,14 @@
if (itemsObj != null && Array.isArray(itemsObj)) {
const itemsArray = itemsObj as any[]
for (let j = 0; j < itemsArray.length; j++) {
<<<<<<< HEAD
const orderItem = itemsArray[j] as UTSJSONObject
=======
const rawItem = itemsArray[j]
const itemStr = JSON.stringify(rawItem)
const orderItem = JSON.parse(itemStr) as UTSJSONObject
>>>>>>> local-backup-root-cyj
order.items.push({
id: orderItem.getString('id') || '',
order_id: orderItem.getString('order_id') || '',
@@ -424,13 +482,29 @@
const rawData = response.data as any[]
if (rawData != null) {
for (let i = 0; i < rawData.length; i++) {
<<<<<<< HEAD
const item = rawData[i] as UTSJSONObject
const status = item.getNumber('order_status') || 1
=======
const row = rawData[i]
const istr = JSON.stringify(row)
const item = JSON.parse(istr) as UTSJSONObject
const status_val = item.get('order_status')
let status = 1
if (status_val != null) {
status = (typeof status_val === 'number') ? (status_val as number) : parseInt(status_val.toString())
}
>>>>>>> local-backup-root-cyj
if (status === 1) counts[1]++
else if (status === 2) counts[2]++
else if (status === 3) counts[3]++
else if (status === 4) counts[4]++
<<<<<<< HEAD
else if (status === 0) counts[0]++
=======
else if (status === 0 || status === 6) counts[0]++
>>>>>>> local-backup-root-cyj
total++
}
}
@@ -500,7 +574,11 @@
},
async confirmShip() {
<<<<<<< HEAD
if (!this.selectedLogistics.name) {
=======
if (this.selectedLogistics == null || !this.selectedLogistics?.name) {
>>>>>>> local-backup-root-cyj
uni.showToast({ title: '请选择物流公司', icon: 'none' })
return
}
@@ -510,6 +588,7 @@
}
try {
<<<<<<< HEAD
const response = await supa
.from('ml_orders')
.update({
@@ -524,6 +603,31 @@
if (response.error != null) {
uni.showToast({ title: '发货失败', icon: 'none' })
=======
const payloadStr = JSON.stringify({
order_status: 3,
shipping_status: 2,
carrier_name: this.selectedLogistics?.name ?? '未知',
tracking_no: this.trackingNumber,
shipped_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
const payload = JSON.parse(payloadStr) as UTSJSONObject;
console.log('--- PAYLOAD TO SEND ---', JSON.stringify(payload));
const response = await supa.from('ml_orders').update(payload
)
.eq('id', this.currentOrder!.id)
.execute()
if (response.error != null || (response.status ?? 200) >= 400) {
let msg = '';
if (response.error != null) msg = response.error!.message;
else if (response.data != null) {
const rData = response.data as UTSJSONObject;
msg = rData.getString('message') ?? rData.getString('code') ?? JSON.stringify(rData);
}
if (!msg) msg = '请检查网络或登录状态'; uni.showToast({ title: '发货被拦截: ' + msg, icon: 'none', duration: 4500 }); console.error('SUPABASE API ERR:', response)
>>>>>>> local-backup-root-cyj
return
}
@@ -531,14 +635,22 @@
this.closeShipModal()
this.loadOrders()
this.loadOrderCounts()
<<<<<<< HEAD
} catch (e) {
uni.showToast({ title: '发货失败', icon: 'none' })
}
=======
} catch (e) { uni.showToast({ title: '发货发生异常', icon: 'none' }); console.error(e) }
>>>>>>> local-backup-root-cyj
},
viewLogistics(order: OrderType) {
uni.navigateTo({
<<<<<<< HEAD
url: `/pages/mall/merchant/logistics?orderId=${order.id}`
=======
url: `/pages/mall/consumer/logistics?orderId=${order.id}`
>>>>>>> local-backup-root-cyj
})
},
@@ -555,7 +667,11 @@
.eq('id', order.id)
.execute()
<<<<<<< HEAD
if (response.error != null) {
=======
if (response.error != null || (response.status ?? 200) >= 400) {
>>>>>>> local-backup-root-cyj
uni.showToast({ title: '删除失败', icon: 'none' })
return
}
@@ -576,7 +692,12 @@
if (status === 2) return '待发货'
if (status === 3) return '待收货'
if (status === 4) return '已完成'
<<<<<<< HEAD
if (status === 0) return '退款中'
=======
if (status === 0 || status === 6) return '退款/售后'
if (status === 7) return '退货完成'
>>>>>>> local-backup-root-cyj
if (status === 5 || status === -1) return '已取消'
return '未知'
},
@@ -717,8 +838,14 @@
.order-card {
background-color: #fff;
<<<<<<< HEAD
border-radius: 16rpx;
margin-bottom: 20rpx;
=======
border-radius: 20rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.04);
>>>>>>> local-backup-root-cyj
overflow: hidden;
}
@@ -817,22 +944,54 @@
-webkit-box-orient: vertical;
}
<<<<<<< HEAD
.product-spec {
font-size: 22rpx;
color: #999;
margin-top: 8rpx;
=======
.product-name {
font-size: 26rpx;
color: #333;
font-weight: 500;
line-height: 1.4;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.product-spec {
font-size: 22rpx;
color: #999;
background-color: #f8f8f8;
padding: 4rpx 12rpx;
border-radius: 4rpx;
align-self: flex-start;
>>>>>>> local-backup-root-cyj
}
.product-right {
display: flex;
flex-direction: column;
align-items: flex-end;
<<<<<<< HEAD
=======
margin-left: 20rpx;
min-width: 120rpx;
>>>>>>> local-backup-root-cyj
}
.product-price {
font-size: 26rpx;
color: #333;
<<<<<<< HEAD
font-weight: 500;
=======
font-weight: bold;
>>>>>>> local-backup-root-cyj
}
.product-quantity {
@@ -845,8 +1004,14 @@
display: flex;
justify-content: space-between;
align-items: center;
<<<<<<< HEAD
padding: 20rpx 24rpx;
border-top: 1rpx solid #f5f5f5;
=======
padding: 24rpx;
border-top: 1rpx solid #f8f8f8;
background-color: #fafafa;
>>>>>>> local-backup-root-cyj
}
.order-amount {
@@ -860,7 +1025,11 @@
}
.amount-value {
<<<<<<< HEAD
font-size: 28rpx;
=======
font-size: 32rpx;
>>>>>>> local-backup-root-cyj
color: #FF3B30;
font-weight: bold;
margin-left: 10rpx;
@@ -872,14 +1041,27 @@
}
.action-btn {
<<<<<<< HEAD
padding: 12rpx 24rpx;
font-size: 24rpx;
border-radius: 28rpx;
=======
min-width: 120rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
border-radius: 28rpx;
border: 1rpx solid #eee;
padding: 0 20rpx;
>>>>>>> local-backup-root-cyj
}
.action-btn.primary {
background-color: #007AFF;
color: #fff;
<<<<<<< HEAD
}
.action-btn.info {
@@ -890,6 +1072,21 @@
.action-btn.default {
background-color: #F5F5F5;
color: #666;
=======
border: none;
}
.action-btn.info {
background-color: #fff;
color: #007AFF;
border-color: #007AFF;
}
.action-btn.default {
background-color: #fff;
color: #666;
border-color: #ddd;
>>>>>>> local-backup-root-cyj
}
.load-more, .no-more {
@@ -912,7 +1109,11 @@
display: flex;
align-items: flex-end;
justify-content: center;
<<<<<<< HEAD
z-index: 1000;
=======
z-index: 99;
>>>>>>> local-backup-root-cyj
}
.modal-content {
@@ -920,6 +1121,11 @@
background-color: #fff;
border-radius: 24rpx 24rpx 0 0;
padding-bottom: env(safe-area-inset-bottom);
<<<<<<< HEAD
=======
position: relative;
z-index: 99;
>>>>>>> local-backup-root-cyj
}
.modal-header {
@@ -994,3 +1200,19 @@
font-weight: bold;
}
</style>
<<<<<<< HEAD
=======
>>>>>>> local-backup-root-cyj

View File

@@ -1,4 +1,8 @@
<<<<<<< HEAD
<!-- 商家端 - 商品管理详情页 -->
=======
<!-- 商家端 - 商品管理详情页 -->
>>>>>>> local-backup-root-cyj
<template>
<view class="product-manage-detail">
<!-- 商品基本信息 -->
@@ -44,8 +48,13 @@
</view>
<view class="info-item">
<text class="info-label">商品状态</text>
<<<<<<< HEAD
<text class="info-value" :class="{ 'status-on': product.status === 1, 'status-off': product.status === 0 }">
{{ product.status === 1 ? '上架' : '下架' }}
=======
<text class="info-value" :class="{ 'status-on': product.status === 1, 'status-off': product.status === 2 || product.status === 0 }">
{{ product.status === 1 ? '上架' : (product.status === 2 || product.status === 0 ? '下架' : '其他') }}
>>>>>>> local-backup-root-cyj
</text>
</view>
</view>
@@ -70,7 +79,11 @@
<view class="sku-details">
<text class="sku-price">¥{{ sku.price }}</text>
<text class="sku-stock">库存: {{ sku.stock }}</text>
<<<<<<< HEAD
<text class="sku-status" :class="{ 'status-on': sku.status === 1, 'status-off': sku.status === 0 }">
=======
<text class="sku-status" :class="{ 'status-on': sku.status === 1, 'status-off': sku.status === 2 || sku.status === 0 }">
>>>>>>> local-backup-root-cyj
{{ sku.status === 1 ? '启用' : '禁用' }}
</text>
</view>
@@ -705,3 +718,7 @@ export default {
color: #fff;
}
</style>
<<<<<<< HEAD
=======
>>>>>>> local-backup-root-cyj

View File

@@ -1,4 +1,8 @@
<<<<<<< HEAD
<!-- 商家端 - 商品编辑页面 -->
=======
 <!-- 商家端 - 商品编辑页面 (已修复缓存) -->
>>>>>>> local-backup-root-cyj
<template>
<view class="product-edit-page">
<!-- 商品基本信息 -->
@@ -131,8 +135,28 @@
</view>
</view>
<<<<<<< HEAD
<view class="form-item">
<text class="label">总库存 *</text>
=======
<view class="form-item">
<text class="label">VIP独立折扣</text>
<switch :checked="product.is_vip_discount" @change="e => { product.is_vip_discount = e.detail.value as boolean }" />
</view>
<view class="form-item" v-if="product.is_vip_discount">
<text class="label">VIP折扣率</text>
<input
class="input"
type="digit"
v-model="product.vip_discount_rate"
placeholder="如 0.85 代表 85 折(空代表采用全局)"
/>
</view>
<view class="form-item">
<text class="label">总库存 *</text>
>>>>>>> local-backup-root-cyj
<input
class="input"
type="number"
@@ -152,6 +176,28 @@
</view>
</view>
<<<<<<< HEAD
=======
<!-- 会员阶梯价 -->
<view class="section">
<view class="section-title">会员等级价格 (选填)</view>
<view class="section-desc">若不填写则按照商品销售价或默认折扣计算</view>
<view v-for="(level, index) in memberLevels" :key="index" class="form-item">
<text class="label">{{ level.name }}价格</text>
<view class="price-input">
<text class="unit">¥</text>
<input
class="input"
v-model="level.price"
type="digit"
placeholder="专属折扣价"
/>
</view>
</view>
</view>
>>>>>>> local-backup-root-cyj
<!-- 商品属性 -->
<view class="section">
<view class="section-title">商品属性</view>
@@ -231,6 +277,17 @@
logo_url: string
}
<<<<<<< HEAD
=======
type MemberLevelType = {
id: string
name: string
level_rank: number
discount_rate: number
price: string // 绑定输入框用
}
>>>>>>> local-backup-root-cyj
export default {
data() {
return {
@@ -242,6 +299,10 @@
brands: [] as BrandType[],
brandIndex: -1,
selectedBrand: null as BrandType | null,
<<<<<<< HEAD
=======
memberLevels: [] as MemberLevelType[],
>>>>>>> local-backup-root-cyj
product: {
name: '',
subtitle: '',
@@ -255,9 +316,17 @@
total_stock: '',
warning_stock: '10',
unit: '件',
<<<<<<< HEAD
is_hot: false,
is_new: false,
is_featured: false,
=======
is_hot: false,
is_new: false,
is_featured: false,
is_vip_discount: true,
vip_discount_rate: '',
>>>>>>> local-backup-root-cyj
description: ''
},
merchantId: ''
@@ -265,15 +334,52 @@
},
onLoad(options: any) {
<<<<<<< HEAD
const productId = options.productId as string
if (productId) {
this.productId = productId
this.isEdit = true
this.loadProductDetail(productId)
=======
let productId = ''
if (options) {
const keys = Object.keys(options as object)
for (let i = 0; i < keys.length; i++) {
if (keys[i] === 'productId') {
productId = String((options as Record<string, any>)[keys[i]])
}
}
if (!productId && options['productId']) {
productId = String(options['productId'])
}
// 兼容某些平台
if (!productId) {
try {
const optsStr = JSON.stringify(options)
const optsObj = JSON.parse(optsStr) as Record<string, any>
if (optsObj['productId']) {
productId = String(optsObj['productId'])
}
} catch(e) {}
}
}
if (productId && productId !== '') {
this.productId = productId
this.isEdit = true
uni.setNavigationBarTitle({ title: '编辑商品' })
this.loadProductDetail(productId)
} else {
uni.setNavigationBarTitle({ title: '添加商品' })
>>>>>>> local-backup-root-cyj
}
this.initMerchantId()
this.loadCategories()
this.loadBrands()
<<<<<<< HEAD
=======
this.loadMemberLevels()
>>>>>>> local-backup-root-cyj
},
methods: {
@@ -281,7 +387,11 @@
try {
const session = supa.getSession()
if (session != null && session.user != null) {
<<<<<<< HEAD
this.merchantId = session.user.getString('id') || ''
=======
this.merchantId = (session.user as any)['id'] != null ? String((session.user as any)['id']) : ''
>>>>>>> local-backup-root-cyj
}
if (!this.merchantId) {
this.merchantId = uni.getStorageSync('user_id') || ''
@@ -291,6 +401,78 @@
}
},
<<<<<<< HEAD
=======
async loadMemberLevels() {
try {
const response = await supa
.from('ml_member_levels')
.select('*')
.eq('is_active', true)
.order('level_rank', { ascending: true })
.execute()
if (response.error != null) {
console.error('获取会员等级失败:', response.error)
return
}
const rawData = response.data as any[]
if (rawData == null) return
this.memberLevels = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as any
this.memberLevels.push({
id: item['id'] != null ? String(item['id']) : '',
name: item['name'] != null ? String(item['name']) : '',
level_rank: item['level_rank'] != null ? parseInt(String(item['level_rank'])) : 0,
discount_rate: item['discount_rate'] != null ? parseFloat(String(item['discount_rate'])) : 1.0,
price: ''
} as MemberLevelType)
}
// 如果是编辑模式,还需要加载已有的会员价
if (this.isEdit) {
this.loadMemberPrices()
}
} catch (e) {
console.error('获取会员等级异常:', e)
}
},
async loadMemberPrices() {
try {
const response = await supa
.from('ml_product_member_prices')
.select('*')
.eq('product_id', this.productId)
.execute()
if (response.error != null) {
console.error('获取会员价失败:', response.error)
return
}
const rawData = response.data as any[]
if (rawData == null || rawData.length == 0) return
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as any
const levelId = String(item['level_id'])
const price = String(item['member_price'])
const index = this.memberLevels.findIndex(lv => lv.id === levelId)
if (index >= 0) {
this.memberLevels[index].price = price
}
}
} catch (e) {
console.error('获取会员价异常:', e)
}
},
>>>>>>> local-backup-root-cyj
async loadCategories() {
try {
const response = await supa
@@ -309,10 +491,17 @@
if (rawData == null) return
for (let i = 0; i < rawData.length; i++) {
<<<<<<< HEAD
const item = rawData[i] as UTSJSONObject
this.categories.push({
id: item.getString('id') || '',
name: item.getString('name') || ''
=======
const item = rawData[i] as any
this.categories.push({
id: item['id'] != null ? String(item['id']) : '',
name: item['name'] != null ? String(item['name']) : ''
>>>>>>> local-backup-root-cyj
} as CategoryType)
}
} catch (e) {
@@ -338,11 +527,19 @@
if (rawData == null) return
for (let i = 0; i < rawData.length; i++) {
<<<<<<< HEAD
const item = rawData[i] as UTSJSONObject
this.brands.push({
id: item.getString('id') || '',
name: item.getString('name') || '',
logo_url: item.getString('logo_url') || ''
=======
const item = rawData[i] as any
this.brands.push({
id: item['id'] != null ? String(item['id']) : '',
name: item['name'] != null ? String(item['name']) : '',
logo_url: item['logo_url'] != null ? String(item['logo_url']) : ''
>>>>>>> local-backup-root-cyj
} as BrandType)
}
} catch (e) {
@@ -352,6 +549,10 @@
async loadProductDetail(productId: string) {
try {
<<<<<<< HEAD
=======
uni.showLoading({ title: '加载商品中...' })
>>>>>>> local-backup-root-cyj
const response = await supa
.from('ml_products')
.select('*')
@@ -359,6 +560,7 @@
.single()
.execute()
<<<<<<< HEAD
if (response.error != null) {
console.error('获取商品详情失败:', response.error)
return
@@ -386,6 +588,47 @@
description: rawData.getString('description') || ''
}
=======
uni.hideLoading()
if (response.error != null) {
console.error('获取详情失败:', response.error)
uni.showToast({ title: '没有找到该商品', icon: 'none' })
return
}
let rawData = response.data as any
if (rawData == null) return
// 防止Supabase某些版本把single()仍返回数组的坑
if (Array.isArray(rawData) && rawData.length > 0) {
rawData = rawData[0]
}
const getStr = (key: string): string => { try { return rawData[key] != null ? String(rawData[key]) : '' } catch(e){ return '' } }
const getBool = (key: string): boolean => { try { return rawData[key] === true || rawData[key] === 'true' } catch(e){ return false } }
this.product.name = getStr('name')
this.product.subtitle = getStr('subtitle')
this.product.category_id = getStr('category_id')
this.product.brand_id = getStr('brand_id')
this.product.main_image_url = getStr('main_image_url')
this.product.imageList = this.parseImageUrls(getStr('image_urls'))
this.product.base_price = getStr('base_price')
this.product.market_price = getStr('market_price')
this.product.cost_price = getStr('cost_price')
this.product.total_stock = getStr('total_stock')
this.product.warning_stock = getStr('warning_stock') || '10'
this.product.unit = getStr('unit') || '件'
this.product.is_hot = getBool('is_hot')
this.product.is_new = getBool('is_new')
this.product.is_featured = getBool('is_featured')
const _isVip = rawData['is_vip_discount']
this.product.is_vip_discount = _isVip == null ? true : getBool('is_vip_discount')
this.product.vip_discount_rate = getStr('vip_discount_rate')
this.product.description = getStr('description')
>>>>>>> local-backup-root-cyj
if (this.product.category_id) {
this.categoryIndex = this.categories.findIndex(c => c.id === this.product.category_id)
if (this.categoryIndex >= 0) {
@@ -400,7 +643,13 @@
}
}
} catch (e) {
<<<<<<< HEAD
console.error('获取商品详情异常:', e)
=======
uni.hideLoading()
console.error('获取商品详情异常:', e)
uni.showToast({ title: '加载异常: ' + String(e), icon: 'none', duration: 3000 })
>>>>>>> local-backup-root-cyj
}
},
@@ -453,6 +702,7 @@
this.product.imageList.splice(index, 1)
},
<<<<<<< HEAD
async saveProduct() {
if (!this.product.name) {
uni.showToast({ title: '请输入商品名称', icon: 'none' })
@@ -533,6 +783,203 @@
}
}
}
=======
async uploadImageToSupa(localPath: string): Promise<string> {
if (localPath.startsWith('http://') || localPath.startsWith('https://')) {
return localPath
}
let ext = '.jpg'
const dotIndex = localPath.lastIndexOf('.')
if (dotIndex > -1) {
ext = localPath.substring(dotIndex).toLowerCase()
}
const uuid = Date.now().toString() + '_' + Math.floor(Math.random() * 1000)
const remotePath = `products/${this.merchantId}_${uuid}${ext}`
try {
const uploadResult = await supa.storage.from('zhipao').upload(remotePath, localPath, {})
if (uploadResult.error != null) {
console.error('上传图片失败:', uploadResult.error)
return localPath
}
return supa.storage.getPublicUrl('zhipao', remotePath)
} catch (e) {
console.error('上传图片异常:', e)
return localPath
}
},
async saveProduct() {
if (!this.product.name) {
uni.showToast({ title: '请输入商品名称', icon: 'none' })
return
}
if (!this.product.category_id) {
uni.showToast({ title: '请选择商品分类', icon: 'none' })
return
}
if (!this.product.base_price) {
uni.showToast({ title: '请输入销售价', icon: 'none' })
return
}
if (!this.product.total_stock) {
uni.showToast({ title: '请输入总库存', icon: 'none' })
return
}
if (this.product.is_vip_discount && this.product.vip_discount_rate !== '') {
const rate = parseFloat(this.product.vip_discount_rate)
if (isNaN(rate) || rate <= 0 || rate > 1) {
uni.showToast({ title: 'VIP折扣率需在0~1之间', icon: 'none' })
return
}
}
uni.showLoading({ title: '保存中...' })
try {
let finalMainImage = this.product.main_image_url
if (finalMainImage != '') {
finalMainImage = await this.uploadImageToSupa(finalMainImage)
}
const finalImageList = [] as string[]
for (let i = 0; i < this.product.imageList.length; i++) {
const img = await this.uploadImageToSupa(this.product.imageList[i])
finalImageList.push(img)
}
const imageUrlsStr = JSON.stringify(finalImageList)
const productData = {
merchant_id: this.merchantId,
name: this.product.name,
subtitle: this.product.subtitle,
category_id: this.product.category_id,
brand_id: this.product.brand_id || null,
main_image_url: finalMainImage,
image_urls: imageUrlsStr,
base_price: this.product.base_price ? parseFloat(this.product.base_price) : 0,
market_price: this.product.market_price ? parseFloat(this.product.market_price) : null,
cost_price: this.product.cost_price ? parseFloat(this.product.cost_price) : null,
total_stock: parseInt(this.product.total_stock),
available_stock: parseInt(this.product.total_stock),
is_hot: this.product.is_hot,
is_new: this.product.is_new,
is_featured: this.product.is_featured,
is_vip_discount: this.product.is_vip_discount,
vip_discount_rate: this.product.vip_discount_rate ? parseFloat(this.product.vip_discount_rate) : null,
description: this.product.description,
status: 1,
updated_at: new Date().toISOString()
} as UTSJSONObject
let response : any = null
if (this.isEdit) {
const updateData = {} as UTSJSONObject
const keys = UTSJSONObject.keys(productData)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
if (key != 'status') {
updateData[key] = productData[key]
}
}
console.log('执行产品更新, ID:', this.productId)
const updateResponse = await supa
.from('ml_products')
.update(updateData)
.eq('id', this.productId)
.execute()
if (updateResponse.error != null) {
throw new Error('产品更新失败: ' + String(updateResponse.error!.message))
}
response = updateResponse
} else {
productData['created_at'] = new Date().toISOString()
productData['product_code'] = 'P' + Date.now().toString()
console.log('执行新产品插入')
const insertResponse = await supa
.from('ml_products')
.insert(productData)
.execute()
if (insertResponse.error != null) {
throw new Error('产品发布失败: ' + String(insertResponse.error!.message))
}
response = insertResponse
}
// 保存会员价
let targetProductId = this.isEdit ? this.productId : ''
if (response != null && response.data != null) {
const responseData = response.data
if (Array.isArray(responseData)) {
const dataArr = responseData as any[]
if (dataArr.length > 0) {
const firstRow = dataArr[0] as UTSJSONObject
if (firstRow['id'] != null) {
targetProductId = String(firstRow['id'])
}
}
} else if (responseData instanceof UTSJSONObject) {
const dataObj = responseData as UTSJSONObject
if (dataObj['id'] != null) {
targetProductId = String(dataObj['id'])
}
}
}
console.log('最终目标产品ID:', targetProductId)
if (targetProductId && targetProductId !== '' && targetProductId !== 'undefined') {
// 1. 先删除旧的会员价
if (this.isEdit) {
console.log('删除旧会员价:', targetProductId)
await supa.from('ml_product_member_prices').delete().eq('product_id', targetProductId).execute()
}
// 2. 插入新的会员价
for (let i = 0; i < this.memberLevels.length; i++) {
const level = this.memberLevels[i]
if (level.price && level.price > 0) {
const memberPriceData = {
product_id: targetProductId,
level_id: level.id,
member_price: level.price,
created_at: new Date().toISOString()
} as UTSJSONObject
const insertRes = await supa
.from('ml_product_member_prices')
.insert(memberPriceData)
.execute()
if (insertRes.error != null) {
console.error('插入会员价失败', insertRes.error)
}
}
}
}
uni.hideLoading()
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (e) {
uni.hideLoading()
console.error('保存商品异常:', e)
uni.showToast({ title: '保存异常: ' + String(e), icon: 'none' })
}
}
}
}
>>>>>>> local-backup-root-cyj
</script>
<style>
@@ -557,6 +1004,16 @@
border-bottom: 1rpx solid #f5f5f5;
}
<<<<<<< HEAD
=======
.section-desc {
font-size: 24rpx;
color: #999;
margin-top: -20rpx;
margin-bottom: 30rpx;
}
>>>>>>> local-backup-root-cyj
.form-item {
margin-bottom: 30rpx;
}
@@ -713,3 +1170,9 @@
color: #fff;
}
</style>
<<<<<<< HEAD
=======
>>>>>>> local-backup-root-cyj

View File

@@ -1,4 +1,8 @@
<<<<<<< HEAD
<!-- 商家端 - 商品管理列表页面 -->
=======
<!-- 商家端 - 商品管理列表页面 -->
>>>>>>> local-backup-root-cyj
<template>
<view class="products-page">
<!-- 搜索栏 -->
@@ -84,11 +88,20 @@
</text>
</view>
<text class="product-subtitle">{{ product.subtitle || '暂无描述' }}</text>
<<<<<<< HEAD
<view class="product-tags" v-if="product.tags">
<text v-if="product.is_hot" class="tag hot">热</text>
<text v-if="product.is_new" class="tag new">新</text>
<text v-if="product.is_featured" class="tag recommend">荐</text>
</view>
=======
<view class="product-tags">
<text v-if="product.is_hot" class="tag hot">热</text>
<text v-if="product.is_new" class="tag new">新</text>
<text v-if="product.is_featured" class="tag recommend">荐</text>
<text v-if="product.is_vip_discount" class="tag vip">VIP</text>
</view>
>>>>>>> local-backup-root-cyj
<view class="product-stats">
<view class="price-row">
<text class="current-price">¥{{ product.base_price }}</text>
@@ -222,7 +235,11 @@
if (this.currentFilter === 'onsale') {
query = query.eq('status', 1)
} else if (this.currentFilter === 'offsale') {
<<<<<<< HEAD
query = query.eq('status', 0)
=======
query = query.eq('status', 2)
>>>>>>> local-backup-root-cyj
} else if (this.currentFilter === 'low_stock') {
query = query.lte('total_stock', this.lowStockThreshold).gte('total_stock', 0)
}
@@ -259,7 +276,11 @@
market_price: prodObj.getNumber('market_price') || 0,
total_stock: prodObj.getNumber('total_stock') || 0,
sale_count: prodObj.getNumber('sale_count') || 0,
<<<<<<< HEAD
status: prodObj.getNumber('status') || 0,
=======
status: prodObj.getNumber('status') || 1,
>>>>>>> local-backup-root-cyj
is_hot: prodObj.getBoolean('is_hot') || false,
is_new: prodObj.getBoolean('is_new') || false,
is_featured: prodObj.getBoolean('is_featured') || false,
@@ -332,7 +353,11 @@
},
async toggleStatus(product: ProductType) {
<<<<<<< HEAD
const newStatus = product.status === 1 ? 0 : 1
=======
const newStatus = product.status === 1 ? 2 : 1
>>>>>>> local-backup-root-cyj
const actionText = newStatus === 1 ? '上架' : '下架'
uni.showModal({
@@ -395,13 +420,21 @@
getStatusClass(status: number): string {
if (status === 1) return 'status-onsale'
<<<<<<< HEAD
if (status === 0) return 'status-offsale'
=======
if (status === 2 || status === 0) return 'status-offsale'
>>>>>>> local-backup-root-cyj
return 'status-pending'
},
getStatusText(status: number): string {
if (status === 1) return '在售'
<<<<<<< HEAD
if (status === 0) return '已下架'
=======
if (status === 2 || status === 0) return '已下架'
>>>>>>> local-backup-root-cyj
return '待审核'
}
}
@@ -601,10 +634,23 @@
color: #fff;
}
<<<<<<< HEAD
.tag.recommend {
background-color: #9C27B0;
color: #fff;
}
=======
.tag.recommend {
background-color: #9C27B0;
color: #fff;
}
.tag.vip {
background-color: #FFC107;
color: #333;
font-weight: bold;
}
>>>>>>> local-backup-root-cyj
.product-stats {
display: flex;
@@ -710,3 +756,9 @@
font-weight: bold;
}
</style>
<<<<<<< HEAD
=======
>>>>>>> local-backup-root-cyj

View File

@@ -135,13 +135,52 @@
}
})
},
<<<<<<< HEAD
=======
async uploadImageToSupa(localPath: string): Promise<string> {
if (localPath.startsWith('http://') || localPath.startsWith('https://')) {
return localPath
}
let ext = '.jpg'
const dotIndex = localPath.lastIndexOf('.')
if (dotIndex > -1) {
ext = localPath.substring(dotIndex).toLowerCase()
}
const uuid = Date.now().toString() + '_' + Math.floor(Math.random() * 1000)
const remotePath = `shops/${this.merchantId}_${uuid}${ext}`
try {
const uploadResult = await supa.storage.from('zhipao').upload(remotePath, localPath, {})
if (uploadResult.status == 200 || uploadResult.status == 201) {
const data = uploadResult.data
if (data != null) {
const dataObj = data as UTSJSONObject
const key = dataObj.getString('Key')
if (key != null && key != '') {
return `${supa.baseUrl}/storage/v1/object/public/${key}`
}
}
}
console.error('上传图片失败:', uploadResult.error)
return localPath
} catch (e) {
console.error('上传图片异常:', e)
return localPath
}
},
>>>>>>> local-backup-root-cyj
async saveShop() {
if (!this.shop.shop_name) {
uni.showToast({ title: '请输入店铺名称', icon: 'none' })
return
}
<<<<<<< HEAD
uni.showLoading({ title: '保存中...' })
try {
@@ -149,6 +188,27 @@
shop_name: this.shop.shop_name,
shop_logo: this.shop.shop_logo,
shop_banner: this.shop.shop_banner,
=======
uni.showLoading({ title: '正在上传图片...' })
try {
let finalLogo = this.shop.shop_logo
if (finalLogo != '' && !finalLogo.startsWith('http')) {
finalLogo = await this.uploadImageToSupa(finalLogo)
}
let finalBanner = this.shop.shop_banner
if (finalBanner != '' && !finalBanner.startsWith('http')) {
finalBanner = await this.uploadImageToSupa(finalBanner)
}
uni.showLoading({ title: '保存中...' })
const shopData = {
shop_name: this.shop.shop_name,
shop_logo: finalLogo,
shop_banner: finalBanner,
>>>>>>> local-backup-root-cyj
description: this.shop.description,
contact_name: this.shop.contact_name,
contact_phone: this.shop.contact_phone,