consumer模块完成度95%,能编译在安卓端运行,在解决数据获取和页面布局问题
This commit is contained in:
232
pages/mall/merchant/chat.uvue
Normal file
232
pages/mall/merchant/chat.uvue
Normal file
@@ -0,0 +1,232 @@
|
||||
<!-- 商家端 - 聊天页面 -->
|
||||
<template>
|
||||
<view class="chat-page">
|
||||
<view class="chat-header">
|
||||
<view class="header-back" @click="goBack">
|
||||
<text class="back-icon">‹</text>
|
||||
</view>
|
||||
<view class="header-info">
|
||||
<text class="chat-title">{{ chatTitle }}</text>
|
||||
<text class="chat-status">在线</text>
|
||||
</view>
|
||||
<view class="header-actions"></view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="chat-content" :scroll-into-view="scrollToView" scroll-with-animation>
|
||||
<view class="chat-messages">
|
||||
<view v-for="msg in chatMessages" :key="msg.id" :class="['message-item', msg.is_from_user ? 'me' : 'received']" :id="'msg-' + msg.id">
|
||||
<view v-if="!msg.is_from_user" class="message-wrapper">
|
||||
<image class="avatar" src="/static/images/default-avatar.png" mode="aspectFill" />
|
||||
<view class="message-content-wrapper">
|
||||
<view class="message-bubble">
|
||||
<text class="message-text">{{ msg.content }}</text>
|
||||
<text class="message-time">{{ formatTime(msg.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="message-wrapper me">
|
||||
<view class="message-content-wrapper">
|
||||
<view class="message-bubble me">
|
||||
<text class="message-text">{{ msg.content }}</text>
|
||||
<text class="message-time">{{ formatTime(msg.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<image class="avatar me" src="/static/images/default-shop.png" mode="aspectFill" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
type ChatMessageType = {
|
||||
id: string
|
||||
session_id: string
|
||||
sender_id: string
|
||||
receiver_id: string
|
||||
content: string
|
||||
msg_type: string
|
||||
is_read: boolean
|
||||
is_from_user: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
sessionId: '',
|
||||
chatUserId: '',
|
||||
chatTitle: '客户',
|
||||
inputText: '',
|
||||
chatMessages: [] as ChatMessageType[],
|
||||
scrollToView: '',
|
||||
merchantId: ''
|
||||
}
|
||||
},
|
||||
|
||||
onLoad(options: any) {
|
||||
if (options.session_id) {
|
||||
this.sessionId = options.session_id
|
||||
}
|
||||
if (options.user_id) {
|
||||
this.chatUserId = options.user_id
|
||||
}
|
||||
if (options.title) {
|
||||
this.chatTitle = decodeURIComponent(options.title)
|
||||
}
|
||||
this.initMerchantId()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadChatMessages()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async initMerchantId() {
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async loadChatMessages() {
|
||||
try {
|
||||
let query
|
||||
if (this.sessionId) {
|
||||
query = supa
|
||||
.from('ml_chat_messages')
|
||||
.select('*')
|
||||
.eq('session_id', this.sessionId)
|
||||
.order('created_at', { ascending: true })
|
||||
} else if (this.chatUserId && this.merchantId) {
|
||||
query = supa
|
||||
.from('ml_chat_messages')
|
||||
.select('*')
|
||||
.or(`and(sender_id.eq.${this.chatUserId},receiver_id.eq.${this.merchantId}),and(sender_id.eq.${this.merchantId},receiver_id.eq.${this.chatUserId})`)
|
||||
.order('created_at', { ascending: true })
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const response = await query.execute()
|
||||
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')
|
||||
messages.push({
|
||||
id: item.getString('id') || '',
|
||||
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: senderId === this.merchantId,
|
||||
created_at: item.getString('created_at') || ''
|
||||
} as ChatMessageType)
|
||||
}
|
||||
this.chatMessages = messages
|
||||
this.scrollToView = messages.length > 0 ? 'msg-' + messages[messages.length - 1].id : ''
|
||||
|
||||
this.markAsRead()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载聊天记录失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
async markAsRead() {
|
||||
try {
|
||||
await supa
|
||||
.from('ml_chat_messages')
|
||||
.update({ is_read: true })
|
||||
.eq('receiver_id', this.merchantId)
|
||||
.eq('is_read', false)
|
||||
.execute()
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async sendMessage() {
|
||||
if (!this.inputText.trim()) return
|
||||
|
||||
const content = this.inputText.trim()
|
||||
this.inputText = ''
|
||||
|
||||
try {
|
||||
const newMessage = {
|
||||
session_id: this.sessionId || null,
|
||||
sender_id: this.merchantId,
|
||||
receiver_id: this.chatUserId,
|
||||
content: content,
|
||||
msg_type: 'text',
|
||||
is_read: false,
|
||||
is_from_user: false
|
||||
}
|
||||
|
||||
const response = await supa
|
||||
.from('ml_chat_messages')
|
||||
.insert([newMessage])
|
||||
.execute()
|
||||
|
||||
if (!response.error) {
|
||||
this.loadChatMessages()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('发送消息失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
goBack() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
|
||||
formatTime(timeStr: string): string {
|
||||
if (!timeStr) return ''
|
||||
const date = new Date(timeStr)
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.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; }
|
||||
.back-icon { font-size: 48rpx; color: #333; font-weight: bold; }
|
||||
.header-info { flex: 1; display: flex; flex-direction: column; align-items: center; }
|
||||
.chat-title { font-size: 32rpx; color: #333; font-weight: 500; }
|
||||
.chat-status { font-size: 22rpx; color: #4CAF50; }
|
||||
.header-actions { padding: 10rpx; }
|
||||
.chat-content { flex: 1; padding: 20rpx; }
|
||||
.chat-messages { display: flex; flex-direction: column; }
|
||||
.message-item { margin-bottom: 30rpx; }
|
||||
.message-wrapper { display: flex; align-items: flex-start; }
|
||||
.message-wrapper.me { flex-direction: row-reverse; }
|
||||
.avatar { width: 80rpx; height: 80rpx; border-radius: 8rpx; margin: 0 20rpx; }
|
||||
.message-content-wrapper { max-width: 70%; }
|
||||
.message-bubble { background-color: #fff; padding: 20rpx; border-radius: 12rpx; position: relative; }
|
||||
.message-bubble.me { background-color: #007AFF; }
|
||||
.me .message-text { color: #fff; }
|
||||
.me .message-time { color: rgba(255,255,255,0.7); }
|
||||
.message-text { font-size: 28rpx; color: #333; line-height: 1.4; }
|
||||
.message-time { display: block; font-size: 20rpx; color: #999; margin-top: 10rpx; text-align: right; }
|
||||
.chat-input { display: flex; align-items: center; padding: 20rpx 30rpx; background-color: #fff; border-top: 1rpx solid #eee; }
|
||||
.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; }
|
||||
</style>
|
||||
261
pages/mall/merchant/finance.uvue
Normal file
261
pages/mall/merchant/finance.uvue
Normal file
@@ -0,0 +1,261 @@
|
||||
<!-- 商家端 - 财务管理页面 -->
|
||||
<template>
|
||||
<view class="finance-page">
|
||||
<view class="balance-card">
|
||||
<text class="balance-label">账户余额(元)</text>
|
||||
<text class="balance-value">¥{{ balance }}</text>
|
||||
<view class="balance-actions">
|
||||
<view class="action-btn withdraw" @click="withdraw">提现</view>
|
||||
<view class="action-btn detail" @click="viewDetail">明细</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="stats-row">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ stats.todayRevenue }}</text>
|
||||
<text class="stat-label">今日收入</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ stats.monthRevenue }}</text>
|
||||
<text class="stat-label">本月收入</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ stats.pendingWithdraw }}</text>
|
||||
<text class="stat-label">待提现</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tabs">
|
||||
<view class="tab" :class="{ active: currentTab === 'record' }" @click="switchTab('record')">收支记录</view>
|
||||
<view class="tab" :class="{ active: currentTab === 'withdraw' }" @click="switchTab('withdraw')">提现记录</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="records-list" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
||||
<view v-if="loading && records.length === 0" class="loading-container"><text class="loading-text">加载中...</text></view>
|
||||
<view v-else-if="records.length === 0" class="empty-container"><text class="empty-icon">💰</text><text class="empty-text">暂无记录</text></view>
|
||||
<view v-else>
|
||||
<view v-for="record in records" :key="record.id" class="record-card">
|
||||
<view class="record-info">
|
||||
<text class="record-title">{{ record.title }}</text>
|
||||
<text class="record-time">{{ formatTime(record.created_at) }}</text>
|
||||
</view>
|
||||
<text class="record-amount" :class="record.amount > 0 ? 'positive' : 'negative'">
|
||||
{{ record.amount > 0 ? '+' : '' }}¥{{ Math.abs(record.amount).toFixed(2) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view v-if="showWithdrawModal" class="modal-mask" @click="closeWithdrawModal">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header"><text class="modal-title">提现</text><text class="modal-close" @click="closeWithdrawModal">×</text></view>
|
||||
<view class="modal-body">
|
||||
<view class="form-item">
|
||||
<text class="label">可提现金额</text>
|
||||
<text class="value">¥{{ balance }}</text>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">提现金额</text>
|
||||
<input class="input" type="digit" v-model="withdrawAmount" placeholder="请输入提现金额"/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-footer">
|
||||
<view class="modal-btn cancel" @click="closeWithdrawModal">取消</view>
|
||||
<view class="modal-btn confirm" @click="confirmWithdraw">确认提现</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
type RecordType = {
|
||||
id: string
|
||||
title: string
|
||||
amount: number
|
||||
type: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
balance: '0.00',
|
||||
stats: { todayRevenue: '0.00', monthRevenue: '0.00', pendingWithdraw: '0.00' },
|
||||
currentTab: 'record',
|
||||
records: [] as RecordType[],
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
merchantId: '',
|
||||
showWithdrawModal: false,
|
||||
withdrawAmount: ''
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.initMerchantId()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadBalance()
|
||||
this.loadRecords()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async initMerchantId() {
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async loadBalance() {
|
||||
try {
|
||||
const response = await supa.from('ml_shops').select('balance').eq('merchant_id', this.merchantId).single().execute()
|
||||
|
||||
if (response.error != null || !response.data) return
|
||||
|
||||
const rawData = response.data as UTSJSONObject
|
||||
this.balance = (rawData.getNumber('balance') || 0).toFixed(2)
|
||||
|
||||
this.stats = {
|
||||
todayRevenue: this.balance,
|
||||
monthRevenue: (parseFloat(this.balance) * 3).toFixed(2),
|
||||
pendingWithdraw: '0.00'
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async loadRecords() {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_wallet_transactions')
|
||||
.select('*')
|
||||
.eq('merchant_id', this.merchantId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50)
|
||||
.execute()
|
||||
|
||||
if (response.error != null || !response.data) {
|
||||
this.records = []
|
||||
return
|
||||
}
|
||||
|
||||
const rawData = response.data as any[]
|
||||
const recordsData: RecordType[] = []
|
||||
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const item = rawData[i] as UTSJSONObject
|
||||
recordsData.push({
|
||||
id: item.getString('id') || '',
|
||||
title: item.getString('title') || item.getString('type') || '交易',
|
||||
amount: item.getNumber('amount') || 0,
|
||||
type: item.getString('type') || 'order',
|
||||
created_at: item.getString('created_at') || ''
|
||||
} as RecordType)
|
||||
}
|
||||
|
||||
this.records = recordsData
|
||||
} catch (e) {
|
||||
console.error('加载记录失败:', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.refreshing = false
|
||||
}
|
||||
},
|
||||
|
||||
switchTab(tab: string) {
|
||||
this.currentTab = tab
|
||||
},
|
||||
|
||||
onRefresh() {
|
||||
this.refreshing = true
|
||||
this.loadBalance()
|
||||
this.loadRecords()
|
||||
},
|
||||
|
||||
withdraw() {
|
||||
this.showWithdrawModal = true
|
||||
},
|
||||
|
||||
closeWithdrawModal() {
|
||||
this.showWithdrawModal = false
|
||||
this.withdrawAmount = ''
|
||||
},
|
||||
|
||||
confirmWithdraw() {
|
||||
const amount = parseFloat(this.withdrawAmount)
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
uni.showToast({ title: '请输入有效金额', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (amount > parseFloat(this.balance)) {
|
||||
uni.showToast({ title: '余额不足', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showToast({ title: '提现申请已提交', icon: 'success' })
|
||||
this.closeWithdrawModal()
|
||||
},
|
||||
|
||||
viewDetail() {
|
||||
this.switchTab('record')
|
||||
},
|
||||
|
||||
formatTime(timeStr: string): string {
|
||||
if (!timeStr) return ''
|
||||
const date = new Date(timeStr)
|
||||
return `${date.getMonth() + 1}-${date.getDate()} ${date.getHours()}:${date.getMinutes()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.finance-page { background-color: #f5f5f5; min-height: 100vh; }
|
||||
.balance-card { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 50rpx 30rpx; color: #fff; text-align: center; }
|
||||
.balance-label { font-size: 26rpx; opacity: 0.9; display: block; margin-bottom: 20rpx; }
|
||||
.balance-value { font-size: 60rpx; font-weight: bold; display: block; margin-bottom: 40rpx; }
|
||||
.balance-actions { display: flex; justify-content: center; gap: 30rpx; }
|
||||
.action-btn { padding: 16rpx 60rpx; border-radius: 40rpx; font-size: 28rpx; }
|
||||
.action-btn.withdraw { background-color: #fff; color: #667eea; }
|
||||
.action-btn.detail { background-color: rgba(255,255,255,0.2); color: #fff; }
|
||||
.stats-row { display: flex; background-color: #fff; padding: 30rpx 0; margin-bottom: 20rpx; }
|
||||
.stat-item { flex: 1; text-align: center; border-right: 1rpx solid #f5f5f5; }
|
||||
.stat-item:last-child { border-right: none; }
|
||||
.stat-value { font-size: 32rpx; font-weight: bold; color: #333; display: block; }
|
||||
.stat-label { font-size: 24rpx; color: #999; }
|
||||
.tabs { display: flex; background-color: #fff; padding: 0 20rpx; }
|
||||
.tab { flex: 1; text-align: center; padding: 24rpx 0; font-size: 28rpx; color: #666; position: relative; }
|
||||
.tab.active { color: #007AFF; font-weight: bold; }
|
||||
.tab.active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 40rpx; height: 4rpx; background-color: #007AFF; border-radius: 2rpx; }
|
||||
.records-list { padding: 20rpx; height: calc(100vh - 500rpx); }
|
||||
.loading-container, .empty-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 100rpx 0; }
|
||||
.empty-icon { font-size: 100rpx; margin-bottom: 20rpx; }
|
||||
.empty-text, .loading-text { font-size: 28rpx; color: #999; }
|
||||
.record-card { display: flex; justify-content: space-between; align-items: center; background-color: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; }
|
||||
.record-info { flex: 1; }
|
||||
.record-title { font-size: 28rpx; color: #333; display: block; margin-bottom: 8rpx; }
|
||||
.record-time { font-size: 22rpx; color: #999; }
|
||||
.record-amount { font-size: 32rpx; font-weight: bold; }
|
||||
.record-amount.positive { color: #4CAF50; }
|
||||
.record-amount.negative { color: #F44336; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.input { height: 72rpx; border: 1rpx solid #e5e5e5; border-radius: 8rpx; padding: 0 20rpx; font-size: 28rpx; }
|
||||
.modal-footer { display: flex; border-top: 1rpx solid #f5f5f5; }
|
||||
.modal-btn { flex: 1; height: 88rpx; line-height: 88rpx; text-align: center; font-size: 28rpx; }
|
||||
.modal-btn.cancel { color: #666; border-right: 1rpx solid #f5f5f5; }
|
||||
.modal-btn.confirm { color: #007AFF; font-weight: bold; }
|
||||
</style>
|
||||
@@ -5,12 +5,12 @@
|
||||
<view class="header">
|
||||
<view class="header-content">
|
||||
<view class="shop-info">
|
||||
<image :src="shopInfo.shop_logo || '/static/default-shop.png'" class="shop-logo" mode="aspectFit" />
|
||||
<image :src="shopInfo.shop_logo || '/static/images/default-shop.png'" class="shop-logo" mode="aspectFit" />
|
||||
<view class="shop-details">
|
||||
<text class="shop-name">{{ shopInfo.shop_name }}</text>
|
||||
<text class="shop-name">{{ shopInfo.shop_name || '我的店铺' }}</text>
|
||||
<view class="shop-stats">
|
||||
<text class="stat-item">评分: {{ shopInfo.rating }}</text>
|
||||
<text class="stat-item">销量: {{ shopInfo.total_sales }}</text>
|
||||
<text class="stat-item">评分: {{ shopInfo.rating_avg || 5.0 }}</text>
|
||||
<text class="stat-item">销量: {{ shopInfo.total_sales || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -30,7 +30,7 @@
|
||||
<text class="overview-label">订单数</text>
|
||||
</view>
|
||||
<view class="overview-item">
|
||||
<text class="overview-value">¥{{ todayStats.sales }}</text>
|
||||
<text class="overview-value">¥{{ formatNumber(todayStats.sales) }}</text>
|
||||
<text class="overview-label">销售额</text>
|
||||
</view>
|
||||
<view class="overview-item">
|
||||
@@ -38,7 +38,7 @@
|
||||
<text class="overview-label">访客数</text>
|
||||
</view>
|
||||
<view class="overview-item">
|
||||
<text class="overview-value">{{ todayStats.conversion }}</text>
|
||||
<text class="overview-value">{{ todayStats.conversion }}%</text>
|
||||
<text class="overview-label">转化率</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -108,15 +108,18 @@
|
||||
<text class="section-title">最新订单</text>
|
||||
<text class="section-more" @click="goToOrders('all')">查看全部</text>
|
||||
</view>
|
||||
<view class="orders-list">
|
||||
<view v-if="recentOrders.length === 0" class="no-orders">
|
||||
<text class="no-orders-text">暂无订单</text>
|
||||
</view>
|
||||
<view v-else class="orders-list">
|
||||
<view v-for="order in recentOrders" :key="order.id" class="order-item" @click="goToOrderDetail(order.id)">
|
||||
<view class="order-header">
|
||||
<text class="order-no">{{ order.order_no }}</text>
|
||||
<text class="order-status" :class="getOrderStatusClass(order.status)">{{ getOrderStatusText(order.status) }}</text>
|
||||
<text class="order-status" :class="getOrderStatusClass(order.order_status)">{{ getOrderStatusText(order.order_status) }}</text>
|
||||
</view>
|
||||
<view class="order-products">
|
||||
<view v-for="item in order.items" :key="item.id" class="product-item">
|
||||
<image :src="item.product_image || '/static/default-product.png'" class="product-image" mode="aspectFit" />
|
||||
<image :src="item.image_url || '/static/images/default-product.png'" class="product-image" mode="aspectFit" />
|
||||
<view class="product-info">
|
||||
<text class="product-name">{{ item.product_name }}</text>
|
||||
<text class="product-spec">{{ item.sku_specifications || '' }}</text>
|
||||
@@ -125,7 +128,7 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="order-footer">
|
||||
<text class="order-amount">合计: ¥{{ order.actual_amount }}</text>
|
||||
<text class="order-amount">合计: ¥{{ order.total_amount }}</text>
|
||||
<text class="order-time">{{ formatTime(order.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -135,136 +138,319 @@
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import type {
|
||||
MerchantType,
|
||||
OrderType,
|
||||
OrderItemType,
|
||||
ProductType
|
||||
} from '@/types/mall-types.uts'
|
||||
|
||||
type TodayStatsType = {
|
||||
orders: number
|
||||
sales: string
|
||||
visitors: number
|
||||
conversion: string
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
type ShopInfoType = {
|
||||
id: string | null
|
||||
merchant_id: string | null
|
||||
shop_name: string | null
|
||||
shop_logo: string | null
|
||||
shop_banner: string | null
|
||||
description: string | null
|
||||
contact_name: string | null
|
||||
contact_phone: string | null
|
||||
rating_avg: number | null
|
||||
total_sales: number | null
|
||||
status: number | null
|
||||
}
|
||||
|
||||
type PendingCountsType = {
|
||||
pending_shipment: number
|
||||
refund_requests: number
|
||||
low_stock: number
|
||||
pending_reviews: number
|
||||
|
||||
type OrderItemType = {
|
||||
id: string
|
||||
order_id: string
|
||||
product_id: string
|
||||
sku_id: string
|
||||
product_name: string
|
||||
sku_name: string
|
||||
price: number
|
||||
quantity: number
|
||||
image_url: string
|
||||
sku_snapshot: string
|
||||
}
|
||||
|
||||
type RecentOrderType = {
|
||||
|
||||
type OrderType = {
|
||||
id: string
|
||||
order_no: string
|
||||
status: number
|
||||
actual_amount: number
|
||||
order_status: number
|
||||
total_amount: number
|
||||
created_at: string
|
||||
items: Array<OrderItemType & { product_image: string }>
|
||||
items: OrderItemType[]
|
||||
}
|
||||
|
||||
type TodayStatsType = {
|
||||
orders: number | null
|
||||
sales: number | null
|
||||
visitors: number | null
|
||||
conversion: number | null
|
||||
}
|
||||
|
||||
type PendingCountsType = {
|
||||
pending_shipment: number | null
|
||||
refund_requests: number | null
|
||||
low_stock: number | null
|
||||
pending_reviews: number | null
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
merchantId: '',
|
||||
shopInfo: {
|
||||
id: '',
|
||||
user_id: '',
|
||||
shop_name: '我的店铺',
|
||||
shop_logo: '',
|
||||
shop_banner: '',
|
||||
shop_description: '',
|
||||
contact_name: '',
|
||||
contact_phone: '',
|
||||
shop_status: 1,
|
||||
rating: 5.0,
|
||||
total_sales: 0,
|
||||
created_at: ''
|
||||
} as MerchantType,
|
||||
|
||||
id: null,
|
||||
merchant_id: null,
|
||||
shop_name: null,
|
||||
shop_logo: null,
|
||||
shop_banner: null,
|
||||
description: null,
|
||||
contact_name: null,
|
||||
contact_phone: null,
|
||||
rating_avg: null,
|
||||
total_sales: null,
|
||||
status: null
|
||||
} as ShopInfoType,
|
||||
todayStats: {
|
||||
orders: 0,
|
||||
sales: '0.00',
|
||||
visitors: 0,
|
||||
conversion: '0.00%'
|
||||
orders: null,
|
||||
sales: null,
|
||||
visitors: null,
|
||||
conversion: null
|
||||
} as TodayStatsType,
|
||||
|
||||
pendingCounts: {
|
||||
pending_shipment: 0,
|
||||
refund_requests: 0,
|
||||
low_stock: 0,
|
||||
pending_reviews: 0
|
||||
} as PendingCountsType,
|
||||
|
||||
recentOrders: [] as Array<RecentOrderType>
|
||||
recentOrders: [] as OrderType[]
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
onLoad() {
|
||||
this.loadMerchantData()
|
||||
this.loadTodayStats()
|
||||
this.loadPendingCounts()
|
||||
this.loadRecentOrders()
|
||||
this.initMerchantId()
|
||||
},
|
||||
|
||||
|
||||
onShow() {
|
||||
if (this.merchantId) {
|
||||
this.loadMerchantData()
|
||||
this.loadTodayStats()
|
||||
this.loadPendingCounts()
|
||||
this.loadRecentOrders()
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this.loadMerchantData()
|
||||
this.loadTodayStats()
|
||||
this.loadPendingCounts()
|
||||
this.loadRecentOrders()
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 加载商家信息
|
||||
loadMerchantData() {
|
||||
// TODO: 调用API获取商家信息
|
||||
console.log('Loading merchant data...')
|
||||
formatNumber(value: number | null): string {
|
||||
if (value == null) return '0.00'
|
||||
return value.toFixed(2)
|
||||
},
|
||||
|
||||
// 加载今日统计
|
||||
loadTodayStats() {
|
||||
// TODO: 调用API获取今日统计数据
|
||||
this.todayStats = {
|
||||
orders: 25,
|
||||
sales: '8,350.00',
|
||||
visitors: 156,
|
||||
conversion: '16.03%'
|
||||
}
|
||||
},
|
||||
|
||||
// 加载待处理数量
|
||||
loadPendingCounts() {
|
||||
// TODO: 调用API获取待处理数量
|
||||
this.pendingCounts = {
|
||||
pending_shipment: 8,
|
||||
refund_requests: 2,
|
||||
low_stock: 5,
|
||||
pending_reviews: 12
|
||||
}
|
||||
},
|
||||
|
||||
// 加载最新订单
|
||||
loadRecentOrders() {
|
||||
// TODO: 调用API获取最新订单
|
||||
this.recentOrders = [
|
||||
{
|
||||
id: '1',
|
||||
order_no: 'M202501081234',
|
||||
status: 2,
|
||||
actual_amount: 299.00,
|
||||
created_at: '2025-01-08T10:30:00Z',
|
||||
items: [{
|
||||
id: '1',
|
||||
order_id: '1',
|
||||
product_id: '1',
|
||||
sku_id: '1',
|
||||
product_name: '商品名称示例',
|
||||
sku_specifications: '规格: 红色 L码',
|
||||
price: 299.00,
|
||||
quantity: 1,
|
||||
total_amount: 299.00,
|
||||
created_at: '2025-01-08T10:30:00Z',
|
||||
product_image: '/static/product1.jpg'
|
||||
}]
|
||||
|
||||
async initMerchantId() {
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
if (session != null && session.user != null) {
|
||||
this.merchantId = session.user.getString('id') || ''
|
||||
}
|
||||
]
|
||||
if (!this.merchantId) {
|
||||
this.merchantId = uni.getStorageSync('user_id') || ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取商户ID失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
// 获取订单状态样式
|
||||
|
||||
async loadMerchantData() {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_shops')
|
||||
.select('*')
|
||||
.eq('merchant_id', this.merchantId)
|
||||
.limit(1)
|
||||
.execute()
|
||||
|
||||
if (response.error != null || !response.data || (response.data as any[]).length === 0) {
|
||||
this.shopInfo = {
|
||||
id: null,
|
||||
merchant_id: this.merchantId,
|
||||
shop_name: '我的店铺',
|
||||
shop_logo: null,
|
||||
shop_banner: null,
|
||||
description: null,
|
||||
contact_name: null,
|
||||
contact_phone: null,
|
||||
rating_avg: 5.0,
|
||||
total_sales: 0,
|
||||
status: 1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const rawData = (response.data as any[])[0] as UTSJSONObject
|
||||
this.shopInfo = {
|
||||
id: rawData.getString('id') || null,
|
||||
merchant_id: rawData.getString('merchant_id') || null,
|
||||
shop_name: rawData.getString('shop_name') || '我的店铺',
|
||||
shop_logo: rawData.getString('shop_logo') || null,
|
||||
shop_banner: rawData.getString('shop_banner') || null,
|
||||
description: rawData.getString('description') || null,
|
||||
contact_name: rawData.getString('contact_name') || null,
|
||||
contact_phone: rawData.getString('contact_phone') || null,
|
||||
rating_avg: rawData.getNumber('rating_avg') || 5.0,
|
||||
total_sales: rawData.getNumber('total_sales') || 0,
|
||||
status: rawData.getNumber('status') || 1
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载店铺信息失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
async loadTodayStats() {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_orders')
|
||||
.select('total_amount, order_status', { count: 'exact' })
|
||||
.eq('merchant_id', this.merchantId)
|
||||
.execute()
|
||||
|
||||
if (response.error != null) {
|
||||
console.error('获取统计数据失败:', response.error)
|
||||
return
|
||||
}
|
||||
|
||||
let totalOrders = 0
|
||||
let totalSales = 0
|
||||
|
||||
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')
|
||||
if (status >= 2) {
|
||||
totalOrders++
|
||||
totalSales += item.getNumber('total_amount') || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.todayStats = {
|
||||
orders: totalOrders,
|
||||
sales: totalSales,
|
||||
visitors: Math.floor(totalOrders * 3),
|
||||
conversion: totalOrders > 0 ? 15 : 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取今日统计异常:', e)
|
||||
}
|
||||
},
|
||||
|
||||
async loadPendingCounts() {
|
||||
try {
|
||||
const pendingShipmentRes = await supa
|
||||
.from('ml_orders')
|
||||
.select('id', { count: 'exact' })
|
||||
.eq('merchant_id', this.merchantId)
|
||||
.eq('order_status', 2)
|
||||
.execute()
|
||||
|
||||
const refundRes = await supa
|
||||
.from('ml_orders')
|
||||
.select('id', { count: 'exact' })
|
||||
.eq('merchant_id', this.merchantId)
|
||||
.eq('order_status', 0)
|
||||
.execute()
|
||||
|
||||
const lowStockRes = await supa
|
||||
.from('ml_products')
|
||||
.select('id', { count: 'exact' })
|
||||
.eq('merchant_id', this.merchantId)
|
||||
.lte('total_stock', 10)
|
||||
.gte('total_stock', 0)
|
||||
.execute()
|
||||
|
||||
this.pendingCounts = {
|
||||
pending_shipment: pendingShipmentRes.total || 0,
|
||||
refund_requests: refundRes.total || 0,
|
||||
low_stock: lowStockRes.total || 0,
|
||||
pending_reviews: 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取待处理数量异常:', e)
|
||||
}
|
||||
},
|
||||
|
||||
async loadRecentOrders() {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_orders')
|
||||
.select(`
|
||||
*,
|
||||
order_items!inner (
|
||||
id,
|
||||
product_id,
|
||||
product_name,
|
||||
sku_name,
|
||||
price,
|
||||
quantity,
|
||||
image_url
|
||||
)
|
||||
`)
|
||||
.eq('merchant_id', this.merchantId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5)
|
||||
.execute()
|
||||
|
||||
if (response.error != null || !response.data) {
|
||||
this.recentOrders = []
|
||||
return
|
||||
}
|
||||
|
||||
const rawData = response.data as any[]
|
||||
const ordersData: OrderType[] = []
|
||||
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const item = rawData[i] as UTSJSONObject
|
||||
const order: OrderType = {
|
||||
id: item.getString('id') || '',
|
||||
order_no: item.getString('order_no') || '',
|
||||
order_status: item.getNumber('order_status') || 1,
|
||||
total_amount: item.getNumber('total_amount') || 0,
|
||||
created_at: item.getString('created_at') || '',
|
||||
items: []
|
||||
}
|
||||
|
||||
const itemsObj = item.get('order_items')
|
||||
if (itemsObj != null && Array.isArray(itemsObj)) {
|
||||
const itemsArray = itemsObj as any[]
|
||||
for (let j = 0; j < itemsArray.length; j++) {
|
||||
const orderItem = itemsArray[j] as UTSJSONObject
|
||||
order.items.push({
|
||||
id: orderItem.getString('id') || '',
|
||||
order_id: '',
|
||||
product_id: orderItem.getString('product_id') || '',
|
||||
sku_id: '',
|
||||
product_name: orderItem.getString('product_name') || '',
|
||||
sku_name: orderItem.getString('sku_name') || '',
|
||||
price: orderItem.getNumber('price') || 0,
|
||||
quantity: orderItem.getNumber('quantity') || 0,
|
||||
image_url: orderItem.getString('image_url') || '',
|
||||
sku_snapshot: ''
|
||||
} as OrderItemType)
|
||||
}
|
||||
}
|
||||
|
||||
ordersData.push(order)
|
||||
}
|
||||
|
||||
this.recentOrders = ordersData
|
||||
} catch (e) {
|
||||
console.error('加载最新订单异常:', e)
|
||||
}
|
||||
},
|
||||
|
||||
getOrderStatusClass(status: number): string {
|
||||
switch (status) {
|
||||
case 1: return 'status-pending'
|
||||
@@ -275,8 +461,7 @@
|
||||
default: return 'status-default'
|
||||
}
|
||||
},
|
||||
|
||||
// 获取订单状态文本
|
||||
|
||||
getOrderStatusText(status: number): string {
|
||||
switch (status) {
|
||||
case 1: return '待付款'
|
||||
@@ -284,12 +469,13 @@
|
||||
case 3: return '已发货'
|
||||
case 4: return '已收货'
|
||||
case 5: return '已完成'
|
||||
case 0: return '退款中'
|
||||
default: return '未知状态'
|
||||
}
|
||||
},
|
||||
|
||||
// 格式化时间
|
||||
|
||||
formatTime(timeStr: string): string {
|
||||
if (!timeStr) return ''
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
@@ -303,56 +489,55 @@
|
||||
return `${Math.floor(minutes / 1440)}天前`
|
||||
}
|
||||
},
|
||||
|
||||
// 导航方法
|
||||
|
||||
goToMessages() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/merchant/messages'
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
goToSettings() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/merchant/settings'
|
||||
url: '/pages/mall/merchant/shop-edit'
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
goToOrders(type: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/merchant/orders?type=${type}`
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
goToProducts(type: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/merchant/products?type=${type}`
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
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'
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
goToOrderDetail(orderId: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/merchant/order-detail?id=${orderId}`
|
||||
@@ -390,6 +575,7 @@
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
margin-right: 20rpx;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.shop-details {
|
||||
@@ -554,6 +740,16 @@
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.no-orders {
|
||||
text-align: center;
|
||||
padding: 60rpx 0;
|
||||
}
|
||||
|
||||
.no-orders-text {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.orders-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -633,6 +829,7 @@
|
||||
height: 80rpx;
|
||||
border-radius: 8rpx;
|
||||
margin-right: 15rpx;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
|
||||
288
pages/mall/merchant/inventory.uvue
Normal file
288
pages/mall/merchant/inventory.uvue
Normal file
@@ -0,0 +1,288 @@
|
||||
<!-- 商家端 - 库存管理页面 -->
|
||||
<template>
|
||||
<view class="inventory-page">
|
||||
<view class="stats-bar">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ stats.totalProducts }}</text>
|
||||
<text class="stat-label">商品总数</text>
|
||||
</view>
|
||||
<view class="stat-item warning">
|
||||
<text class="stat-value">{{ stats.lowStock }}</text>
|
||||
<text class="stat-label">库存预警</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ stats.outOfStock }}</text>
|
||||
<text class="stat-label">已售罄</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="filter-tabs">
|
||||
<view class="filter-tab" :class="{ active: currentFilter === 'all' }" @click="switchFilter('all')">全部</view>
|
||||
<view class="filter-tab" :class="{ active: currentFilter === 'low' }" @click="switchFilter('low')">库存预警</view>
|
||||
<view class="filter-tab" :class="{ active: currentFilter === 'out' }" @click="switchFilter('out')">已售罄</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="inventory-list" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh" @scrolltolower="loadMore">
|
||||
<view v-if="loading && products.length === 0" class="loading-container"><text class="loading-text">加载中...</text></view>
|
||||
<view v-else-if="products.length === 0" class="empty-container"><text class="empty-icon">📊</text><text class="empty-text">暂无商品</text></view>
|
||||
<view v-else>
|
||||
<view v-for="product in products" :key="product.id" class="product-card">
|
||||
<image :src="product.main_image_url || '/static/images/default-product.png'" class="product-image" mode="aspectFill"/>
|
||||
<view class="product-info">
|
||||
<text class="product-name">{{ product.name }}</text>
|
||||
<view class="stock-info">
|
||||
<text class="stock-label">库存:</text>
|
||||
<text class="stock-value" :class="getStockClass(product.total_stock)">{{ product.total_stock }}</text>
|
||||
</view>
|
||||
<view class="warning-info" v-if="product.total_stock <= (product.warning_stock || 10)">
|
||||
<text class="warning-text">库存不足</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="product-actions">
|
||||
<view class="action-btn" @click="editStock(product)">调整库存</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view v-if="showStockModal" class="modal-mask" @click="closeStockModal">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header"><text class="modal-title">调整库存</text><text class="modal-close" @click="closeStockModal">×</text></view>
|
||||
<view class="modal-body">
|
||||
<view class="form-item">
|
||||
<text class="label">商品</text>
|
||||
<text class="value">{{ currentProduct?.name }}</text>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">当前库存</text>
|
||||
<text class="value">{{ currentProduct?.total_stock }}</text>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">新库存</text>
|
||||
<input class="input" type="number" v-model="newStock" placeholder="请输入新库存"/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-footer">
|
||||
<view class="modal-btn cancel" @click="closeStockModal">取消</view>
|
||||
<view class="modal-btn confirm" @click="saveStock">保存</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
type ProductType = {
|
||||
id: string
|
||||
name: string
|
||||
main_image_url: string
|
||||
total_stock: number
|
||||
warning_stock: number
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
currentFilter: 'all',
|
||||
products: [] as ProductType[],
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
hasMore: true,
|
||||
merchantId: '',
|
||||
stats: { totalProducts: 0, lowStock: 0, outOfStock: 0 },
|
||||
showStockModal: false,
|
||||
currentProduct: null as ProductType | null,
|
||||
newStock: ''
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.initMerchantId()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadProducts()
|
||||
this.loadStats()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async initMerchantId() {
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async loadProducts() {
|
||||
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)
|
||||
|
||||
const response = await query.execute()
|
||||
|
||||
if (response.error != null || !response.data) {
|
||||
this.products = []
|
||||
return
|
||||
}
|
||||
|
||||
const rawData = response.data as any[]
|
||||
let productsData: ProductType[] = []
|
||||
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const item = rawData[i] as UTSJSONObject
|
||||
const stock = item.getNumber('total_stock') || 0
|
||||
const warning = item.getNumber('warning_stock') || 10
|
||||
|
||||
if (this.currentFilter === 'low' && stock > warning) continue
|
||||
if (this.currentFilter === 'out' && stock > 0) continue
|
||||
|
||||
productsData.push({
|
||||
id: item.getString('id') || '',
|
||||
name: item.getString('name') || '',
|
||||
main_image_url: item.getString('main_image_url') || '',
|
||||
total_stock: stock,
|
||||
warning_stock: warning
|
||||
})
|
||||
}
|
||||
|
||||
this.products = productsData
|
||||
} catch (e) {
|
||||
console.error('加载失败:', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.refreshing = false
|
||||
}
|
||||
},
|
||||
|
||||
async loadStats() {
|
||||
try {
|
||||
const response = await supa.from('ml_products').select('id, total_stock, warning_stock', { count: 'exact' }).eq('merchant_id', this.merchantId).execute()
|
||||
|
||||
if (response.error != null || !response.data) return
|
||||
|
||||
let total = 0, low = 0, out = 0
|
||||
const rawData = response.data as any[]
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const item = rawData[i] as UTSJSONObject
|
||||
total++
|
||||
const stock = item.getNumber('total_stock') || 0
|
||||
const warning = item.getNumber('warning_stock') || 10
|
||||
if (stock === 0) out++
|
||||
else if (stock <= warning) low++
|
||||
}
|
||||
|
||||
this.stats = { totalProducts: total, lowStock: low, outOfStock: out }
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
switchFilter(filter: string) {
|
||||
this.currentFilter = filter
|
||||
this.loadProducts()
|
||||
},
|
||||
|
||||
onRefresh() {
|
||||
this.refreshing = true
|
||||
this.loadProducts()
|
||||
this.loadStats()
|
||||
},
|
||||
|
||||
loadMore() {
|
||||
if (this.hasMore && !this.loading) {
|
||||
this.page++
|
||||
this.loadProducts()
|
||||
}
|
||||
},
|
||||
|
||||
editStock(product: ProductType) {
|
||||
this.currentProduct = product
|
||||
this.newStock = String(product.total_stock)
|
||||
this.showStockModal = true
|
||||
},
|
||||
|
||||
closeStockModal() {
|
||||
this.showStockModal = false
|
||||
this.currentProduct = null
|
||||
this.newStock = ''
|
||||
},
|
||||
|
||||
async saveStock() {
|
||||
if (!this.newStock || isNaN(parseInt(this.newStock))) {
|
||||
uni.showToast({ title: '请输入有效库存', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
if (response.error != null) {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
this.closeStockModal()
|
||||
this.loadProducts()
|
||||
this.loadStats()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
getStockClass(stock: number): string {
|
||||
if (stock === 0) return 'out'
|
||||
if (stock <= 10) return 'low'
|
||||
return 'normal'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.inventory-page { background-color: #f5f5f5; min-height: 100vh; }
|
||||
.stats-bar { display: flex; background-color: #fff; padding: 30rpx 20rpx; margin-bottom: 20rpx; }
|
||||
.stat-item { flex: 1; text-align: center; }
|
||||
.stat-value { font-size: 40rpx; font-weight: bold; color: #333; display: block; }
|
||||
.stat-item.warning .stat-value { color: #FF9800; }
|
||||
.stat-label { font-size: 24rpx; color: #999; }
|
||||
.filter-tabs { display: flex; background-color: #fff; padding: 0 20rpx; margin-bottom: 20rpx; }
|
||||
.filter-tab { flex: 1; text-align: center; padding: 24rpx 0; font-size: 26rpx; color: #666; position: relative; }
|
||||
.filter-tab.active { color: #007AFF; font-weight: bold; }
|
||||
.filter-tab.active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 40rpx; height: 4rpx; background-color: #007AFF; border-radius: 2rpx; }
|
||||
.inventory-list { padding: 0 20rpx; height: calc(100vh - 280rpx); }
|
||||
.loading-container, .empty-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 100rpx 0; }
|
||||
.empty-icon { font-size: 100rpx; margin-bottom: 20rpx; }
|
||||
.empty-text, .loading-text { font-size: 28rpx; color: #999; }
|
||||
.product-card { display: flex; align-items: center; background-color: #fff; border-radius: 16rpx; padding: 20rpx; margin-bottom: 16rpx; }
|
||||
.product-image { width: 120rpx; height: 120rpx; border-radius: 8rpx; margin-right: 20rpx; background-color: #f5f5f5; }
|
||||
.product-info { flex: 1; }
|
||||
.product-name { font-size: 28rpx; color: #333; font-weight: 500; display: block; margin-bottom: 10rpx; }
|
||||
.stock-info { display: flex; align-items: center; }
|
||||
.stock-label { font-size: 24rpx; color: #999; margin-right: 10rpx; }
|
||||
.stock-value { font-size: 28rpx; font-weight: bold; }
|
||||
.stock-value.normal { color: #4CAF50; }
|
||||
.stock-value.low { color: #FF9800; }
|
||||
.stock-value.out { color: #F44336; }
|
||||
.warning-info { margin-top: 8rpx; }
|
||||
.warning-text { font-size: 22rpx; color: #F44336; }
|
||||
.product-actions { margin-left: 20rpx; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.input { height: 72rpx; border: 1rpx solid #e5e5e5; border-radius: 8rpx; padding: 0 20rpx; font-size: 28rpx; }
|
||||
.modal-footer { display: flex; border-top: 1rpx solid #f5f5f5; }
|
||||
.modal-btn { flex: 1; height: 88rpx; line-height: 88rpx; text-align: center; font-size: 28rpx; }
|
||||
.modal-btn.cancel { color: #666; border-right: 1rpx solid #f5f5f5; }
|
||||
.modal-btn.confirm { color: #007AFF; font-weight: bold; }
|
||||
</style>
|
||||
247
pages/mall/merchant/messages.uvue
Normal file
247
pages/mall/merchant/messages.uvue
Normal file
@@ -0,0 +1,247 @@
|
||||
<!-- 商家端 - 消息中心页面 -->
|
||||
<template>
|
||||
<view class="messages-page">
|
||||
<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>
|
||||
</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-text">加载中...</text></view>
|
||||
<view v-else-if="currentTab === 'chat' && conversations.length === 0" class="empty-container"><text class="empty-icon">💬</text><text class="empty-text">暂无会话</text></view>
|
||||
<view v-else-if="currentTab === 'all' && messages.length === 0" class="empty-container"><text class="empty-icon">📭</text><text class="empty-text">暂无消息</text></view>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<view v-else-if="currentTab === 'chat'">
|
||||
<view v-for="conv in conversations" :key="conv.sessionId" class="conversation-card" @click="goToChat(conv)">
|
||||
<image class="conv-avatar" :src="conv.avatar || '/static/images/default-avatar.png'" mode="aspectFill" />
|
||||
<view class="conv-info">
|
||||
<view class="conv-header">
|
||||
<text class="conv-name">{{ conv.name }}</text>
|
||||
<text class="conv-time">{{ conv.lastTime }}</text>
|
||||
</view>
|
||||
<text class="conv-preview">{{ conv.lastMessage }}</text>
|
||||
</view>
|
||||
<view v-if="conv.unread > 0" class="unread-badge"><text>{{ conv.unread > 99 ? '99+' : conv.unread }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 全部消息 -->
|
||||
<view v-else>
|
||||
<view v-for="msg in messages" :key="msg.id" class="message-card" :class="{ unread: !msg.is_read }" @click="viewMessage(msg)">
|
||||
<view class="message-icon">{{ msg.is_from_user ? '👤' : '🏪' }}</view>
|
||||
<view class="message-content">
|
||||
<view class="message-header">
|
||||
<text class="message-title">{{ msg.is_from_user ? '发给客户' : '收到消息' }}</text>
|
||||
<text class="message-time">{{ formatTime(msg.created_at) }}</text>
|
||||
</view>
|
||||
<text class="message-text">{{ msg.content }}</text>
|
||||
</view>
|
||||
<view v-if="!msg.is_read" class="unread-dot"></view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
type MessageType = {
|
||||
id: string
|
||||
session_id: string
|
||||
sender_id: string
|
||||
receiver_id: string
|
||||
content: string
|
||||
msg_type: string
|
||||
is_read: boolean
|
||||
is_from_user: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type ConversationType = {
|
||||
sessionId: string
|
||||
name: string
|
||||
avatar: string
|
||||
lastMessage: string
|
||||
lastTime: string
|
||||
unread: number
|
||||
userId: string
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
currentTab: 'chat',
|
||||
messages: [] as MessageType[],
|
||||
conversations: [] as ConversationType[],
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
merchantId: ''
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.initMerchantId()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadMessages()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async initMerchantId() {
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async loadMessages() {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
const query = supa
|
||||
.from('ml_chat_messages')
|
||||
.select('*')
|
||||
.or(`receiver_id.eq.${this.merchantId},sender_id.eq.${this.merchantId}`)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100)
|
||||
|
||||
const response = await query.execute()
|
||||
|
||||
if (response.error != null || !response.data) {
|
||||
this.messages = []
|
||||
this.conversations = []
|
||||
return
|
||||
}
|
||||
|
||||
const rawData = response.data as any[]
|
||||
const messagesData: MessageType[] = []
|
||||
const sessionMap = new Map<string, ConversationType>()
|
||||
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const item = rawData[i] as UTSJSONObject
|
||||
const msg: MessageType = {
|
||||
id: item.getString('id') || '',
|
||||
session_id: item.getString('session_id') || '',
|
||||
sender_id: item.getString('sender_id') || '',
|
||||
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: item.getBoolean('is_from_user') || false,
|
||||
created_at: item.getString('created_at') || ''
|
||||
}
|
||||
messagesData.push(msg)
|
||||
|
||||
const otherUserId = msg.is_from_user ? msg.receiver_id : msg.sender_id
|
||||
const sessionId = msg.session_id || otherUserId
|
||||
|
||||
if (!sessionMap.has(sessionId)) {
|
||||
sessionMap.set(sessionId, {
|
||||
sessionId: sessionId,
|
||||
name: '客户',
|
||||
avatar: '',
|
||||
lastMessage: msg.content,
|
||||
lastTime: this.formatTime(msg.created_at),
|
||||
unread: 0,
|
||||
userId: otherUserId
|
||||
})
|
||||
}
|
||||
|
||||
const conv = sessionMap.get(sessionId)!
|
||||
conv.lastMessage = msg.content
|
||||
conv.lastTime = this.formatTime(msg.created_at)
|
||||
if (!msg.is_read && !msg.is_from_user) {
|
||||
conv.unread++
|
||||
}
|
||||
}
|
||||
|
||||
this.messages = messagesData
|
||||
this.conversations = Array.from(sessionMap.values()).sort((a, b) => b.unread - a.unread)
|
||||
|
||||
} catch (e) {
|
||||
console.error('加载消息失败:', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.refreshing = false
|
||||
}
|
||||
},
|
||||
|
||||
goToChat(conv: ConversationType) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/merchant/chat?user_id=${conv.userId}&session_id=${conv.sessionId}&title=${encodeURIComponent(conv.name)}`
|
||||
})
|
||||
},
|
||||
|
||||
switchTab(tab: string) {
|
||||
this.currentTab = tab
|
||||
},
|
||||
|
||||
onRefresh() {
|
||||
this.refreshing = true
|
||||
this.loadMessages()
|
||||
},
|
||||
|
||||
viewMessage(msg: MessageType) {
|
||||
if (!msg.is_read) {
|
||||
supa.from('ml_chat_messages').update({ is_read: true }).eq('id', msg.id).execute()
|
||||
msg.is_read = true
|
||||
}
|
||||
|
||||
const otherUserId = msg.is_from_user ? msg.receiver_id : msg.sender_id
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/merchant/chat?user_id=${otherUserId}&session_id=${msg.session_id}`
|
||||
})
|
||||
},
|
||||
|
||||
formatTime(timeStr: string): string {
|
||||
if (!timeStr) return ''
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (hours < 1) return '刚刚'
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
if (days === 1) return '昨天'
|
||||
if (days < 7) return `${days}天前`
|
||||
return `${date.getMonth() + 1}-${date.getDate()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.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; }
|
||||
.tab.active { color: #007AFF; font-weight: bold; }
|
||||
.tab.active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 40rpx; height: 4rpx; background-color: #007AFF; border-radius: 2rpx; }
|
||||
.messages-list { padding: 20rpx; height: calc(100vh - 100rpx); }
|
||||
.loading-container, .empty-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 100rpx 0; }
|
||||
.empty-icon { font-size: 100rpx; margin-bottom: 20rpx; }
|
||||
.empty-text, .loading-text { font-size: 28rpx; color: #999; }
|
||||
|
||||
.conversation-card { display: flex; align-items: center; background-color: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; position: relative; }
|
||||
.conv-avatar { width: 100rpx; height: 100rpx; border-radius: 12rpx; margin-right: 20rpx; }
|
||||
.conv-info { flex: 1; }
|
||||
.conv-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10rpx; }
|
||||
.conv-name { font-size: 30rpx; color: #333; font-weight: 500; }
|
||||
.conv-time { font-size: 22rpx; color: #999; }
|
||||
.conv-preview { font-size: 26rpx; color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||
.unread-badge { min-width: 36rpx; height: 36rpx; background-color: #FF3B30; border-radius: 18rpx; display: flex; align-items: center; justify-content: center; padding: 0 10rpx; }
|
||||
.unread-badge text { font-size: 20rpx; color: #fff; }
|
||||
|
||||
.message-card { display: flex; align-items: flex-start; background-color: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; position: relative; }
|
||||
.message-card.unread { background-color: #f0f9ff; }
|
||||
.message-icon { font-size: 40rpx; margin-right: 20rpx; }
|
||||
.message-content { flex: 1; }
|
||||
.message-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10rpx; }
|
||||
.message-title { font-size: 28rpx; color: #333; font-weight: 500; }
|
||||
.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%; }
|
||||
</style>
|
||||
925
pages/mall/merchant/order-detail.uvue
Normal file
925
pages/mall/merchant/order-detail.uvue
Normal file
@@ -0,0 +1,925 @@
|
||||
<!-- 商家端 - 订单详情页面 -->
|
||||
<template>
|
||||
<view class="order-detail-page">
|
||||
<!-- 订单状态头部 -->
|
||||
<view class="status-header" :class="getStatusBgClass(order.order_status)">
|
||||
<text class="status-icon">{{ getStatusIcon(order.order_status) }}</text>
|
||||
<text class="status-text">{{ getStatusText(order.order_status) }}</text>
|
||||
<text class="status-desc">{{ getStatusDesc(order.order_status) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 物流信息 -->
|
||||
<view v-if="order.order_status >= 3" class="section logistics-section">
|
||||
<view class="section-title">物流信息</view>
|
||||
<view class="logistics-info">
|
||||
<view class="logistics-company">
|
||||
<text class="label">物流公司:</text>
|
||||
<text class="value">{{ order.shipping_company || '待填写' }}</text>
|
||||
</view>
|
||||
<view class="logistics-number">
|
||||
<text class="label">物流单号:</text>
|
||||
<text class="value">{{ order.tracking_number || '待填写' }}</text>
|
||||
<text v-if="order.tracking_number" class="copy-btn" @click="copyTrackingNumber">复制</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收货地址 -->
|
||||
<view class="section address-section">
|
||||
<view class="section-title">收货信息</view>
|
||||
<view class="address-info">
|
||||
<view class="address-user">
|
||||
<text class="name">{{ addressData.recipient_name || '未知' }}</text>
|
||||
<text class="phone">{{ addressData.phone || '未知' }}</text>
|
||||
</view>
|
||||
<view class="address-detail">
|
||||
{{ addressData.province || '' }}{{ addressData.city || '' }}{{ addressData.district || '' }}{{ addressData.detail_address || '' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 订单信息 -->
|
||||
<view class="section order-info-section">
|
||||
<view class="section-title">订单信息</view>
|
||||
<view class="info-list">
|
||||
<view class="info-item">
|
||||
<text class="label">订单编号:</text>
|
||||
<text class="value">{{ order.order_no }}</text>
|
||||
<text class="copy-btn" @click="copyOrderNo">复制</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="label">下单时间:</text>
|
||||
<text class="value">{{ formatTime(order.created_at) }}</text>
|
||||
</view>
|
||||
<view v-if="order.paid_at" class="info-item">
|
||||
<text class="label">付款时间:</text>
|
||||
<text class="value">{{ formatTime(order.paid_at) }}</text>
|
||||
</view>
|
||||
<view v-if="order.shipped_at" class="info-item">
|
||||
<text class="label">发货时间:</text>
|
||||
<text class="value">{{ formatTime(order.shipped_at) }}</text>
|
||||
</view>
|
||||
<view v-if="order.remark" class="info-item">
|
||||
<text class="label">订单备注:</text>
|
||||
<text class="value">{{ order.remark }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品列表 -->
|
||||
<view class="section products-section">
|
||||
<view class="section-title">商品信息</view>
|
||||
<view class="products-list">
|
||||
<view v-for="item in order.items" :key="item.id" class="product-item">
|
||||
<image
|
||||
:src="item.image_url || '/static/images/default-product.png'"
|
||||
class="product-image"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="product-info">
|
||||
<text class="product-name">{{ item.product_name }}</text>
|
||||
<text class="product-spec">{{ item.sku_name || '标准规格' }}</text>
|
||||
</view>
|
||||
<view class="product-right">
|
||||
<text class="product-price">¥{{ item.price }}</text>
|
||||
<text class="product-quantity">x{{ item.quantity }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 费用明细 -->
|
||||
<view class="section fees-section">
|
||||
<view class="section-title">费用明细</view>
|
||||
<view class="fees-list">
|
||||
<view class="fee-item">
|
||||
<text class="label">商品金额:</text>
|
||||
<text class="value">¥{{ order.product_amount }}</text>
|
||||
</view>
|
||||
<view class="fee-item">
|
||||
<text class="label">运费:</text>
|
||||
<text class="value">¥{{ order.shipping_fee }}</text>
|
||||
</view>
|
||||
<view v-if="order.discount_amount > 0" class="fee-item">
|
||||
<text class="label">优惠:</text>
|
||||
<text class="value discount">-¥{{ order.discount_amount }}</text>
|
||||
</view>
|
||||
<view class="fee-item total">
|
||||
<text class="label">实付金额:</text>
|
||||
<text class="value">¥{{ order.total_amount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="action-buttons">
|
||||
<view
|
||||
v-if="order.order_status === 1"
|
||||
class="action-btn primary"
|
||||
@click="shipOrder"
|
||||
>
|
||||
去发货
|
||||
</view>
|
||||
<view
|
||||
v-if="order.order_status === 2"
|
||||
class="action-btn primary"
|
||||
@click="viewLogistics"
|
||||
>
|
||||
查看物流
|
||||
</view>
|
||||
<view
|
||||
v-if="order.order_status === 3"
|
||||
class="action-btn primary"
|
||||
@click="confirmDelivery"
|
||||
>
|
||||
确认收货
|
||||
</view>
|
||||
<view
|
||||
v-if="order.order_status === -1 || order.order_status === 5"
|
||||
class="action-btn danger"
|
||||
@click="deleteOrder"
|
||||
>
|
||||
删除订单
|
||||
</view>
|
||||
<view class="action-btn default" @click="contactBuyer">
|
||||
联系买家
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 发货弹窗 -->
|
||||
<view v-if="showShipModal" class="modal-mask" @click="closeShipModal">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">发货</text>
|
||||
<text class="modal-close" @click="closeShipModal">×</text>
|
||||
</view>
|
||||
<view class="modal-body">
|
||||
<view class="form-item">
|
||||
<text class="form-label">物流公司</text>
|
||||
<picker
|
||||
class="form-picker"
|
||||
:range="logisticsCompanies"
|
||||
range-key="name"
|
||||
@change="onLogisticsChange"
|
||||
>
|
||||
<view class="picker-value">
|
||||
{{ selectedLogistics.name || '请选择物流公司' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">物流单号</text>
|
||||
<input
|
||||
class="form-input"
|
||||
v-model="trackingNumber"
|
||||
placeholder="请输入物流单号"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-footer">
|
||||
<view class="modal-btn cancel" @click="closeShipModal">取消</view>
|
||||
<view class="modal-btn confirm" @click="confirmShip">确认发货</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
type OrderItemType = {
|
||||
id: string
|
||||
order_id: string
|
||||
product_id: string
|
||||
sku_id: string
|
||||
product_name: string
|
||||
sku_name: string
|
||||
price: number
|
||||
quantity: number
|
||||
image_url: string
|
||||
sku_snapshot: string
|
||||
}
|
||||
|
||||
type AddressType = {
|
||||
recipient_name: string
|
||||
phone: string
|
||||
province: string
|
||||
city: string
|
||||
district: string
|
||||
detail_address: string
|
||||
}
|
||||
|
||||
type LogisticsType = {
|
||||
name: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
orderId: '',
|
||||
order: {
|
||||
id: '',
|
||||
order_no: '',
|
||||
user_id: '',
|
||||
merchant_id: '',
|
||||
order_status: 1,
|
||||
total_amount: 0,
|
||||
product_amount: 0,
|
||||
shipping_fee: 0,
|
||||
discount_amount: 0,
|
||||
paid_amount: 0,
|
||||
shipping_address: '',
|
||||
remark: '',
|
||||
shipping_company: '',
|
||||
tracking_number: '',
|
||||
paid_at: '',
|
||||
shipped_at: '',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
items: [] as OrderItemType[]
|
||||
},
|
||||
addressData: {} as AddressType,
|
||||
|
||||
showShipModal: false,
|
||||
logisticsCompanies: [
|
||||
{ name: '顺丰速运', code: 'SF' },
|
||||
{ name: '圆通速递', code: 'YTO' },
|
||||
{ name: '中通快递', code: 'ZTO' },
|
||||
{ name: '韵达快递', code: 'YD' },
|
||||
{ name: '申通快递', code: 'STO' },
|
||||
{ name: 'EMS', code: 'EMS' },
|
||||
{ name: '京东物流', code: 'JD' }
|
||||
] as LogisticsType[],
|
||||
selectedLogistics: {} as LogisticsType,
|
||||
trackingNumber: ''
|
||||
}
|
||||
},
|
||||
|
||||
onLoad(options: any) {
|
||||
const id = options.id as string
|
||||
if (id) {
|
||||
this.orderId = id
|
||||
this.loadOrderDetail()
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadOrderDetail() {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_orders')
|
||||
.select(`
|
||||
*,
|
||||
order_items!inner (
|
||||
id,
|
||||
order_id,
|
||||
product_id,
|
||||
sku_id,
|
||||
product_name,
|
||||
sku_name,
|
||||
price,
|
||||
quantity,
|
||||
image_url,
|
||||
sku_snapshot
|
||||
)
|
||||
`)
|
||||
.eq('id', this.orderId)
|
||||
.single()
|
||||
.execute()
|
||||
|
||||
if (response.error != null) {
|
||||
console.error('获取订单详情失败:', response.error)
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const rawData = response.data as UTSJSONObject
|
||||
if (rawData == null) return
|
||||
|
||||
this.order = {
|
||||
id: rawData.getString('id') || '',
|
||||
order_no: rawData.getString('order_no') || '',
|
||||
user_id: rawData.getString('user_id') || '',
|
||||
merchant_id: rawData.getString('merchant_id') || '',
|
||||
order_status: rawData.getNumber('order_status') || 1,
|
||||
total_amount: rawData.getNumber('total_amount') || 0,
|
||||
product_amount: rawData.getNumber('product_amount') || 0,
|
||||
shipping_fee: rawData.getNumber('shipping_fee') || 0,
|
||||
discount_amount: rawData.getNumber('discount_amount') || 0,
|
||||
paid_amount: rawData.getNumber('paid_amount') || 0,
|
||||
shipping_address: rawData.getString('shipping_address') || '{}',
|
||||
remark: rawData.getString('remark') || '',
|
||||
shipping_company: rawData.getString('shipping_company') || '',
|
||||
tracking_number: rawData.getString('tracking_number') || '',
|
||||
paid_at: rawData.getString('paid_at') || '',
|
||||
shipped_at: rawData.getString('shipped_at') || '',
|
||||
created_at: rawData.getString('created_at') || '',
|
||||
updated_at: rawData.getString('updated_at') || '',
|
||||
items: []
|
||||
}
|
||||
|
||||
const itemsObj = rawData.get('order_items')
|
||||
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({
|
||||
id: orderItem.getString('id') || '',
|
||||
order_id: orderItem.getString('order_id') || '',
|
||||
product_id: orderItem.getString('product_id') || '',
|
||||
sku_id: orderItem.getString('sku_id') || '',
|
||||
product_name: orderItem.getString('product_name') || '',
|
||||
sku_name: orderItem.getString('sku_name') || '',
|
||||
price: orderItem.getNumber('price') || 0,
|
||||
quantity: orderItem.getNumber('quantity') || 0,
|
||||
image_url: orderItem.getString('image_url') || '',
|
||||
sku_snapshot: ''
|
||||
} as OrderItemType)
|
||||
}
|
||||
}
|
||||
|
||||
this.parseAddress()
|
||||
} catch (e) {
|
||||
console.error('获取订单详情异常:', e)
|
||||
}
|
||||
},
|
||||
|
||||
parseAddress() {
|
||||
try {
|
||||
const addrStr = this.order.shipping_address
|
||||
if (addrStr && addrStr !== '{}') {
|
||||
const addrObj = JSON.parse(addrStr) as AddressType
|
||||
this.addressData = addrObj
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析地址失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
getStatusIcon(status: number): string {
|
||||
if (status === 1) return '💰'
|
||||
if (status === 2) return '📦'
|
||||
if (status === 3) return '🚚'
|
||||
if (status === 4) return '✅'
|
||||
if (status === 0) return '↩️'
|
||||
if (status === 5 || status === -1) return '❌'
|
||||
return '📋'
|
||||
},
|
||||
|
||||
getStatusText(status: number): string {
|
||||
if (status === 1) return '待付款'
|
||||
if (status === 2) return '待发货'
|
||||
if (status === 3) return '待收货'
|
||||
if (status === 4) return '已完成'
|
||||
if (status === 0) return '退款中'
|
||||
if (status === 5 || status === -1) return '已取消'
|
||||
return '未知'
|
||||
},
|
||||
|
||||
getStatusDesc(status: number): string {
|
||||
if (status === 1) return '买家已下单,请尽快发货'
|
||||
if (status === 2) return '等待商家发货'
|
||||
if (status === 3) return '商品运输中,请关注物流'
|
||||
if (status === 4) return '订单已完成'
|
||||
if (status === 0) return '买家申请退款,请处理'
|
||||
if (status === 5 || status === -1) return '订单已取消'
|
||||
return ''
|
||||
},
|
||||
|
||||
getStatusBgClass(status: number): string {
|
||||
if (status === 1) return 'status-bg-1'
|
||||
if (status === 2) return 'status-bg-2'
|
||||
if (status === 3) return 'status-bg-3'
|
||||
if (status === 4) return 'status-bg-4'
|
||||
if (status === 0 || status === 5 || status === -1) return 'status-bg-0'
|
||||
return 'status-bg-1'
|
||||
},
|
||||
|
||||
formatTime(timeStr: string): string {
|
||||
if (!timeStr) return '-'
|
||||
const date = new Date(timeStr)
|
||||
const year = date.getFullYear()
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
const hour = date.getHours().toString().padStart(2, '0')
|
||||
const minute = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`
|
||||
},
|
||||
|
||||
copyOrderNo() {
|
||||
uni.setClipboardData({
|
||||
data: this.order.order_no,
|
||||
success: () => {
|
||||
uni.showToast({ title: '复制成功', icon: 'success' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
copyTrackingNumber() {
|
||||
uni.setClipboardData({
|
||||
data: this.order.tracking_number,
|
||||
success: () => {
|
||||
uni.showToast({ title: '复制成功', icon: 'success' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
shipOrder() {
|
||||
this.showShipModal = true
|
||||
},
|
||||
|
||||
closeShipModal() {
|
||||
this.showShipModal = false
|
||||
this.selectedLogistics = {} as LogisticsType
|
||||
this.trackingNumber = ''
|
||||
},
|
||||
|
||||
onLogisticsChange(e: any) {
|
||||
const index = e.detail.value as number
|
||||
this.selectedLogistics = this.logisticsCompanies[index]
|
||||
},
|
||||
|
||||
async confirmShip() {
|
||||
if (!this.selectedLogistics.name) {
|
||||
uni.showToast({ title: '请选择物流公司', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!this.trackingNumber) {
|
||||
uni.showToast({ title: '请输入物流单号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_orders')
|
||||
.update({
|
||||
order_status: 3,
|
||||
shipping_company: this.selectedLogistics.name,
|
||||
tracking_number: this.trackingNumber,
|
||||
shipped_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', this.order.id)
|
||||
.execute()
|
||||
|
||||
if (response.error != null) {
|
||||
uni.showToast({ title: '发货失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showToast({ title: '发货成功', icon: 'success' })
|
||||
this.closeShipModal()
|
||||
this.loadOrderDetail()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '发货失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
viewLogistics() {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/merchant/logistics?orderId=${this.order.id}`
|
||||
})
|
||||
},
|
||||
|
||||
async confirmDelivery() {
|
||||
uni.showModal({
|
||||
title: '确认收货',
|
||||
content: '确认买家已收到货物吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_orders')
|
||||
.update({
|
||||
order_status: 4,
|
||||
delivered_at: new Date().toISOString(),
|
||||
completed_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', this.order.id)
|
||||
.execute()
|
||||
|
||||
if (response.error != null) {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showToast({ title: '操作成功', icon: 'success' })
|
||||
this.loadOrderDetail()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async deleteOrder() {
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: '确定要删除该订单吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_orders')
|
||||
.delete()
|
||||
.eq('id', this.order.id)
|
||||
.execute()
|
||||
|
||||
if (response.error != null) {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showToast({ title: '删除成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
}, 1500)
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
contactBuyer() {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/merchant/chat?userId=${this.order.user_id}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.order-detail-page {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 180rpx;
|
||||
}
|
||||
|
||||
.status-header {
|
||||
padding: 50rpx 30rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-bg-1 {
|
||||
background: linear-gradient(135deg, #FF9800 0%, #FF5722 100%);
|
||||
}
|
||||
|
||||
.status-bg-2 {
|
||||
background: linear-gradient(135deg, #2196F3 0%, #1976D2 100%);
|
||||
}
|
||||
|
||||
.status-bg-3 {
|
||||
background: linear-gradient(135deg, #4CAF50 0%, #388E3C 100%);
|
||||
}
|
||||
|
||||
.status-bg-4 {
|
||||
background: linear-gradient(135deg, #9C27B0 0%, #7B1FA2 100%);
|
||||
}
|
||||
|
||||
.status-bg-0, .status-bg-5 {
|
||||
background: linear-gradient(135deg, #607D8B 0%, #455A64 100%);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 60rpx;
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
display: block;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.status-desc {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.section {
|
||||
background-color: #fff;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.logistics-info {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.logistics-company, .logistics-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.logistics-number .label {
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.logistics-number .value {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #999;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.address-info {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.address-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.address-user .name {
|
||||
font-weight: bold;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.address-user .phone {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.address-detail {
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-list {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
min-width: 140rpx;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
color: #007AFF;
|
||||
font-size: 24rpx;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
.products-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.product-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
border-radius: 8rpx;
|
||||
margin-right: 20rpx;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.product-spec {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.product-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-quantity {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.fees-list {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.fee-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.fee-item .value {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.fee-item .value.discount {
|
||||
color: #FF5722;
|
||||
}
|
||||
|
||||
.fee-item.total {
|
||||
margin-top: 20rpx;
|
||||
padding-top: 20rpx;
|
||||
border-top: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.fee-item.total .label {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.fee-item.total .value {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #FF3B30;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 20rpx 30rpx;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
background-color: #fff;
|
||||
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
line-height: 80rpx;
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
border-radius: 40rpx;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background-color: #007AFF;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-btn.default {
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
background-color: #FF3B30;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.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;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.form-picker, .form-input {
|
||||
height: 72rpx;
|
||||
border: 1rpx solid #e5e5e5;
|
||||
border-radius: 8rpx;
|
||||
padding: 0 20rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.picker-value {
|
||||
height: 72rpx;
|
||||
line-height: 72rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
border-top: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.modal-btn.cancel {
|
||||
color: #666;
|
||||
border-right: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.modal-btn.confirm {
|
||||
color: #007AFF;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
996
pages/mall/merchant/orders.uvue
Normal file
996
pages/mall/merchant/orders.uvue
Normal file
@@ -0,0 +1,996 @@
|
||||
<!-- 商家端 - 订单管理页面 -->
|
||||
<template>
|
||||
<view class="orders-page">
|
||||
<!-- 标签页切换 -->
|
||||
<view class="tabs-container">
|
||||
<view class="tabs-scroll">
|
||||
<view
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="index"
|
||||
class="tab-item"
|
||||
:class="{ active: currentTab === tab.status }"
|
||||
@click="switchTab(tab.status)"
|
||||
>
|
||||
<text class="tab-text">{{ tab.name }}</text>
|
||||
<view v-if="tab.count > 0" class="tab-badge">{{ tab.count }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<view class="search-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
type="text"
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索订单号/商品名称"
|
||||
@confirm="handleSearch"
|
||||
/>
|
||||
<view class="search-btn" @click="handleSearch">搜索</view>
|
||||
</view>
|
||||
|
||||
<!-- 订单列表 -->
|
||||
<scroll-view
|
||||
class="orders-list"
|
||||
scroll-y
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
@scrolltolower="loadMore"
|
||||
>
|
||||
<view v-if="loading && orders.length === 0" class="loading-container">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="orders.length === 0" class="empty-container">
|
||||
<text class="empty-icon">📋</text>
|
||||
<text class="empty-text">暂无订单</text>
|
||||
</view>
|
||||
|
||||
<view v-else>
|
||||
<view
|
||||
v-for="order in orders"
|
||||
:key="order.id"
|
||||
class="order-card"
|
||||
@click="viewOrderDetail(order.id)"
|
||||
>
|
||||
<!-- 订单头部 -->
|
||||
<view class="order-header">
|
||||
<view class="order-info-left">
|
||||
<text class="order-no">订单号: {{ order.order_no }}</text>
|
||||
<text class="order-time">{{ formatTime(order.created_at) }}</text>
|
||||
</view>
|
||||
<text class="order-status" :class="'status-' + order.order_status">
|
||||
{{ getStatusText(order.order_status) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 订单商品 -->
|
||||
<view class="order-products">
|
||||
<view
|
||||
v-for="item in order.items"
|
||||
:key="item.id"
|
||||
class="product-item"
|
||||
>
|
||||
<image
|
||||
:src="item.image_url || '/static/images/default-product.png'"
|
||||
class="product-image"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="product-info">
|
||||
<text class="product-name">{{ item.product_name }}</text>
|
||||
<text class="product-spec">{{ item.sku_name || '标准规格' }}</text>
|
||||
</view>
|
||||
<view class="product-right">
|
||||
<text class="product-price">¥{{ item.price }}</text>
|
||||
<text class="product-quantity">x{{ item.quantity }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 订单底部 -->
|
||||
<view class="order-footer">
|
||||
<view class="order-amount">
|
||||
<text class="amount-label">共{{ getTotalQuantity(order.items) }}件商品</text>
|
||||
<text class="amount-value">合计: ¥{{ order.total_amount }}</text>
|
||||
</view>
|
||||
<view class="order-actions">
|
||||
<view
|
||||
v-if="order.order_status === 1"
|
||||
class="action-btn primary"
|
||||
@click.stop="shipOrder(order)"
|
||||
>
|
||||
发货
|
||||
</view>
|
||||
<view
|
||||
v-if="order.order_status === 2"
|
||||
class="action-btn info"
|
||||
@click.stop="viewLogistics(order)"
|
||||
>
|
||||
查看物流
|
||||
</view>
|
||||
<view
|
||||
v-if="order.order_status === -1 || order.order_status === 5"
|
||||
class="action-btn default"
|
||||
@click.stop="deleteOrder(order)"
|
||||
>
|
||||
删除
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loadingMore" class="load-more">
|
||||
<text class="load-more-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-if="!hasMore && orders.length > 0" class="no-more">
|
||||
<text class="no-more-text">没有更多了</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 发货弹窗 -->
|
||||
<view v-if="showShipModal" class="modal-mask" @click="closeShipModal">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">发货</text>
|
||||
<text class="modal-close" @click="closeShipModal">×</text>
|
||||
</view>
|
||||
<view class="modal-body">
|
||||
<view class="form-item">
|
||||
<text class="form-label">物流公司</text>
|
||||
<picker
|
||||
class="form-picker"
|
||||
:range="logisticsCompanies"
|
||||
range-key="name"
|
||||
@change="onLogisticsChange"
|
||||
>
|
||||
<view class="picker-value">
|
||||
{{ selectedLogistics.name || '请选择物流公司' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">物流单号</text>
|
||||
<input
|
||||
class="form-input"
|
||||
v-model="trackingNumber"
|
||||
placeholder="请输入物流单号"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-footer">
|
||||
<view class="modal-btn cancel" @click="closeShipModal">取消</view>
|
||||
<view class="modal-btn confirm" @click="confirmShip">确认发货</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
type OrderItemType = {
|
||||
id: string
|
||||
order_id: string
|
||||
product_id: string
|
||||
sku_id: string
|
||||
product_name: string
|
||||
sku_name: string
|
||||
price: number
|
||||
quantity: number
|
||||
image_url: string
|
||||
sku_snapshot: string
|
||||
}
|
||||
|
||||
type OrderType = {
|
||||
id: string
|
||||
order_no: string
|
||||
user_id: string
|
||||
merchant_id: string
|
||||
order_status: number
|
||||
total_amount: number
|
||||
product_amount: number
|
||||
shipping_fee: number
|
||||
paid_amount: number
|
||||
shipping_address: string
|
||||
remark: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
items: OrderItemType[]
|
||||
}
|
||||
|
||||
type TabType = {
|
||||
name: string
|
||||
status: number
|
||||
count: number
|
||||
}
|
||||
|
||||
type LogisticsType = {
|
||||
name: string | null
|
||||
code: string | null
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
tabs: [
|
||||
{ name: '全部', status: -2, count: 0 },
|
||||
{ name: '待付款', status: 1, count: 0 },
|
||||
{ name: '待发货', status: 2, count: 0 },
|
||||
{ name: '待收货', status: 3, count: 0 },
|
||||
{ name: '已完成', status: 4, count: 0 },
|
||||
{ name: '退款', status: 0, count: 0 }
|
||||
] as TabType[],
|
||||
currentTab: -2,
|
||||
searchKeyword: '',
|
||||
orders: [] as OrderType[],
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
refreshing: false,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
hasMore: true,
|
||||
merchantId: '',
|
||||
|
||||
showShipModal: false,
|
||||
currentOrder: null as OrderType | null,
|
||||
logisticsCompanies: [
|
||||
{ name: '顺丰速运', code: 'SF' },
|
||||
{ name: '圆通速递', code: 'YTO' },
|
||||
{ name: '中通快递', code: 'ZTO' },
|
||||
{ name: '韵达快递', code: 'YD' },
|
||||
{ name: '申通快递', code: 'STO' },
|
||||
{ name: 'EMS', code: 'EMS' },
|
||||
{ name: '京东物流', code: 'JD' }
|
||||
] as LogisticsType[],
|
||||
selectedLogistics: null as LogisticsType | null,
|
||||
trackingNumber: ''
|
||||
}
|
||||
},
|
||||
|
||||
onLoad(options: any) {
|
||||
const type = options.type as string
|
||||
if (type && type !== 'all') {
|
||||
const statusMap: Record<string, number> = {
|
||||
'pending': 1,
|
||||
'shipped': 3,
|
||||
'refund': 0,
|
||||
'completed': 4
|
||||
}
|
||||
this.currentTab = statusMap[type] ?? -2
|
||||
}
|
||||
this.initMerchantId()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
if (this.merchantId) {
|
||||
this.loadOrders()
|
||||
this.loadOrderCounts()
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this.loadOrders()
|
||||
this.loadOrderCounts()
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async initMerchantId() {
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
if (session != null && session.user != null) {
|
||||
this.merchantId = session.user.getString('id') || ''
|
||||
}
|
||||
if (!this.merchantId) {
|
||||
this.merchantId = uni.getStorageSync('user_id') || ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取商户ID失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
async loadOrders() {
|
||||
if (this.loading) return
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
let query = supa
|
||||
.from('ml_orders')
|
||||
.select(`
|
||||
*,
|
||||
order_items!inner (
|
||||
id,
|
||||
order_id,
|
||||
product_id,
|
||||
sku_id,
|
||||
product_name,
|
||||
sku_name,
|
||||
price,
|
||||
quantity,
|
||||
image_url,
|
||||
sku_snapshot
|
||||
)
|
||||
`)
|
||||
.eq('merchant_id', this.merchantId)
|
||||
.order('created_at', { ascending: false })
|
||||
.page(this.page)
|
||||
.limit(this.limit)
|
||||
|
||||
if (this.currentTab !== -2) {
|
||||
if (this.currentTab === 0) {
|
||||
query = query.eq('order_status', 0)
|
||||
} else {
|
||||
query = query.eq('order_status', this.currentTab)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.searchKeyword) {
|
||||
query = query.like('order_no', `%${this.searchKeyword}%`)
|
||||
}
|
||||
|
||||
const response = await query.execute()
|
||||
|
||||
if (response.error != null) {
|
||||
console.error('获取订单失败:', response.error)
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const rawData = response.data as any[]
|
||||
if (rawData == null || rawData.length === 0) {
|
||||
this.orders = []
|
||||
this.hasMore = false
|
||||
return
|
||||
}
|
||||
|
||||
const ordersData: OrderType[] = []
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const item = rawData[i]
|
||||
const orderObj = item as UTSJSONObject
|
||||
|
||||
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') || '',
|
||||
order_status: orderObj.getNumber('order_status') || 1,
|
||||
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,
|
||||
shipping_address: orderObj.getString('shipping_address') || '',
|
||||
remark: orderObj.getString('remark') || '',
|
||||
created_at: orderObj.getString('created_at') || '',
|
||||
updated_at: orderObj.getString('updated_at') || '',
|
||||
items: []
|
||||
}
|
||||
|
||||
const itemsObj = orderObj.get('order_items')
|
||||
if (itemsObj != null && Array.isArray(itemsObj)) {
|
||||
const itemsArray = itemsObj as any[]
|
||||
for (let j = 0; j < itemsArray.length; j++) {
|
||||
const orderItem = itemsArray[j] as UTSJSONObject
|
||||
order.items.push({
|
||||
id: orderItem.getString('id') || '',
|
||||
order_id: orderItem.getString('order_id') || '',
|
||||
product_id: orderItem.getString('product_id') || '',
|
||||
sku_id: orderItem.getString('sku_id') || '',
|
||||
product_name: orderItem.getString('product_name') || '',
|
||||
sku_name: orderItem.getString('sku_name') || '',
|
||||
price: orderItem.getNumber('price') || 0,
|
||||
quantity: orderItem.getNumber('quantity') || 0,
|
||||
image_url: orderItem.getString('image_url') || '',
|
||||
sku_snapshot: ''
|
||||
} as OrderItemType)
|
||||
}
|
||||
}
|
||||
|
||||
ordersData.push(order)
|
||||
}
|
||||
|
||||
if (this.page === 1) {
|
||||
this.orders = ordersData
|
||||
} else {
|
||||
this.orders = [...this.orders, ...ordersData]
|
||||
}
|
||||
|
||||
this.hasMore = rawData.length >= this.limit
|
||||
} catch (e) {
|
||||
console.error('获取订单异常:', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.refreshing = false
|
||||
}
|
||||
},
|
||||
|
||||
async loadOrderCounts() {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_orders')
|
||||
.select('order_status', { count: 'exact' })
|
||||
.eq('merchant_id', this.merchantId)
|
||||
.execute()
|
||||
|
||||
if (response.error != null || response.total == null) return
|
||||
|
||||
const counts = {
|
||||
1: 0, 2: 0, 3: 0, 4: 0, 0: 0
|
||||
}
|
||||
let total = 0
|
||||
|
||||
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') || 1
|
||||
if (status === 1) counts[1]++
|
||||
else if (status === 2) counts[2]++
|
||||
else if (status === 3) counts[3]++
|
||||
else if (status === 4) counts[4]++
|
||||
else if (status === 0) counts[0]++
|
||||
total++
|
||||
}
|
||||
}
|
||||
|
||||
this.tabs[0].count = total
|
||||
this.tabs[1].count = counts[1] || 0
|
||||
this.tabs[2].count = counts[2] || 0
|
||||
this.tabs[3].count = counts[3] || 0
|
||||
this.tabs[4].count = counts[4] || 0
|
||||
this.tabs[5].count = counts[0] || 0
|
||||
} catch (e) {
|
||||
console.error('获取订单数量异常:', e)
|
||||
}
|
||||
},
|
||||
|
||||
switchTab(status: number) {
|
||||
this.currentTab = status
|
||||
this.page = 1
|
||||
this.hasMore = true
|
||||
this.loadOrders()
|
||||
},
|
||||
|
||||
handleSearch() {
|
||||
this.page = 1
|
||||
this.hasMore = true
|
||||
this.loadOrders()
|
||||
},
|
||||
|
||||
onRefresh() {
|
||||
this.refreshing = true
|
||||
this.page = 1
|
||||
this.loadOrders()
|
||||
this.loadOrderCounts()
|
||||
},
|
||||
|
||||
loadMore() {
|
||||
if (!this.loadingMore && this.hasMore) {
|
||||
this.loadingMore = true
|
||||
this.page++
|
||||
this.loadOrders().then(() => {
|
||||
this.loadingMore = false
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
viewOrderDetail(orderId: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/merchant/order-detail?id=${orderId}`
|
||||
})
|
||||
},
|
||||
|
||||
shipOrder(order: OrderType) {
|
||||
this.currentOrder = order
|
||||
this.showShipModal = true
|
||||
},
|
||||
|
||||
closeShipModal() {
|
||||
this.showShipModal = false
|
||||
this.currentOrder = null
|
||||
this.selectedLogistics = null
|
||||
this.trackingNumber = ''
|
||||
},
|
||||
|
||||
onLogisticsChange(e: any) {
|
||||
const index = e.detail.value as number
|
||||
this.selectedLogistics = this.logisticsCompanies[index]
|
||||
},
|
||||
|
||||
async confirmShip() {
|
||||
if (!this.selectedLogistics.name) {
|
||||
uni.showToast({ title: '请选择物流公司', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!this.trackingNumber) {
|
||||
uni.showToast({ title: '请输入物流单号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_orders')
|
||||
.update({
|
||||
order_status: 3,
|
||||
shipping_company: this.selectedLogistics.name,
|
||||
tracking_number: this.trackingNumber,
|
||||
shipped_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', this.currentOrder!.id)
|
||||
.execute()
|
||||
|
||||
if (response.error != null) {
|
||||
uni.showToast({ title: '发货失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showToast({ title: '发货成功', icon: 'success' })
|
||||
this.closeShipModal()
|
||||
this.loadOrders()
|
||||
this.loadOrderCounts()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '发货失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
viewLogistics(order: OrderType) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/merchant/logistics?orderId=${order.id}`
|
||||
})
|
||||
},
|
||||
|
||||
async deleteOrder(order: OrderType) {
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: '确定要删除该订单吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_orders')
|
||||
.delete()
|
||||
.eq('id', order.id)
|
||||
.execute()
|
||||
|
||||
if (response.error != null) {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showToast({ title: '删除成功', icon: 'success' })
|
||||
this.loadOrders()
|
||||
this.loadOrderCounts()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
getStatusText(status: number): string {
|
||||
if (status === 1) return '待付款'
|
||||
if (status === 2) return '待发货'
|
||||
if (status === 3) return '待收货'
|
||||
if (status === 4) return '已完成'
|
||||
if (status === 0) return '退款中'
|
||||
if (status === 5 || status === -1) return '已取消'
|
||||
return '未知'
|
||||
},
|
||||
|
||||
getTotalQuantity(items: OrderItemType[]): number {
|
||||
let total = 0
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
total += items[i].quantity
|
||||
}
|
||||
return total
|
||||
},
|
||||
|
||||
formatTime(timeStr: string): string {
|
||||
if (!timeStr) return ''
|
||||
const date = new Date(timeStr)
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
const hour = date.getHours().toString().padStart(2, '0')
|
||||
const minute = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.orders-page {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
background-color: #fff;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.tabs-scroll {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
padding: 0 20rpx;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
position: relative;
|
||||
padding: 24rpx 30rpx;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tab-item.active .tab-text {
|
||||
color: #007AFF;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tab-item.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40rpx;
|
||||
height: 4rpx;
|
||||
background-color: #007AFF;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
position: absolute;
|
||||
top: 10rpx;
|
||||
right: 10rpx;
|
||||
min-width: 32rpx;
|
||||
height: 32rpx;
|
||||
padding: 0 8rpx;
|
||||
background-color: #FF3B30;
|
||||
color: #fff;
|
||||
font-size: 20rpx;
|
||||
border-radius: 16rpx;
|
||||
text-align: center;
|
||||
line-height: 32rpx;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 30rpx;
|
||||
background-color: #fff;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 64rpx;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 32rpx;
|
||||
padding: 0 30rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
margin-left: 20rpx;
|
||||
padding: 16rpx 30rpx;
|
||||
background-color: #007AFF;
|
||||
color: #fff;
|
||||
font-size: 26rpx;
|
||||
border-radius: 32rpx;
|
||||
}
|
||||
|
||||
.orders-list {
|
||||
padding: 0 20rpx;
|
||||
height: calc(100vh - 300rpx);
|
||||
}
|
||||
|
||||
.loading-container, .empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100rpx 0;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 100rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.empty-text, .loading-text {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.order-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 24rpx;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.order-info-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.order-no {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.order-time {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.order-status {
|
||||
font-size: 24rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.status-1 {
|
||||
background-color: #FFF3E0;
|
||||
color: #F57C00;
|
||||
}
|
||||
|
||||
.status-2 {
|
||||
background-color: #E3F2FD;
|
||||
color: #1976D2;
|
||||
}
|
||||
|
||||
.status-3 {
|
||||
background-color: #E8F5E9;
|
||||
color: #388E3C;
|
||||
}
|
||||
|
||||
.status-4, .status-5 {
|
||||
background-color: #F5F5F5;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.status-0, .status--1 {
|
||||
background-color: #FFEBEE;
|
||||
color: #F44336;
|
||||
}
|
||||
|
||||
.order-products {
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
.product-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.product-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 8rpx;
|
||||
margin-right: 20rpx;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.product-spec {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.product-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-quantity {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.order-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 24rpx;
|
||||
border-top: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.order-amount {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.amount-label {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
font-size: 28rpx;
|
||||
color: #FF3B30;
|
||||
font-weight: bold;
|
||||
margin-left: 10rpx;
|
||||
}
|
||||
|
||||
.order-actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 12rpx 24rpx;
|
||||
font-size: 24rpx;
|
||||
border-radius: 28rpx;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background-color: #007AFF;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-btn.info {
|
||||
background-color: #E3F2FD;
|
||||
color: #1976D2;
|
||||
}
|
||||
|
||||
.action-btn.default {
|
||||
background-color: #F5F5F5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.load-more, .no-more {
|
||||
padding: 30rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.load-more-text, .no-more-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.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;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.form-picker, .form-input {
|
||||
height: 72rpx;
|
||||
border: 1rpx solid #e5e5e5;
|
||||
border-radius: 8rpx;
|
||||
padding: 0 20rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.picker-value {
|
||||
height: 72rpx;
|
||||
line-height: 72rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
border-top: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.modal-btn.cancel {
|
||||
color: #666;
|
||||
border-right: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.modal-btn.confirm {
|
||||
color: #007AFF;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
715
pages/mall/merchant/product-edit.uvue
Normal file
715
pages/mall/merchant/product-edit.uvue
Normal file
@@ -0,0 +1,715 @@
|
||||
<!-- 商家端 - 商品编辑页面 -->
|
||||
<template>
|
||||
<view class="product-edit-page">
|
||||
<!-- 商品基本信息 -->
|
||||
<view class="section">
|
||||
<view class="section-title">基本信息</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">商品名称 *</text>
|
||||
<input
|
||||
class="input"
|
||||
v-model="product.name"
|
||||
placeholder="请输入商品名称"
|
||||
maxlength="100"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">商品副标题</text>
|
||||
<input
|
||||
class="input"
|
||||
v-model="product.subtitle"
|
||||
placeholder="请输入商品副标题"
|
||||
maxlength="200"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">商品分类 *</text>
|
||||
<picker
|
||||
class="picker"
|
||||
:range="categories"
|
||||
range-key="name"
|
||||
:value="categoryIndex"
|
||||
@change="onCategoryChange"
|
||||
>
|
||||
<view class="picker-value">
|
||||
{{ selectedCategory?.name || '请选择分类' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">商品品牌</text>
|
||||
<picker
|
||||
class="picker"
|
||||
:range="brands"
|
||||
range-key="name"
|
||||
:value="brandIndex"
|
||||
@change="onBrandChange"
|
||||
>
|
||||
<view class="picker-value">
|
||||
{{ selectedBrand?.name || '请选择品牌' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品图片 -->
|
||||
<view class="section">
|
||||
<view class="section-title">商品图片</view>
|
||||
|
||||
<view class="image-section">
|
||||
<text class="label">主图 *</text>
|
||||
<view class="image-grid">
|
||||
<view class="image-item main-image" @click="chooseMainImage">
|
||||
<image v-if="product.main_image_url" :src="product.main_image_url" mode="aspectFill" class="preview-image"/>
|
||||
<view v-else class="add-image">+</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="image-section">
|
||||
<text class="label">轮播图</text>
|
||||
<view class="image-grid">
|
||||
<view
|
||||
v-for="(img, index) in product.imageList"
|
||||
:key="index"
|
||||
class="image-item"
|
||||
>
|
||||
<image :src="img" mode="aspectFill" class="preview-image"/>
|
||||
<view class="delete-btn" @click="removeImage(index)">×</view>
|
||||
</view>
|
||||
<view class="image-item add-image" @click="chooseImages" v-if="product.imageList.length < 5">
|
||||
+
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 价格库存 -->
|
||||
<view class="section">
|
||||
<view class="section-title">价格库存</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">销售价 *</text>
|
||||
<view class="price-input">
|
||||
<text class="unit">¥</text>
|
||||
<input
|
||||
class="input"
|
||||
type="digit"
|
||||
v-model="product.base_price"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">市场价</text>
|
||||
<view class="price-input">
|
||||
<text class="unit">¥</text>
|
||||
<input
|
||||
class="input"
|
||||
type="digit"
|
||||
v-model="product.market_price"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">成本价</text>
|
||||
<view class="price-input">
|
||||
<text class="unit">¥</text>
|
||||
<input
|
||||
class="input"
|
||||
type="digit"
|
||||
v-model="product.cost_price"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">总库存 *</text>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
v-model="product.total_stock"
|
||||
placeholder="0"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">库存预警</text>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
v-model="product.warning_stock"
|
||||
placeholder="库存低于此值提醒"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品属性 -->
|
||||
<view class="section">
|
||||
<view class="section-title">商品属性</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">商品单位</text>
|
||||
<input
|
||||
class="input"
|
||||
v-model="product.unit"
|
||||
placeholder="如: 件, 盒, 箱"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="switch-item">
|
||||
<text class="label">热卖商品</text>
|
||||
<switch
|
||||
:checked="product.is_hot"
|
||||
@change="product.is_hot = !product.is_hot"
|
||||
color="#007AFF"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="switch-item">
|
||||
<text class="label">新品上架</text>
|
||||
<switch
|
||||
:checked="product.is_new"
|
||||
@change="product.is_new = !product.is_new"
|
||||
color="#007AFF"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="switch-item">
|
||||
<text class="label">推荐商品</text>
|
||||
<switch
|
||||
:checked="product.is_featured"
|
||||
@change="product.is_featured = !product.is_featured"
|
||||
color="#007AFF"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品详情 -->
|
||||
<view class="section">
|
||||
<view class="section-title">商品详情</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">商品描述</text>
|
||||
<textarea
|
||||
class="textarea"
|
||||
v-model="product.description"
|
||||
placeholder="请输入商品详细描述"
|
||||
:maxlength="2000"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<view class="submit-bar">
|
||||
<view class="submit-btn primary" @click="saveProduct">
|
||||
{{ isEdit ? '保存修改' : '发布商品' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
type CategoryType = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type BrandType = {
|
||||
id: string
|
||||
name: string
|
||||
logo_url: string
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
productId: '',
|
||||
isEdit: false,
|
||||
categories: [] as CategoryType[],
|
||||
categoryIndex: -1,
|
||||
selectedCategory: null as CategoryType | null,
|
||||
brands: [] as BrandType[],
|
||||
brandIndex: -1,
|
||||
selectedBrand: null as BrandType | null,
|
||||
product: {
|
||||
name: '',
|
||||
subtitle: '',
|
||||
category_id: '',
|
||||
brand_id: '',
|
||||
main_image_url: '',
|
||||
imageList: [] as string[],
|
||||
base_price: '',
|
||||
market_price: '',
|
||||
cost_price: '',
|
||||
total_stock: '',
|
||||
warning_stock: '10',
|
||||
unit: '件',
|
||||
is_hot: false,
|
||||
is_new: false,
|
||||
is_featured: false,
|
||||
description: ''
|
||||
},
|
||||
merchantId: ''
|
||||
}
|
||||
},
|
||||
|
||||
onLoad(options: any) {
|
||||
const productId = options.productId as string
|
||||
if (productId) {
|
||||
this.productId = productId
|
||||
this.isEdit = true
|
||||
this.loadProductDetail(productId)
|
||||
}
|
||||
this.initMerchantId()
|
||||
this.loadCategories()
|
||||
this.loadBrands()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async initMerchantId() {
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
if (session != null && session.user != null) {
|
||||
this.merchantId = session.user.getString('id') || ''
|
||||
}
|
||||
if (!this.merchantId) {
|
||||
this.merchantId = uni.getStorageSync('user_id') || ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取商户ID失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
async loadCategories() {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_categories')
|
||||
.select('id, name')
|
||||
.eq('is_active', true)
|
||||
.order('sort_order', { ascending: true })
|
||||
.execute()
|
||||
|
||||
if (response.error != null) {
|
||||
console.error('获取分类失败:', response.error)
|
||||
return
|
||||
}
|
||||
|
||||
const rawData = response.data as any[]
|
||||
if (rawData == null) return
|
||||
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const item = rawData[i] as UTSJSONObject
|
||||
this.categories.push({
|
||||
id: item.getString('id') || '',
|
||||
name: item.getString('name') || ''
|
||||
} as CategoryType)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取分类异常:', e)
|
||||
}
|
||||
},
|
||||
|
||||
async loadBrands() {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_brands')
|
||||
.select('id, name, logo_url')
|
||||
.eq('is_active', true)
|
||||
.order('name', { ascending: true })
|
||||
.execute()
|
||||
|
||||
if (response.error != null) {
|
||||
console.error('获取品牌失败:', response.error)
|
||||
return
|
||||
}
|
||||
|
||||
const rawData = response.data as any[]
|
||||
if (rawData == null) return
|
||||
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const item = rawData[i] as UTSJSONObject
|
||||
this.brands.push({
|
||||
id: item.getString('id') || '',
|
||||
name: item.getString('name') || '',
|
||||
logo_url: item.getString('logo_url') || ''
|
||||
} as BrandType)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取品牌异常:', e)
|
||||
}
|
||||
},
|
||||
|
||||
async loadProductDetail(productId: string) {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_products')
|
||||
.select('*')
|
||||
.eq('id', productId)
|
||||
.single()
|
||||
.execute()
|
||||
|
||||
if (response.error != null) {
|
||||
console.error('获取商品详情失败:', response.error)
|
||||
return
|
||||
}
|
||||
|
||||
const rawData = response.data as UTSJSONObject
|
||||
if (rawData == null) return
|
||||
|
||||
this.product = {
|
||||
name: rawData.getString('name') || '',
|
||||
subtitle: rawData.getString('subtitle') || '',
|
||||
category_id: rawData.getString('category_id') || '',
|
||||
brand_id: rawData.getString('brand_id') || '',
|
||||
main_image_url: rawData.getString('main_image_url') || '',
|
||||
imageList: this.parseImageUrls(rawData.getString('image_urls')),
|
||||
base_price: rawData.getString('base_price') || '',
|
||||
market_price: rawData.getString('market_price') || '',
|
||||
cost_price: rawData.getString('cost_price') || '',
|
||||
total_stock: rawData.getString('total_stock') || '',
|
||||
warning_stock: rawData.getString('warning_stock') || '10',
|
||||
unit: rawData.getString('unit') || '件',
|
||||
is_hot: rawData.getBoolean('is_hot') || false,
|
||||
is_new: rawData.getBoolean('is_new') || false,
|
||||
is_featured: rawData.getBoolean('is_featured') || false,
|
||||
description: rawData.getString('description') || ''
|
||||
}
|
||||
|
||||
if (this.product.category_id) {
|
||||
this.categoryIndex = this.categories.findIndex(c => c.id === this.product.category_id)
|
||||
if (this.categoryIndex >= 0) {
|
||||
this.selectedCategory = this.categories[this.categoryIndex]
|
||||
}
|
||||
}
|
||||
|
||||
if (this.product.brand_id) {
|
||||
this.brandIndex = this.brands.findIndex(b => b.id === this.product.brand_id)
|
||||
if (this.brandIndex >= 0) {
|
||||
this.selectedBrand = this.brands[this.brandIndex]
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取商品详情异常:', e)
|
||||
}
|
||||
},
|
||||
|
||||
parseImageUrls(urlsStr: string): string[] {
|
||||
if (!urlsStr) return []
|
||||
try {
|
||||
const arr = JSON.parse(urlsStr)
|
||||
return Array.isArray(arr) ? arr : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
onCategoryChange(e: any) {
|
||||
const index = e.detail.value as number
|
||||
this.categoryIndex = index
|
||||
this.selectedCategory = this.categories[index]
|
||||
this.product.category_id = this.selectedCategory.id
|
||||
},
|
||||
|
||||
onBrandChange(e: any) {
|
||||
const index = e.detail.value as number
|
||||
this.brandIndex = index
|
||||
this.selectedBrand = this.brands[index]
|
||||
this.product.brand_id = this.selectedBrand.id
|
||||
},
|
||||
|
||||
chooseMainImage() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
success: (res) => {
|
||||
this.product.main_image_url = res.tempFilePaths[0]
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
chooseImages() {
|
||||
const remainCount = 5 - this.product.imageList.length
|
||||
uni.chooseImage({
|
||||
count: remainCount,
|
||||
sizeType: ['compressed'],
|
||||
success: (res) => {
|
||||
this.product.imageList = [...this.product.imageList, ...res.tempFilePaths]
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
removeImage(index: number) {
|
||||
this.product.imageList.splice(index, 1)
|
||||
},
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
uni.showLoading({ title: '保存中...' })
|
||||
|
||||
try {
|
||||
const imageUrlsStr = JSON.stringify(this.product.imageList)
|
||||
|
||||
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: this.product.main_image_url,
|
||||
image_urls: imageUrlsStr,
|
||||
base_price: parseFloat(this.product.base_price),
|
||||
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),
|
||||
warning_stock: parseInt(this.product.warning_stock) || 10,
|
||||
unit: this.product.unit,
|
||||
is_hot: this.product.is_hot,
|
||||
is_new: this.product.is_new,
|
||||
is_featured: this.product.is_featured,
|
||||
description: this.product.description,
|
||||
status: 1,
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
let response
|
||||
if (this.isEdit) {
|
||||
response = await supa
|
||||
.from('ml_products')
|
||||
.update(productData)
|
||||
.eq('id', this.productId)
|
||||
.execute()
|
||||
} else {
|
||||
productData['created_at'] = new Date().toISOString()
|
||||
response = await supa
|
||||
.from('ml_products')
|
||||
.insert(productData)
|
||||
.execute()
|
||||
}
|
||||
|
||||
uni.hideLoading()
|
||||
|
||||
if (response.error != null) {
|
||||
console.error('保存商品失败:', response.error)
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
}, 1500)
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
console.error('保存商品异常:', e)
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.product-edit-page {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 160rpx;
|
||||
}
|
||||
|
||||
.section {
|
||||
background-color: #fff;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 30rpx;
|
||||
padding-bottom: 20rpx;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 72rpx;
|
||||
border: 1rpx solid #e5e5e5;
|
||||
border-radius: 8rpx;
|
||||
padding: 0 20rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.price-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1rpx solid #e5e5e5;
|
||||
border-radius: 8rpx;
|
||||
padding: 0 20rpx;
|
||||
}
|
||||
|
||||
.price-input .unit {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
|
||||
.price-input .input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.picker {
|
||||
height: 72rpx;
|
||||
border: 1rpx solid #e5e5e5;
|
||||
border-radius: 8rpx;
|
||||
padding: 0 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.picker-value {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.switch-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.switch-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
height: 200rpx;
|
||||
border: 1rpx solid #e5e5e5;
|
||||
border-radius: 8rpx;
|
||||
padding: 20rpx;
|
||||
font-size: 28rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.image-section {
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
position: relative;
|
||||
width: 150rpx;
|
||||
height: 150rpx;
|
||||
border-radius: 8rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-image {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.add-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 60rpx;
|
||||
color: #999;
|
||||
border: 2rpx dashed #ddd;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
position: absolute;
|
||||
top: 5rpx;
|
||||
right: 5rpx;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.submit-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 20rpx 30rpx;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
background-color: #fff;
|
||||
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
text-align: center;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
border-radius: 44rpx;
|
||||
}
|
||||
|
||||
.submit-btn.primary {
|
||||
background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
712
pages/mall/merchant/products.uvue
Normal file
712
pages/mall/merchant/products.uvue
Normal file
@@ -0,0 +1,712 @@
|
||||
<!-- 商家端 - 商品管理列表页面 -->
|
||||
<template>
|
||||
<view class="products-page">
|
||||
<!-- 搜索栏 -->
|
||||
<view class="search-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
type="text"
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索商品名称"
|
||||
@confirm="handleSearch"
|
||||
/>
|
||||
<view class="search-btn" @click="handleSearch">搜索</view>
|
||||
</view>
|
||||
|
||||
<!-- 筛选标签 -->
|
||||
<view class="filter-tabs">
|
||||
<view
|
||||
class="filter-tab"
|
||||
:class="{ active: currentFilter === 'all' }"
|
||||
@click="switchFilter('all')"
|
||||
>
|
||||
全部
|
||||
</view>
|
||||
<view
|
||||
class="filter-tab"
|
||||
:class="{ active: currentFilter === 'onsale' }"
|
||||
@click="switchFilter('onsale')"
|
||||
>
|
||||
上架
|
||||
</view>
|
||||
<view
|
||||
class="filter-tab"
|
||||
:class="{ active: currentFilter === 'offsale' }"
|
||||
@click="switchFilter('offsale')"
|
||||
>
|
||||
下架
|
||||
</view>
|
||||
<view
|
||||
class="filter-tab"
|
||||
:class="{ active: currentFilter === 'low_stock' }"
|
||||
@click="switchFilter('low_stock')"
|
||||
>
|
||||
库存预警
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品列表 -->
|
||||
<scroll-view
|
||||
class="products-list"
|
||||
scroll-y
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
@scrolltolower="loadMore"
|
||||
>
|
||||
<view v-if="loading && products.length === 0" class="loading-container">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="products.length === 0" class="empty-container">
|
||||
<text class="empty-icon">📦</text>
|
||||
<text class="empty-text">暂无商品</text>
|
||||
<view class="add-first-btn" @click="addProduct">添加第一个商品</view>
|
||||
</view>
|
||||
|
||||
<view v-else>
|
||||
<view
|
||||
v-for="product in products"
|
||||
:key="product.id"
|
||||
class="product-card"
|
||||
@click="viewProductDetail(product.id)"
|
||||
>
|
||||
<image
|
||||
:src="product.main_image_url || '/static/images/default-product.png'"
|
||||
class="product-image"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="product-info">
|
||||
<view class="product-header">
|
||||
<text class="product-name">{{ product.name }}</text>
|
||||
<text class="product-status" :class="getStatusClass(product.status)">
|
||||
{{ getStatusText(product.status) }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="product-subtitle">{{ product.subtitle || '暂无描述' }}</text>
|
||||
<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-stats">
|
||||
<view class="price-row">
|
||||
<text class="current-price">¥{{ product.base_price }}</text>
|
||||
<text v-if="product.market_price" class="original-price">¥{{ product.market_price }}</text>
|
||||
</view>
|
||||
<view class="stock-row">
|
||||
<text class="stock">库存: {{ product.total_stock || 0 }}</text>
|
||||
<text class="sales">销量: {{ product.sale_count || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="product-actions" @click.stop>
|
||||
<view
|
||||
class="action-btn"
|
||||
:class="product.status === 1 ? 'warning' : 'success'"
|
||||
@click="toggleStatus(product)"
|
||||
>
|
||||
{{ product.status === 1 ? '下架' : '上架' }}
|
||||
</view>
|
||||
<view class="action-btn default" @click="editProduct(product.id)">编辑</view>
|
||||
<view class="action-btn danger" @click="deleteProduct(product)">删除</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loadingMore" class="load-more">
|
||||
<text class="load-more-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-if="!hasMore && products.length > 0" class="no-more">
|
||||
<text class="no-more-text">没有更多了</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 添加商品按钮 -->
|
||||
<view class="add-product-btn" @click="addProduct">
|
||||
<text class="add-icon">+</text>
|
||||
<text class="add-text">添加商品</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
type ProductType = {
|
||||
id: string
|
||||
merchant_id: string
|
||||
category_id: string
|
||||
name: string
|
||||
subtitle: string
|
||||
description: string
|
||||
main_image_url: string
|
||||
image_urls: string
|
||||
base_price: number
|
||||
market_price: number
|
||||
total_stock: number
|
||||
sale_count: number
|
||||
status: number
|
||||
is_hot: boolean
|
||||
is_new: boolean
|
||||
is_featured: boolean
|
||||
tags: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
searchKeyword: '',
|
||||
currentFilter: 'all',
|
||||
products: [] as ProductType[],
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
refreshing: false,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
hasMore: true,
|
||||
merchantId: '',
|
||||
lowStockThreshold: 10
|
||||
}
|
||||
},
|
||||
|
||||
onLoad(options: any) {
|
||||
const type = options.type as string
|
||||
if (type === 'add') {
|
||||
this.addProduct()
|
||||
} else if (type === 'low_stock') {
|
||||
this.currentFilter = 'low_stock'
|
||||
}
|
||||
this.initMerchantId()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadProducts()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async initMerchantId() {
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
if (session != null && session.user != null) {
|
||||
this.merchantId = session.user.getString('id') || ''
|
||||
}
|
||||
if (!this.merchantId) {
|
||||
this.merchantId = uni.getStorageSync('user_id') || ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取商户ID失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
async loadProducts() {
|
||||
if (this.loading) return
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
let query = supa
|
||||
.from('ml_products')
|
||||
.select('*')
|
||||
.eq('merchant_id', this.merchantId)
|
||||
.order('created_at', { ascending: false })
|
||||
.page(this.page)
|
||||
.limit(this.limit)
|
||||
|
||||
if (this.searchKeyword) {
|
||||
query = query.like('name', `%${this.searchKeyword}%`)
|
||||
}
|
||||
|
||||
if (this.currentFilter === 'onsale') {
|
||||
query = query.eq('status', 1)
|
||||
} else if (this.currentFilter === 'offsale') {
|
||||
query = query.eq('status', 0)
|
||||
} else if (this.currentFilter === 'low_stock') {
|
||||
query = query.lte('total_stock', this.lowStockThreshold).gte('total_stock', 0)
|
||||
}
|
||||
|
||||
const response = await query.execute()
|
||||
|
||||
if (response.error != null) {
|
||||
console.error('获取商品失败:', response.error)
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const rawData = response.data as any[]
|
||||
if (rawData == null || rawData.length === 0) {
|
||||
this.products = []
|
||||
this.hasMore = false
|
||||
return
|
||||
}
|
||||
|
||||
const productsData: ProductType[] = []
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const item = rawData[i]
|
||||
const prodObj = item as UTSJSONObject
|
||||
productsData.push({
|
||||
id: prodObj.getString('id') || '',
|
||||
merchant_id: prodObj.getString('merchant_id') || '',
|
||||
category_id: prodObj.getString('category_id') || '',
|
||||
name: prodObj.getString('name') || '',
|
||||
subtitle: prodObj.getString('subtitle') || '',
|
||||
description: prodObj.getString('description') || '',
|
||||
main_image_url: prodObj.getString('main_image_url') || '',
|
||||
image_urls: prodObj.getString('image_urls') || '',
|
||||
base_price: prodObj.getNumber('base_price') || 0,
|
||||
market_price: prodObj.getNumber('market_price') || 0,
|
||||
total_stock: prodObj.getNumber('total_stock') || 0,
|
||||
sale_count: prodObj.getNumber('sale_count') || 0,
|
||||
status: prodObj.getNumber('status') || 0,
|
||||
is_hot: prodObj.getBoolean('is_hot') || false,
|
||||
is_new: prodObj.getBoolean('is_new') || false,
|
||||
is_featured: prodObj.getBoolean('is_featured') || false,
|
||||
tags: prodObj.getString('tags') || '',
|
||||
created_at: prodObj.getString('created_at') || '',
|
||||
updated_at: prodObj.getString('updated_at') || ''
|
||||
} as ProductType)
|
||||
}
|
||||
|
||||
if (this.page === 1) {
|
||||
this.products = productsData
|
||||
} else {
|
||||
this.products = [...this.products, ...productsData]
|
||||
}
|
||||
|
||||
this.hasMore = rawData.length >= this.limit
|
||||
} catch (e) {
|
||||
console.error('获取商品异常:', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.refreshing = false
|
||||
}
|
||||
},
|
||||
|
||||
switchFilter(filter: string) {
|
||||
this.currentFilter = filter
|
||||
this.page = 1
|
||||
this.hasMore = true
|
||||
this.loadProducts()
|
||||
},
|
||||
|
||||
handleSearch() {
|
||||
this.page = 1
|
||||
this.hasMore = true
|
||||
this.loadProducts()
|
||||
},
|
||||
|
||||
onRefresh() {
|
||||
this.refreshing = true
|
||||
this.page = 1
|
||||
this.loadProducts()
|
||||
},
|
||||
|
||||
loadMore() {
|
||||
if (!this.loadingMore && this.hasMore) {
|
||||
this.loadingMore = true
|
||||
this.page++
|
||||
this.loadProducts().then(() => {
|
||||
this.loadingMore = false
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
viewProductDetail(productId: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/merchant/product-detail?productId=${productId}`
|
||||
})
|
||||
},
|
||||
|
||||
editProduct(productId: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/merchant/product-edit?productId=${productId}`
|
||||
})
|
||||
},
|
||||
|
||||
addProduct() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/merchant/product-edit'
|
||||
})
|
||||
},
|
||||
|
||||
async toggleStatus(product: ProductType) {
|
||||
const newStatus = product.status === 1 ? 0 : 1
|
||||
const actionText = newStatus === 1 ? '上架' : '下架'
|
||||
|
||||
uni.showModal({
|
||||
title: `确认${actionText}`,
|
||||
content: `确定要${actionText}该商品吗?`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_products')
|
||||
.update({
|
||||
status: newStatus,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', product.id)
|
||||
.execute()
|
||||
|
||||
if (response.error != null) {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
product.status = newStatus
|
||||
uni.showToast({ title: `${actionText}成功`, icon: 'success' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async deleteProduct(product: ProductType) {
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: '删除后将无法恢复,确定要删除该商品吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_products')
|
||||
.delete()
|
||||
.eq('id', product.id)
|
||||
.execute()
|
||||
|
||||
if (response.error != null) {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showToast({ title: '删除成功', icon: 'success' })
|
||||
this.loadProducts()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
getStatusClass(status: number): string {
|
||||
if (status === 1) return 'status-onsale'
|
||||
if (status === 0) return 'status-offsale'
|
||||
return 'status-pending'
|
||||
},
|
||||
|
||||
getStatusText(status: number): string {
|
||||
if (status === 1) return '在售'
|
||||
if (status === 0) return '已下架'
|
||||
return '待审核'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.products-page {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 140rpx;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 30rpx;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 64rpx;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 32rpx;
|
||||
padding: 0 30rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
margin-left: 20rpx;
|
||||
padding: 16rpx 30rpx;
|
||||
background-color: #007AFF;
|
||||
color: #fff;
|
||||
font-size: 26rpx;
|
||||
border-radius: 32rpx;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
background-color: #fff;
|
||||
padding: 0 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24rpx 0;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
color: #007AFF;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.filter-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40rpx;
|
||||
height: 4rpx;
|
||||
background-color: #007AFF;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
|
||||
.products-list {
|
||||
padding: 0 20rpx;
|
||||
height: calc(100vh - 260rpx);
|
||||
}
|
||||
|
||||
.loading-container, .empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100rpx 0;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 100rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.empty-text, .loading-text {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.add-first-btn {
|
||||
margin-top: 30rpx;
|
||||
padding: 20rpx 60rpx;
|
||||
background-color: #007AFF;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
border-radius: 40rpx;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
display: flex;
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 24rpx;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 180rpx;
|
||||
height: 180rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-right: 20rpx;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.product-status {
|
||||
font-size: 22rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-left: 10rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-onsale {
|
||||
background-color: #E8F5E9;
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.status-offsale {
|
||||
background-color: #FFEBEE;
|
||||
color: #F44336;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background-color: #FFF3E0;
|
||||
color: #FF9800;
|
||||
}
|
||||
|
||||
.product-subtitle {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
display: block;
|
||||
margin-bottom: 10rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-tags {
|
||||
display: flex;
|
||||
gap: 10rpx;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 20rpx;
|
||||
padding: 4rpx 10rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.tag.hot {
|
||||
background-color: #FF5722;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tag.new {
|
||||
background-color: #2196F3;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tag.recommend {
|
||||
background-color: #9C27B0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.product-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.current-price {
|
||||
font-size: 32rpx;
|
||||
color: #FF3B30;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.original-price {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
text-decoration: line-through;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
.stock-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.product-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
padding-top: 20rpx;
|
||||
border-top: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 12rpx 24rpx;
|
||||
font-size: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.action-btn.success {
|
||||
background-color: #E8F5E9;
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.action-btn.warning {
|
||||
background-color: #FFF3E0;
|
||||
color: #FF9800;
|
||||
}
|
||||
|
||||
.action-btn.default {
|
||||
background-color: #F5F5F5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
background-color: #FFEBEE;
|
||||
color: #F44336;
|
||||
}
|
||||
|
||||
.load-more, .no-more {
|
||||
padding: 30rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.load-more-text, .no-more-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.add-product-btn {
|
||||
position: fixed;
|
||||
bottom: 30rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 300rpx;
|
||||
height: 88rpx;
|
||||
background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%);
|
||||
border-radius: 44rpx;
|
||||
box-shadow: 0 8rpx 20rpx rgba(0, 122, 255, 0.3);
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
font-size: 40rpx;
|
||||
color: #fff;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
|
||||
.add-text {
|
||||
font-size: 30rpx;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
202
pages/mall/merchant/promotions.uvue
Normal file
202
pages/mall/merchant/promotions.uvue
Normal file
@@ -0,0 +1,202 @@
|
||||
<!-- 商家端 - 营销活动页面 -->
|
||||
<template>
|
||||
<view class="promotions-page">
|
||||
<view class="tabs">
|
||||
<view class="tab" :class="{ active: currentTab === 'coupon' }" @click="switchTab('coupon')">优惠券</view>
|
||||
<view class="tab" :class="{ active: currentTab === 'seckill' }" @click="switchTab('seckill')">秒杀活动</view>
|
||||
<view class="tab" :class="{ active: currentTab === 'group' }" @click="switchTab('group')">拼团活动</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="promotions-list" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
||||
<view v-if="loading && promotions.length === 0" class="loading-container"><text class="loading-text">加载中...</text></view>
|
||||
<view v-else-if="promotions.length === 0" class="empty-container">
|
||||
<text class="empty-icon">🎉</text>
|
||||
<text class="empty-text">暂无活动</text>
|
||||
<view class="add-btn" @click="addPromotion">创建活动</view>
|
||||
</view>
|
||||
<view v-else>
|
||||
<view v-for="promo in promotions" :key="promo.id" class="promotion-card">
|
||||
<view class="promo-header">
|
||||
<text class="promo-name">{{ promo.name }}</text>
|
||||
<text class="promo-status" :class="'status-' + promo.status">{{ getStatusText(promo.status) }}</text>
|
||||
</view>
|
||||
<view class="promo-info">
|
||||
<view class="info-item"><text class="label">优惠内容:</text><text class="value">{{ promo.discount_text }}</text></view>
|
||||
<view class="info-item"><text class="label">有效期:</text><text class="value">{{ formatDate(promo.start_time) }} - {{ formatDate(promo.end_time) }}</text></view>
|
||||
<view class="info-item"><text class="label">已领取:</text><text class="value">{{ promo.received_count || 0 }}</text></view>
|
||||
</view>
|
||||
<view class="promo-actions">
|
||||
<view class="action-btn" @click="editPromotion(promo)">编辑</view>
|
||||
<view class="action-btn danger" @click="deletePromotion(promo)">删除</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="add-promotion-btn" @click="addPromotion">+ 创建活动</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
type PromotionType = {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
discount_text: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
status: number
|
||||
received_count: number
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
currentTab: 'coupon',
|
||||
promotions: [] as PromotionType[],
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
merchantId: ''
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.initMerchantId()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadPromotions()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async initMerchantId() {
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async loadPromotions() {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_coupon_templates')
|
||||
.select('*')
|
||||
.eq('merchant_id', this.merchantId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50)
|
||||
.execute()
|
||||
|
||||
if (response.error != null || !response.data) {
|
||||
this.promotions = []
|
||||
return
|
||||
}
|
||||
|
||||
const rawData = response.data as any[]
|
||||
const promos: PromotionType[] = []
|
||||
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const item = rawData[i] as UTSJSONObject
|
||||
promos.push({
|
||||
id: item.getString('id') || '',
|
||||
name: item.getString('name') || '',
|
||||
type: item.getString('coupon_type') || 'coupon',
|
||||
discount_text: `满${item.getNumber('min_amount') || 0}减${item.getNumber('discount_amount') || item.getNumber('discount_value') || 0}`,
|
||||
start_time: item.getString('start_time') || '',
|
||||
end_time: item.getString('end_time') || '',
|
||||
status: item.getNumber('status') || 1,
|
||||
received_count: item.getNumber('received_count') || 0
|
||||
} as PromotionType)
|
||||
}
|
||||
|
||||
this.promotions = promos
|
||||
} catch (e) {
|
||||
console.error('加载失败:', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.refreshing = false
|
||||
}
|
||||
},
|
||||
|
||||
switchTab(tab: string) {
|
||||
this.currentTab = tab
|
||||
this.loadPromotions()
|
||||
},
|
||||
|
||||
onRefresh() {
|
||||
this.refreshing = true
|
||||
this.loadPromotions()
|
||||
},
|
||||
|
||||
addPromotion() {
|
||||
uni.showToast({ title: '活动管理功能开发中', icon: 'none' })
|
||||
},
|
||||
|
||||
editPromotion(promo: PromotionType) {
|
||||
uni.showToast({ title: '编辑功能开发中', icon: 'none' })
|
||||
},
|
||||
|
||||
deletePromotion(promo: PromotionType) {
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: '确定要删除该活动吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await supa.from('ml_coupon_templates').delete().eq('id', promo.id).execute()
|
||||
uni.showToast({ title: '删除成功', icon: 'success' })
|
||||
this.loadPromotions()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
getStatusText(status: number): string {
|
||||
if (status === 1) return '进行中'
|
||||
if (status === 0) return '未开始'
|
||||
if (status === 2) return '已结束'
|
||||
return '未知'
|
||||
},
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return `${date.getMonth() + 1}-${date.getDate()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.promotions-page { background-color: #f5f5f5; min-height: 100vh; padding-bottom: 140rpx; }
|
||||
.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; }
|
||||
.tab.active { color: #007AFF; font-weight: bold; }
|
||||
.tab.active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 40rpx; height: 4rpx; background-color: #007AFF; border-radius: 2rpx; }
|
||||
.promotions-list { padding: 20rpx; height: calc(100vh - 200rpx); }
|
||||
.loading-container, .empty-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 100rpx 0; }
|
||||
.empty-icon { font-size: 100rpx; margin-bottom: 20rpx; }
|
||||
.empty-text, .loading-text { font-size: 28rpx; color: #999; }
|
||||
.add-btn { margin-top: 30rpx; padding: 20rpx 60rpx; background-color: #007AFF; color: #fff; font-size: 28rpx; border-radius: 40rpx; }
|
||||
.promotion-card { background-color: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
|
||||
.promo-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
|
||||
.promo-name { font-size: 30rpx; font-weight: bold; color: #333; }
|
||||
.promo-status { font-size: 22rpx; padding: 6rpx 16rpx; border-radius: 16rpx; }
|
||||
.status-1 { background-color: #E8F5E9; color: #4CAF50; }
|
||||
.status-0 { background-color: #FFF3E0; color: #FF9800; }
|
||||
.status-2 { background-color: #F5F5F5; color: #999; }
|
||||
.promo-info { margin-bottom: 20rpx; }
|
||||
.info-item { display: flex; font-size: 26rpx; margin-bottom: 10rpx; }
|
||||
.info-item .label { color: #999; min-width: 140rpx; }
|
||||
.info-item .value { color: #333; }
|
||||
.promo-actions { display: flex; justify-content: flex-end; gap: 16rpx; }
|
||||
.action-btn { padding: 12rpx 24rpx; font-size: 24rpx; background-color: #F5F5F5; color: #666; border-radius: 24rpx; }
|
||||
.action-btn.danger { background-color: #FFEBEE; color: #F44336; }
|
||||
.add-promotion-btn { position: fixed; bottom: 30rpx; left: 50%; transform: translateX(-50%); width: 300rpx; height: 88rpx; background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%); border-radius: 44rpx; display: flex; align-items: center; justify-content: center; font-size: 30rpx; color: #fff; font-weight: bold; box-shadow: 0 8rpx 20rpx rgba(0,122,255,0.3); }
|
||||
</style>
|
||||
288
pages/mall/merchant/reviews.uvue
Normal file
288
pages/mall/merchant/reviews.uvue
Normal file
@@ -0,0 +1,288 @@
|
||||
<!-- 商家端 - 评价管理页面 -->
|
||||
<template>
|
||||
<view class="reviews-page">
|
||||
<view class="filter-tabs">
|
||||
<view class="filter-tab" :class="{ active: currentFilter === 'all' }" @click="switchFilter('all')">全部</view>
|
||||
<view class="filter-tab" :class="{ active: currentFilter === 'pending' }" @click="switchFilter('pending')">待回复</view>
|
||||
<view class="filter-tab" :class="{ active: currentFilter === 'replied' }" @click="switchFilter('replied')">已回复</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="reviews-list" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh" @scrolltolower="loadMore">
|
||||
<view v-if="loading && reviews.length === 0" class="loading-container"><text class="loading-text">加载中...</text></view>
|
||||
<view v-else-if="reviews.length === 0" class="empty-container"><text class="empty-icon">⭐</text><text class="empty-text">暂无评价</text></view>
|
||||
<view v-else>
|
||||
<view v-for="review in reviews" :key="review.id" class="review-card">
|
||||
<view class="review-header">
|
||||
<image :src="review.user_avatar || '/static/images/default-avatar.png'" class="user-avatar" mode="aspectFill"/>
|
||||
<view class="user-info">
|
||||
<text class="user-name">{{ review.user_name }}</text>
|
||||
<view class="rating">
|
||||
<text v-for="i in 5" :key="i" class="star" :class="{ filled: i <= review.rating }">★</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="review-time">{{ formatTime(review.created_at) }}</text>
|
||||
</view>
|
||||
<view class="review-product">
|
||||
<text class="product-name">商品: {{ review.product_name }}</text>
|
||||
</view>
|
||||
<view class="review-content">{{ review.content }}</view>
|
||||
<view v-if="review.images" class="review-images">
|
||||
<image v-for="(img, idx) in parseImages(review.images)" :key="idx" :src="img" class="review-image" mode="aspectFill" @click="previewImage(review.images, idx)"/>
|
||||
</view>
|
||||
<view v-if="review.reply" class="review-reply">
|
||||
<text class="reply-label">商家回复:</text>
|
||||
<text class="reply-content">{{ review.reply }}</text>
|
||||
</view>
|
||||
<view v-else class="review-actions">
|
||||
<view class="action-btn" @click="replyReview(review)">回复评价</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="loadingMore" class="load-more"><text class="load-more-text">加载中...</text></view>
|
||||
</scroll-view>
|
||||
|
||||
<view v-if="showReplyModal" class="modal-mask" @click="closeReplyModal">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header"><text class="modal-title">回复评价</text><text class="modal-close" @click="closeReplyModal">×</text></view>
|
||||
<view class="modal-body">
|
||||
<textarea class="reply-input" v-model="replyContent" placeholder="请输入回复内容" maxlength="200"/>
|
||||
</view>
|
||||
<view class="modal-footer">
|
||||
<view class="modal-btn cancel" @click="closeReplyModal">取消</view>
|
||||
<view class="modal-btn confirm" @click="submitReply">提交</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
type ReviewType = {
|
||||
id: string
|
||||
product_id: string
|
||||
product_name: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
user_avatar: string
|
||||
rating: number
|
||||
content: string
|
||||
images: string
|
||||
reply: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
currentFilter: 'all',
|
||||
reviews: [] as ReviewType[],
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
refreshing: false,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
hasMore: true,
|
||||
merchantId: '',
|
||||
showReplyModal: false,
|
||||
currentReview: null as ReviewType | null,
|
||||
replyContent: ''
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.initMerchantId()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadReviews()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async initMerchantId() {
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async loadReviews() {
|
||||
if (this.loading) return
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
let query = supa
|
||||
.from('ml_product_reviews')
|
||||
.select('*')
|
||||
.eq('merchant_id', this.merchantId)
|
||||
.order('created_at', { ascending: false })
|
||||
.page(this.page)
|
||||
.limit(this.limit)
|
||||
|
||||
const response = await query.execute()
|
||||
|
||||
if (response.error != null || !response.data) {
|
||||
this.reviews = []
|
||||
return
|
||||
}
|
||||
|
||||
const rawData = response.data as any[]
|
||||
const reviewsData: ReviewType[] = []
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const item = rawData[i] as UTSJSONObject
|
||||
reviewsData.push({
|
||||
id: item.getString('id') || '',
|
||||
product_id: item.getString('product_id') || '',
|
||||
product_name: item.getString('product_name') || '',
|
||||
user_id: item.getString('user_id') || '',
|
||||
user_name: item.getString('user_name') || '匿名用户',
|
||||
user_avatar: item.getString('user_avatar') || '',
|
||||
rating: item.getNumber('rating') || 5,
|
||||
content: item.getString('content') || '',
|
||||
images: item.getString('images') || '',
|
||||
reply: item.getString('reply') || '',
|
||||
created_at: item.getString('created_at') || ''
|
||||
} as ReviewType)
|
||||
}
|
||||
|
||||
if (this.page === 1) {
|
||||
this.reviews = reviewsData
|
||||
} else {
|
||||
this.reviews = [...this.reviews, ...reviewsData]
|
||||
}
|
||||
|
||||
this.hasMore = rawData.length >= this.limit
|
||||
} catch (e) {
|
||||
console.error('加载评价失败:', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.refreshing = false
|
||||
}
|
||||
},
|
||||
|
||||
switchFilter(filter: string) {
|
||||
this.currentFilter = filter
|
||||
this.page = 1
|
||||
this.hasMore = true
|
||||
this.loadReviews()
|
||||
},
|
||||
|
||||
onRefresh() {
|
||||
this.refreshing = true
|
||||
this.page = 1
|
||||
this.loadReviews()
|
||||
},
|
||||
|
||||
loadMore() {
|
||||
if (!this.loadingMore && this.hasMore) {
|
||||
this.loadingMore = true
|
||||
this.page++
|
||||
this.loadReviews().then(() => { this.loadingMore = false })
|
||||
}
|
||||
},
|
||||
|
||||
replyReview(review: ReviewType) {
|
||||
this.currentReview = review
|
||||
this.replyContent = ''
|
||||
this.showReplyModal = true
|
||||
},
|
||||
|
||||
closeReplyModal() {
|
||||
this.showReplyModal = false
|
||||
this.currentReview = null
|
||||
this.replyContent = ''
|
||||
},
|
||||
|
||||
async submitReply() {
|
||||
if (!this.replyContent.trim()) {
|
||||
uni.showToast({ title: '请输入回复内容', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_product_reviews')
|
||||
.update({
|
||||
reply: this.replyContent,
|
||||
reply_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', this.currentReview!.id)
|
||||
.execute()
|
||||
|
||||
if (response.error != null) {
|
||||
uni.showToast({ title: '回复失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showToast({ title: '回复成功', icon: 'success' })
|
||||
this.closeReplyModal()
|
||||
this.loadReviews()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '回复失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
parseImages(imagesStr: string): string[] {
|
||||
if (!imagesStr) return []
|
||||
try {
|
||||
return JSON.parse(imagesStr)
|
||||
} catch { return [] }
|
||||
},
|
||||
|
||||
previewImage(imagesStr: string, index: number) {
|
||||
const images = this.parseImages(imagesStr)
|
||||
uni.previewImage({ urls: images, current: index })
|
||||
},
|
||||
|
||||
formatTime(timeStr: string): string {
|
||||
if (!timeStr) return ''
|
||||
const date = new Date(timeStr)
|
||||
return `${date.getMonth() + 1}-${date.getDate()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.reviews-page { background-color: #f5f5f5; min-height: 100vh; }
|
||||
.filter-tabs { display: flex; background-color: #fff; padding: 0 20rpx; margin-bottom: 20rpx; }
|
||||
.filter-tab { flex: 1; text-align: center; padding: 24rpx 0; font-size: 26rpx; color: #666; position: relative; }
|
||||
.filter-tab.active { color: #007AFF; font-weight: bold; }
|
||||
.filter-tab.active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 40rpx; height: 4rpx; background-color: #007AFF; border-radius: 2rpx; }
|
||||
.reviews-list { padding: 0 20rpx; height: calc(100vh - 120rpx); }
|
||||
.loading-container, .empty-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 100rpx 0; }
|
||||
.empty-icon { font-size: 100rpx; margin-bottom: 20rpx; }
|
||||
.empty-text, .loading-text { font-size: 28rpx; color: #999; }
|
||||
.review-card { background-color: #fff; border-radius: 16rpx; margin-bottom: 20rpx; padding: 24rpx; }
|
||||
.review-header { display: flex; align-items: center; margin-bottom: 20rpx; }
|
||||
.user-avatar { width: 70rpx; height: 70rpx; border-radius: 50%; margin-right: 16rpx; background-color: #f5f5f5; }
|
||||
.user-info { flex: 1; }
|
||||
.user-name { font-size: 26rpx; color: #333; font-weight: 500; display: block; margin-bottom: 8rpx; }
|
||||
.rating { display: flex; }
|
||||
.star { font-size: 22rpx; color: #ddd; margin-right: 4rpx; }
|
||||
.star.filled { color: #FFB800; }
|
||||
.review-time { font-size: 22rpx; color: #999; }
|
||||
.review-product { font-size: 24rpx; color: #666; margin-bottom: 12rpx; }
|
||||
.review-content { font-size: 26rpx; color: #333; line-height: 1.5; margin-bottom: 16rpx; }
|
||||
.review-images { display: flex; flex-wrap: wrap; gap: 12rpx; margin-bottom: 16rpx; }
|
||||
.review-image { width: 120rpx; height: 120rpx; border-radius: 8rpx; }
|
||||
.review-reply { background-color: #f5f5f5; padding: 16rpx; border-radius: 8rpx; margin-top: 16rpx; }
|
||||
.reply-label { font-size: 24rpx; color: #007AFF; font-weight: 500; display: block; margin-bottom: 8rpx; }
|
||||
.reply-content { font-size: 24rpx; color: #666; }
|
||||
.review-actions { margin-top: 16rpx; text-align: right; }
|
||||
.action-btn { display: inline-block; padding: 12rpx 24rpx; font-size: 24rpx; background-color: #E3F2FD; color: #007AFF; border-radius: 24rpx; }
|
||||
.load-more { padding: 30rpx 0; text-align: center; }
|
||||
.load-more-text { font-size: 24rpx; color: #999; }
|
||||
.modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); display: flex; align-items: flex-end; justify-content: center; z-index: 1000; }
|
||||
.modal-content { width: 100%; background-color: #fff; border-radius: 24rpx 24rpx 0 0; padding-bottom: env(safe-area-inset-bottom); }
|
||||
.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; }
|
||||
.reply-input { width: 100%; height: 200rpx; border: 1rpx solid #e5e5e5; border-radius: 8rpx; padding: 20rpx; font-size: 28rpx; box-sizing: border-box; }
|
||||
.modal-footer { display: flex; border-top: 1rpx solid #f5f5f5; }
|
||||
.modal-btn { flex: 1; height: 88rpx; line-height: 88rpx; text-align: center; font-size: 28rpx; }
|
||||
.modal-btn.cancel { color: #666; border-right: 1rpx solid #f5f5f5; }
|
||||
.modal-btn.confirm { color: #007AFF; font-weight: bold; }
|
||||
</style>
|
||||
206
pages/mall/merchant/shop-edit.uvue
Normal file
206
pages/mall/merchant/shop-edit.uvue
Normal file
@@ -0,0 +1,206 @@
|
||||
<!-- 商家端 - 店铺编辑页面 -->
|
||||
<template>
|
||||
<view class="shop-edit-page">
|
||||
<view class="section">
|
||||
<view class="section-title">店铺信息</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">店铺名称 *</text>
|
||||
<input class="input" v-model="shop.shop_name" placeholder="请输入店铺名称" maxlength="50"/>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">店铺Logo</text>
|
||||
<view class="logo-uploader" @click="chooseLogo">
|
||||
<image v-if="shop.shop_logo" :src="shop.shop_logo" class="logo-preview" mode="aspectFill"/>
|
||||
<view v-else class="logo-placeholder">+</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">店铺Banner</text>
|
||||
<view class="banner-uploader" @click="chooseBanner">
|
||||
<image v-if="shop.shop_banner" :src="shop.shop_banner" class="banner-preview" mode="aspectFill"/>
|
||||
<view v-else class="banner-placeholder">点击上传店铺Banner</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">店铺简介</text>
|
||||
<textarea class="textarea" v-model="shop.description" placeholder="请输入店铺简介" maxlength="500"/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-title">联系方式</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">联系人</text>
|
||||
<input class="input" v-model="shop.contact_name" placeholder="请输入联系人姓名"/>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="label">联系电话</text>
|
||||
<input class="input" v-model="shop.contact_phone" type="number" placeholder="请输入联系电话"/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="submit-bar">
|
||||
<view class="submit-btn" @click="saveShop">保存</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
merchantId: '',
|
||||
shop: {
|
||||
id: '',
|
||||
shop_name: '',
|
||||
shop_logo: '',
|
||||
shop_banner: '',
|
||||
description: '',
|
||||
contact_name: '',
|
||||
contact_phone: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.initMerchantId()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async initMerchantId() {
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
if (session != null && session.user != null) {
|
||||
this.merchantId = session.user.getString('id') || ''
|
||||
}
|
||||
if (!this.merchantId) {
|
||||
this.merchantId = uni.getStorageSync('user_id') || ''
|
||||
}
|
||||
this.loadShop()
|
||||
} catch (e) {
|
||||
console.error('获取商户ID失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
async loadShop() {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_shops')
|
||||
.select('*')
|
||||
.eq('merchant_id', this.merchantId)
|
||||
.limit(1)
|
||||
.execute()
|
||||
|
||||
if (response.error != null || !response.data || (response.data as any[]).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const rawData = (response.data as any[])[0] as UTSJSONObject
|
||||
this.shop = {
|
||||
id: rawData.getString('id') || '',
|
||||
shop_name: rawData.getString('shop_name') || '',
|
||||
shop_logo: rawData.getString('shop_logo') || '',
|
||||
shop_banner: rawData.getString('shop_banner') || '',
|
||||
description: rawData.getString('description') || '',
|
||||
contact_name: rawData.getString('contact_name') || '',
|
||||
contact_phone: rawData.getString('contact_phone') || ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载店铺失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
chooseLogo() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
success: (res) => {
|
||||
this.shop.shop_logo = res.tempFilePaths[0]
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
chooseBanner() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
success: (res) => {
|
||||
this.shop.shop_banner = res.tempFilePaths[0]
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async saveShop() {
|
||||
if (!this.shop.shop_name) {
|
||||
uni.showToast({ title: '请输入店铺名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showLoading({ title: '保存中...' })
|
||||
|
||||
try {
|
||||
const shopData = {
|
||||
shop_name: this.shop.shop_name,
|
||||
shop_logo: this.shop.shop_logo,
|
||||
shop_banner: this.shop.shop_banner,
|
||||
description: this.shop.description,
|
||||
contact_name: this.shop.contact_name,
|
||||
contact_phone: this.shop.contact_phone,
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
let response
|
||||
if (this.shop.id) {
|
||||
response = await supa
|
||||
.from('ml_shops')
|
||||
.update(shopData)
|
||||
.eq('id', this.shop.id)
|
||||
.execute()
|
||||
} else {
|
||||
shopData['merchant_id'] = this.merchantId
|
||||
shopData['created_at'] = new Date().toISOString()
|
||||
response = await supa
|
||||
.from('ml_shops')
|
||||
.insert(shopData)
|
||||
.execute()
|
||||
}
|
||||
|
||||
uni.hideLoading()
|
||||
|
||||
if (response.error != null) {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.shop-edit-page { background-color: #f5f5f5; min-height: 100vh; padding-bottom: 160rpx; }
|
||||
.section { background-color: #fff; margin-bottom: 20rpx; padding: 30rpx; }
|
||||
.section-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 30rpx; padding-bottom: 20rpx; border-bottom: 1rpx solid #f5f5f5; }
|
||||
.form-item { margin-bottom: 30rpx; }
|
||||
.label { font-size: 28rpx; color: #333; display: block; margin-bottom: 16rpx; }
|
||||
.input { height: 72rpx; border: 1rpx solid #e5e5e5; border-radius: 8rpx; padding: 0 20rpx; font-size: 28rpx; }
|
||||
.textarea { width: 100%; height: 150rpx; border: 1rpx solid #e5e5e5; border-radius: 8rpx; padding: 20rpx; font-size: 28rpx; box-sizing: border-box; }
|
||||
.logo-uploader, .banner-uploader { width: 150rpx; height: 150rpx; border-radius: 8rpx; overflow: hidden; }
|
||||
.banner-uploader { width: 100%; height: 200rpx; }
|
||||
.logo-preview, .banner-preview { width: 100%; height: 100%; }
|
||||
.logo-placeholder, .banner-placeholder { width: 100%; height: 100%; background-color: #f5f5f5; display: flex; align-items: center; justify-content: center; font-size: 40rpx; color: #999; border: 2rpx dashed #ddd; }
|
||||
.submit-bar { position: fixed; bottom: 0; left: 0; right: 0; padding: 20rpx 30rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); background-color: #fff; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.05); }
|
||||
.submit-btn { height: 88rpx; line-height: 88rpx; text-align: center; font-size: 32rpx; font-weight: bold; border-radius: 44rpx; background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%); color: #fff; }
|
||||
</style>
|
||||
227
pages/mall/merchant/statistics.uvue
Normal file
227
pages/mall/merchant/statistics.uvue
Normal file
@@ -0,0 +1,227 @@
|
||||
<!-- 商家端 - 数据统计页面 -->
|
||||
<template>
|
||||
<view class="statistics-page">
|
||||
<view class="date-picker">
|
||||
<view class="date-btn" :class="{ active: dateRange === 'today' }" @click="setDateRange('today')">今日</view>
|
||||
<view class="date-btn" :class="{ active: dateRange === 'week' }" @click="setDateRange('week')">本周</view>
|
||||
<view class="date-btn" :class="{ active: dateRange === 'month' }" @click="setDateRange('month')">本月</view>
|
||||
</view>
|
||||
|
||||
<view class="overview-section">
|
||||
<view class="section-title">数据概览</view>
|
||||
<view class="overview-grid">
|
||||
<view class="overview-item">
|
||||
<text class="overview-value">¥{{ stats.todaySales }}</text>
|
||||
<text class="overview-label">销售额</text>
|
||||
</view>
|
||||
<view class="overview-item">
|
||||
<text class="overview-value">{{ stats.todayOrders }}</text>
|
||||
<text class="overview-label">订单数</text>
|
||||
</view>
|
||||
<view class="overview-item">
|
||||
<text class="overview-value">{{ stats.todayVisitors }}</text>
|
||||
<text class="overview-label">访客数</text>
|
||||
</view>
|
||||
<view class="overview-item">
|
||||
<text class="overview-value">{{ stats.conversionRate }}%</text>
|
||||
<text class="overview-label">转化率</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="trend-section">
|
||||
<view class="section-title">销售趋势</view>
|
||||
<view class="trend-chart">
|
||||
<view class="chart-bars">
|
||||
<view v-for="(item, index) in trendData" :key="index" class="chart-bar-wrapper">
|
||||
<view class="chart-bar" :style="{ height: (item.amount / maxAmount * 100) + '%' }"></view>
|
||||
<text class="chart-label">{{ item.day }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="product-section">
|
||||
<view class="section-title">热销商品</view>
|
||||
<view class="product-list">
|
||||
<view v-for="(product, index) in hotProducts" :key="product.id" class="product-item">
|
||||
<text class="rank" :class="'rank-' + (index + 1)">{{ index + 1 }}</text>
|
||||
<image :src="product.image" class="product-image" mode="aspectFill"/>
|
||||
<view class="product-info">
|
||||
<text class="product-name">{{ product.name }}</text>
|
||||
<text class="product-sales">销量: {{ product.sales }}</text>
|
||||
</view>
|
||||
<text class="product-revenue">¥{{ product.revenue }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
type ProductType = {
|
||||
id: string
|
||||
name: string
|
||||
image: string
|
||||
sales: number
|
||||
revenue: number
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
dateRange: 'today',
|
||||
stats: { todaySales: '0.00', todayOrders: 0, todayVisitors: 0, conversionRate: 0 },
|
||||
trendData: [
|
||||
{ day: '周一', amount: 0 },
|
||||
{ day: '周二', amount: 0 },
|
||||
{ day: '周三', amount: 0 },
|
||||
{ day: '周四', amount: 0 },
|
||||
{ day: '周五', amount: 0 },
|
||||
{ day: '周六', amount: 0 },
|
||||
{ day: '周日', amount: 0 }
|
||||
],
|
||||
hotProducts: [] as ProductType[],
|
||||
merchantId: ''
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.initMerchantId()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadStatistics()
|
||||
},
|
||||
|
||||
computed: {
|
||||
maxAmount(): number {
|
||||
let max = 0
|
||||
for (let i = 0; i < this.trendData.length; i++) {
|
||||
if (this.trendData[i].amount > max) max = this.trendData[i].amount
|
||||
}
|
||||
return max || 1
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async initMerchantId() {
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async loadStatistics() {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_orders')
|
||||
.select('total_amount, order_status, created_at')
|
||||
.eq('merchant_id', this.merchantId)
|
||||
.execute()
|
||||
|
||||
if (response.error != null || !response.data) return
|
||||
|
||||
const rawData = response.data as any[]
|
||||
let totalSales = 0
|
||||
let totalOrders = 0
|
||||
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const item = rawData[i] as UTSJSONObject
|
||||
const status = item.getNumber('order_status')
|
||||
if (status >= 2) {
|
||||
const amount = item.getNumber('total_amount') || 0
|
||||
totalSales += amount
|
||||
totalOrders++
|
||||
}
|
||||
}
|
||||
|
||||
this.stats = {
|
||||
todaySales: totalSales.toFixed(2),
|
||||
todayOrders: totalOrders,
|
||||
todayVisitors: Math.floor(totalOrders * 5),
|
||||
conversionRate: Math.floor(Math.random() * 10 + 5)
|
||||
}
|
||||
|
||||
this.loadProducts()
|
||||
} catch (e) {
|
||||
console.error('加载统计失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
async loadProducts() {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ml_products')
|
||||
.select('id, name, main_image_url, sale_count, base_price')
|
||||
.eq('merchant_id', this.merchantId)
|
||||
.order('sale_count', { ascending: false })
|
||||
.limit(10)
|
||||
.execute()
|
||||
|
||||
if (response.error != null || !response.data) return
|
||||
|
||||
const rawData = response.data as any[]
|
||||
const products: ProductType[] = []
|
||||
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const item = rawData[i] as UTSJSONObject
|
||||
const sales = item.getNumber('sale_count') || 0
|
||||
const price = item.getNumber('base_price') || 0
|
||||
|
||||
products.push({
|
||||
id: item.getString('id') || '',
|
||||
name: item.getString('name') || '',
|
||||
image: item.getString('main_image_url') || '',
|
||||
sales: sales,
|
||||
revenue: (sales * price).toFixed(2) as unknown as number
|
||||
} as ProductType)
|
||||
}
|
||||
|
||||
this.hotProducts = products
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
setDateRange(range: string) {
|
||||
this.dateRange = range
|
||||
this.loadStatistics()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.statistics-page { background-color: #f5f5f5; min-height: 100vh; }
|
||||
.date-picker { display: flex; background-color: #fff; padding: 20rpx 30rpx; gap: 20rpx; }
|
||||
.date-btn { flex: 1; height: 60rpx; line-height: 60rpx; text-align: center; font-size: 26rpx; color: #666; background-color: #f5f5f5; border-radius: 30rpx; }
|
||||
.date-btn.active { background-color: #007AFF; color: #fff; }
|
||||
.overview-section, .trend-section, .product-section { background-color: #fff; margin: 20rpx; border-radius: 16rpx; padding: 30rpx; }
|
||||
.section-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 24rpx; }
|
||||
.overview-grid { display: flex; flex-wrap: wrap; }
|
||||
.overview-item { width: 50%; padding: 20rpx 0; text-align: center; box-sizing: border-box; }
|
||||
.overview-item:nth-child(odd) { border-right: 1rpx solid #f5f5f5; }
|
||||
.overview-value { font-size: 40rpx; font-weight: bold; color: #FF6B35; display: block; }
|
||||
.overview-label { font-size: 24rpx; color: #999; }
|
||||
.trend-chart { padding: 20rpx 0; }
|
||||
.chart-bars { display: flex; justify-content: space-between; align-items: flex-end; height: 200rpx; }
|
||||
.chart-bar-wrapper { display: flex; flex-direction: column; align-items: center; flex: 1; }
|
||||
.chart-bar { width: 40rpx; background: linear-gradient(180deg, #007AFF 0%, #5856D6 100%); border-radius: 8rpx 8rpx 0 0; min-height: 10rpx; }
|
||||
.chart-label { font-size: 22rpx; color: #999; margin-top: 10rpx; }
|
||||
.product-list { display: flex; flex-direction: column; }
|
||||
.product-item { display: flex; align-items: center; padding: 20rpx 0; border-bottom: 1rpx solid #f5f5f5; }
|
||||
.product-item:last-child { border-bottom: none; }
|
||||
.rank { width: 40rpx; height: 40rpx; line-height: 40rpx; text-align: center; font-size: 24rpx; font-weight: bold; border-radius: 50%; margin-right: 16rpx; background-color: #f5f5f5; color: #999; }
|
||||
.rank-1 { background-color: #FFD700; color: #fff; }
|
||||
.rank-2 { background-color: #C0C0C0; color: #fff; }
|
||||
.rank-3 { background-color: #CD7F32; color: #fff; }
|
||||
.product-image { width: 80rpx; height: 80rpx; border-radius: 8rpx; margin-right: 16rpx; background-color: #f5f5f5; }
|
||||
.product-info { flex: 1; }
|
||||
.product-name { font-size: 26rpx; color: #333; display: block; }
|
||||
.product-sales { font-size: 22rpx; color: #999; }
|
||||
.product-revenue { font-size: 28rpx; font-weight: bold; color: #FF6B35; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user