Files
medical-mall/pages/mall/merchant/orders.uvue
2026-03-20 17:30:30 +08:00

1047 lines
24 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 商家端 - 订单管理页面 -->
<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 === 2"
class="action-btn primary"
@click.stop="shipOrder(order)"
>
发货
</view>
<view
v-if="order.order_status === 3"
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: 6, 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': 6,
'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 (
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 === 6) {
// 退款状态同时查询 0 和 6
query = query.in('order_status', [0, 6])
} 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 || (response.status ?? 200) >= 400) {
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 str = JSON.stringify(item)
const orderObj = JSON.parse(str) 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') ?? (orderObj.get('order_status') == null ? 1 : (orderObj.get('order_status') as number)),
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.get('shipping_address') != null ? (typeof orderObj.get('shipping_address') === 'string' ? orderObj.getString('shipping_address')! : JSON.stringify(orderObj.get('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 rawItem = itemsArray[j]
const itemStr = JSON.stringify(rawItem)
const orderItem = JSON.parse(itemStr) 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 row = rawData[i]
const istr = JSON.stringify(row)
const item = JSON.parse(istr) as UTSJSONObject
const status_val = item.get('order_status')
let status = 1
if (status_val != null) {
status = (typeof status_val === 'number') ? (status_val as number) : parseInt(status_val.toString())
}
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 || status === 6) 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 == null || !this.selectedLogistics?.name) {
uni.showToast({ title: '请选择物流公司', icon: 'none' })
return
}
if (!this.trackingNumber) {
uni.showToast({ title: '请输入物流单号', icon: 'none' })
return
}
try {
const payloadStr = JSON.stringify({
order_status: 3,
shipping_status: 2,
carrier_name: this.selectedLogistics?.name ?? '未知',
tracking_no: this.trackingNumber,
shipped_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
const payload = JSON.parse(payloadStr) as UTSJSONObject;
console.log('--- PAYLOAD TO SEND ---', JSON.stringify(payload));
const response = await supa.from('ml_orders').update(payload
)
.eq('id', this.currentOrder!.id)
.execute()
if (response.error != null || (response.status ?? 200) >= 400) {
let msg = '';
if (response.error != null) msg = response.error!.message;
else if (response.data != null) {
const rData = response.data as UTSJSONObject;
msg = rData.getString('message') ?? rData.getString('code') ?? JSON.stringify(rData);
}
if (!msg) msg = '请检查网络或登录状态'; uni.showToast({ title: '发货被拦截: ' + msg, icon: 'none', duration: 4500 }); console.error('SUPABASE API ERR:', response)
return
}
uni.showToast({ title: '发货成功', icon: 'success' })
this.closeShipModal()
this.loadOrders()
this.loadOrderCounts()
} catch (e) { uni.showToast({ title: '发货发生异常', icon: 'none' }); console.error(e) }
},
viewLogistics(order: OrderType) {
uni.navigateTo({
url: `/pages/mall/consumer/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 || (response.status ?? 200) >= 400) {
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 || status === 6) return '退款/售后'
if (status === 7) 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: 20rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.04);
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-name {
font-size: 26rpx;
color: #333;
font-weight: 500;
line-height: 1.4;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.product-spec {
font-size: 22rpx;
color: #999;
background-color: #f8f8f8;
padding: 4rpx 12rpx;
border-radius: 4rpx;
align-self: flex-start;
}
.product-right {
display: flex;
flex-direction: column;
align-items: flex-end;
margin-left: 20rpx;
min-width: 120rpx;
}
.product-price {
font-size: 26rpx;
color: #333;
font-weight: bold;
}
.product-quantity {
font-size: 22rpx;
color: #999;
margin-top: 8rpx;
}
.order-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx;
border-top: 1rpx solid #f8f8f8;
background-color: #fafafa;
}
.order-amount {
display: flex;
align-items: baseline;
}
.amount-label {
font-size: 22rpx;
color: #999;
}
.amount-value {
font-size: 32rpx;
color: #FF3B30;
font-weight: bold;
margin-left: 10rpx;
}
.order-actions {
display: flex;
gap: 16rpx;
}
.action-btn {
min-width: 120rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
border-radius: 28rpx;
border: 1rpx solid #eee;
padding: 0 20rpx;
}
.action-btn.primary {
background-color: #007AFF;
color: #fff;
border: none;
}
.action-btn.info {
background-color: #fff;
color: #007AFF;
border-color: #007AFF;
}
.action-btn.default {
background-color: #fff;
color: #666;
border-color: #ddd;
}
.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: 99;
}
.modal-content {
width: 100%;
background-color: #fff;
border-radius: 24rpx 24rpx 0 0;
padding-bottom: env(safe-area-inset-bottom);
position: relative;
z-index: 99;
}
.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>