Files
medical-mall/pages/mall/consumer/orders.uvue

2453 lines
76 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.
<!-- pages/mall/consumer/orders.uvue -->
<template>
<view class="orders-page" :style="ordersPageStyle">
<view class="orders-header" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="custom-nav-row" :style="{ paddingRight: headerRightReserve + 'px', height: navHeight + 'px' }">
<view class="nav-back-btn" @click="goBack">
<text class="nav-back-icon"></text>
</view>
<view class="search-shell">
<text class="search-icon-left">🔍</text>
<input
class="search-input"
type="text"
placeholder="搜索订单号、药品、器械或服务"
:value="searchKeyword"
@input="onSearchInput"
@confirm="onSearchConfirm"
/>
<text v-if="searchKeyword != ''" class="search-clear" @click="clearSearch">×</text>
</view>
</view>
</view>
<view class="biz-tabs-wrap">
<view
v-for="tab in businessTabs"
:key="tab.id"
:class="['biz-tab-item', { active: activeBizType === tab.id }]"
@click="switchBizTab(tab.id)"
>
<text class="biz-tab-text">{{ tab.name }}</text>
<text v-if="tab.count > 0" class="biz-tab-badge">{{ tab.count }}</text>
<view v-if="activeBizType === tab.id" class="biz-tab-indicator"></view>
</view>
</view>
<view class="status-tabs-wrap">
<scroll-view
class="status-tabs-scroll"
direction="horizontal"
:show-scrollbar="false"
:scroll-with-animation="true"
>
<view class="status-tabs-inner">
<view
v-for="tab in currentStatusTabs"
:key="tab.id"
:class="['status-tab-item', { active: activeStatusTab === tab.id }]"
@click="switchStatusTab(tab.id)"
>
<text class="status-tab-text">{{ tab.name }}</text>
<text v-if="tab.count > 0" class="status-tab-badge">{{ tab.count }}</text>
</view>
</view>
</scroll-view>
</view>
<scroll-view
class="orders-content"
:style="ordersContentStyle"
scroll-y="true"
:scroll-top="scrollTop"
refresher-enabled
:refresher-triggered="refreshing"
:lower-threshold="120"
@refresherrefresh="onRefresh"
@scroll="onOrdersScroll"
@scrolltolower="loadMore"
>
<view v-if="!loading && loadedOrders.length === 0" class="empty-orders">
<text class="empty-icon">📦</text>
<text class="empty-title">{{ getEmptyStateTitle() }}</text>
<text class="empty-desc">{{ getEmptyStateDescription() }}</text>
<button class="go-shopping-btn" @click="goShopping">去逛逛</button>
</view>
<view v-else class="virtual-list">
<view class="virtual-top-spacer" :style="{ height: topSpacerHeight + 'px' }"></view>
<view
v-for="order in visibleOrders"
:key="order.id"
class="virtual-order-item"
:style="{ height: getOrderCardHeight(order) + 'px' }"
>
<view class="order-card" :style="{ height: getOrderCardBodyHeight(order) + 'px' }" @click="viewOrderDetail(order)">
<view class="order-card-header">
<view class="shop-info">
<view class="shop-title-row">
<text class="shop-icon">🏥</text>
<text class="shop-name">{{ getOrderShopName(order) }}</text>
<text class="arrow-right">></text>
</view>
<text class="order-meta-line">{{ getOrderMetaLine(order) }}</text>
</view>
<view class="status-row">
<view class="status-main">
<text :class="['order-status', getStatusClass(order)]">{{ getOrderStatusText(order) }}</text>
<text v-if="getOrderStatusCountdownText(order) != ''" :class="['order-status-countdown', getStatusClass(order)]">{{ getOrderStatusCountdownText(order) }}</text>
</view>
<text v-if="shouldShowDeleteIcon(order)" class="delete-order-icon" @click.stop="deleteOrder(order.id)">🗑</text>
<text v-else class="more-btn" @click.stop="showOrderMenu(order)">⋯</text>
</view>
</view>
<view class="order-main" @click.stop="onCardContentTap(order)">
<image class="order-cover" :src="getOrderCover(order)" mode="aspectFill" lazy-load="true" />
<view class="order-main-info">
<text class="order-main-title">{{ getOrderTitle(order) }}</text>
<view v-if="order.biz_type === 'service'" class="service-meta-wrap">
<text class="service-meta-text">预约:{{ getServiceAppointmentText(order) }}</text>
<text class="service-meta-text">地址:{{ getServiceAddressText(order) }}</text>
</view>
<view v-else class="goods-meta-wrap">
<text class="goods-spec-chip">{{ getPrimaryProductSpec(order) }}</text>
<text v-if="getExtraGoodsCount(order) > 0" class="goods-extra-text">共{{ order.products.length }}件商品</text>
</view>
<view class="order-main-footer">
<text class="order-main-price">¥{{ formatAmount(getOrderPrimaryPrice(order)) }}</text>
<text class="order-main-count">{{ getOrderPrimaryCountText(order) }}</text>
</view>
</view>
</view>
<view class="order-summary">
<text class="order-time">{{ formatDate(order.create_time) }}</text>
<view class="summary-right">
<text class="summary-text">{{ getOrderSummaryPrefix(order) }}</text>
<text class="summary-price-label">实付:</text>
<text class="summary-price">¥{{ formatAmount(order.total_amount) }}</text>
</view>
</view>
<view class="order-actions" @click.stop="">
<view class="action-buttons">
<button
v-for="action in getOrderActions(order)"
:key="action.id"
:class="['action-btn', action.kind === 'primary' ? 'primary' : 'secondary']"
@click.stop="onOrderActionTap(order, action.id)"
>
{{ action.name }}
</button>
</view>
</view>
</view>
</view>
<view class="virtual-bottom-spacer" :style="{ height: bottomSpacerHeight + 'px' }"></view>
</view>
<view v-if="loadingMore" class="loading-more">
<view class="loading-spinner"></view>
<text>加载中...</text>
</view>
<view v-if="!hasMore && loadedOrders.length > 0" class="no-more">
<text>没有更多订单了</text>
</view>
<view class="safe-area"></view>
</scroll-view>
<view class="tabbar-placeholder"></view>
</view>
</template>
<script setup lang="uts">
import { computed, ref } from 'vue'
import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import { supabaseService } from '@/utils/supabaseService.uts'
import { goToLogin } from '@/utils/utils.uts'
import { formatCountdownHM, getRemainingSeconds, getUnifiedDisplayState, isOrderPayExpired, isPendingPayOrder, ORDER_STATUS_CANCELLED, ORDER_STATUS_PENDING, ORDER_STATUS_TIMEOUT_LEGACY, ORDER_TIMEOUT_CANCEL_REASON, PAYMENT_STATUS_TIMEOUT, PAYMENT_STATUS_UNPAID, type OrderStatusSource } from '@/utils/orderStatus.uts'
type BusinessTabItem = {
id: string,
name: string,
count: number
}
type StatusTabItem = {
id: string,
name: string,
count: number
}
type OrderProduct = {
id: string,
name: string,
price: number,
image: string,
spec: string,
quantity: number
}
type UnifiedServiceInfo = {
service_name: string,
service_image: string,
appointment_time: string,
address: string,
contact_name: string,
contact_phone: string,
provider_name: string
}
type OrderItem = {
id: string,
order_no: string,
biz_type: string,
source: string,
status: number,
payment_status: number,
cancel_reason: string,
pay_expire_at: string,
create_time: string,
product_amount: number,
shipping_fee: number,
total_amount: number,
merchant_id: string,
shop_name: string,
products: OrderProduct[],
service_info: UnifiedServiceInfo | null
}
type UnifiedOrdersCursorResult = {
list: OrderItem[],
nextCursor: string,
hasMore: boolean
}
type OrderActionItem = {
id: string,
name: string,
kind: string
}
const SEARCH_HEADER_HEIGHT = 44
const BIZ_TABS_HEIGHT = 44
const STATUS_TABS_HEIGHT = 42
const TABBAR_HEIGHT = 50
const ORDER_CARD_GAP = 10
const ORDER_HEADER_HEIGHT = 44
const ORDER_PRODUCT_HEIGHT = 88
const ORDER_SERVICE_PRODUCT_HEIGHT = 104
const ORDER_SUMMARY_HEIGHT = 36
const ORDER_ACTIONS_HEIGHT = 54
const ORDER_CARD_AVERAGE_HEIGHT = 232
const ORDER_VISIBLE_PRODUCT_LIMIT = 1
const VIRTUAL_BUFFER = 5
const PAGE_SIZE = 10
const PREFETCH_THRESHOLD = 5
const loadedOrders = ref<OrderItem[]>([])
const visibleOrders = ref<OrderItem[]>([])
const loading = ref<boolean>(false)
const loadingMore = ref<boolean>(false)
const refreshing = ref<boolean>(false)
const hasMore = ref<boolean>(true)
const initialized = ref<boolean>(false)
const pageSize = ref<number>(PAGE_SIZE)
const nextCursor = ref<string>('')
const totalLoaded = ref<number>(0)
const scrollTop = ref<number>(0)
const viewportHeight = ref<number>(0)
const pageWindowHeight = ref<number>(0)
const isAndroidApp = ref<boolean>(false)
const startIndex = ref<number>(0)
const endIndex = ref<number>(0)
const topSpacerHeight = ref<number>(0)
const bottomSpacerHeight = ref<number>(0)
const activeBizType = ref<string>('all')
const activeStatusTab = ref<string>('all')
const searchKeyword = ref<string>('')
const statusBarHeight = ref<number>(0)
const headerRightReserve = ref<number>(12)
const navHeight = ref<number>(44)
const goodsStatusCounts = ref<UTSJSONObject>(createCountBucket())
const serviceStatusCounts = ref<UTSJSONObject>(createCountBucket())
let searchTimer: number = 0
const nowTick = ref<number>(Date.now())
let orderTicker = 0
const expiringOrderIds: string[] = []
function toOrderStatusSource(order: OrderItem): OrderStatusSource {
let inferredPaymentStatus = order.payment_status
if (inferredPaymentStatus <= 0) {
if (order.status == ORDER_STATUS_PENDING) {
inferredPaymentStatus = PAYMENT_STATUS_UNPAID
} else if (order.status == ORDER_STATUS_CANCELLED || order.status == ORDER_STATUS_TIMEOUT_LEGACY) {
inferredPaymentStatus = PAYMENT_STATUS_TIMEOUT
} else {
inferredPaymentStatus = 2
}
}
return {
source: order.source,
order_status: order.status,
payment_status: inferredPaymentStatus,
pay_expire_at: order.pay_expire_at,
created_at: order.create_time,
cancel_reason: order.cancel_reason
}
}
function isTimeoutOrder(order: OrderItem): boolean {
return isOrderPayExpired(toOrderStatusSource(order))
}
function isPendingPayDisplayOrder(order: OrderItem): boolean {
return isPendingPayOrder(toOrderStatusSource(order))
}
function getOrderStatusCountdownText(order: OrderItem): string {
const currentTick = nowTick.value
if (currentTick < 0) {
return ''
}
if (!isPendingPayDisplayOrder(order)) {
return ''
}
const seconds = getRemainingSeconds(toOrderStatusSource(order))
if (seconds <= 0) {
return ''
}
return formatCountdownHM(seconds)
}
function shouldShowDeleteIcon(order: OrderItem): boolean {
const displayState = getUnifiedDisplayState(toOrderStatusSource(order))
return displayState == 'cancelled' || displayState == 'expired'
}
function isExpiring(orderId: string): boolean {
return expiringOrderIds.indexOf(orderId) >= 0
}
function pushExpiringOrder(orderId: string): void {
if (!isExpiring(orderId)) {
expiringOrderIds.push(orderId)
}
}
function removeExpiringOrder(orderId: string): void {
const index = expiringOrderIds.indexOf(orderId)
if (index >= 0) {
expiringOrderIds.splice(index, 1)
}
}
function syncExpiredOrders(): void {
for (let i = 0; i < loadedOrders.value.length; i++) {
const order = loadedOrders.value[i]
const source = toOrderStatusSource(order)
if (isPendingPayDisplayOrder(order) && isOrderPayExpired(source) && !isExpiring(order.id)) {
pushExpiringOrder(order.id)
const expiredStatus = order.biz_type === 'service' ? ORDER_STATUS_TIMEOUT_LEGACY : ORDER_STATUS_CANCELLED
updateLoadedOrderStatus(order.id, expiredStatus, PAYMENT_STATUS_TIMEOUT, ORDER_TIMEOUT_CANCEL_REASON, order.pay_expire_at)
supabaseService.expireUnifiedOrder(order.id, order.source).finally(() => {
removeExpiringOrder(order.id)
loadOrderCounts()
})
}
}
}
function startOrderTicker(): void {
if (orderTicker > 0) {
return
}
nowTick.value = Date.now()
syncExpiredOrders()
orderTicker = setInterval(() => {
nowTick.value = Date.now()
syncExpiredOrders()
}, 1000)
}
function stopOrderTicker(): void {
if (orderTicker > 0) {
clearInterval(orderTicker)
orderTicker = 0
}
}
function createCountBucket(): UTSJSONObject {
const bucket = new UTSJSONObject()
bucket.set('all', 0)
bucket.set('pending', 0)
bucket.set('inprogress', 0)
bucket.set('shipping', 0)
bucket.set('delivering', 0)
bucket.set('completed', 0)
bucket.set('aftersale', 0)
bucket.set('accepted', 0)
bucket.set('scheduled', 0)
bucket.set('inservice', 0)
return bucket
}
function getBucketCount(bucket: UTSJSONObject, key: string): number {
const value = bucket.getNumber(key)
if (value != null) {
return value
}
return 0
}
function setBucketCount(bucket: UTSJSONObject, key: string, value: number): void {
bucket.set(key, value)
}
function goBack(): void {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.switchTab({ url: '/pages/mall/consumer/profile' })
}
}
const businessTabs = computed((): BusinessTabItem[] => {
const tabs: BusinessTabItem[] = []
tabs.push({ id: 'all', name: '全部', count: getBucketCount(goodsStatusCounts.value, 'all') + getBucketCount(serviceStatusCounts.value, 'all') })
tabs.push({ id: 'goods', name: '购物', count: getBucketCount(goodsStatusCounts.value, 'all') })
tabs.push({ id: 'service', name: '服务', count: getBucketCount(serviceStatusCounts.value, 'all') })
return tabs
})
const currentStatusTabs = computed((): StatusTabItem[] => {
const tabs: StatusTabItem[] = []
if (activeBizType.value === 'service') {
tabs.push({ id: 'all', name: '全部', count: getBucketCount(serviceStatusCounts.value, 'all') })
tabs.push({ id: 'pending', name: '待付款', count: getBucketCount(serviceStatusCounts.value, 'pending') })
tabs.push({ id: 'accepted', name: '待接单', count: getBucketCount(serviceStatusCounts.value, 'accepted') })
tabs.push({ id: 'scheduled', name: '待服务', count: getBucketCount(serviceStatusCounts.value, 'scheduled') })
tabs.push({ id: 'inservice', name: '服务中', count: getBucketCount(serviceStatusCounts.value, 'inservice') })
tabs.push({ id: 'completed', name: '已完成', count: getBucketCount(serviceStatusCounts.value, 'completed') })
tabs.push({ id: 'aftersale', name: '售后', count: getBucketCount(serviceStatusCounts.value, 'aftersale') })
return tabs
}
if (activeBizType.value === 'goods') {
tabs.push({ id: 'all', name: '全部', count: getBucketCount(goodsStatusCounts.value, 'all') })
tabs.push({ id: 'pending', name: '待付款', count: getBucketCount(goodsStatusCounts.value, 'pending') })
tabs.push({ id: 'shipping', name: '待发货', count: getBucketCount(goodsStatusCounts.value, 'shipping') })
tabs.push({ id: 'delivering', name: '待收货', count: getBucketCount(goodsStatusCounts.value, 'delivering') })
tabs.push({ id: 'completed', name: '已完成', count: getBucketCount(goodsStatusCounts.value, 'completed') })
tabs.push({ id: 'aftersale', name: '售后', count: getBucketCount(goodsStatusCounts.value, 'aftersale') })
return tabs
}
tabs.push({ id: 'all', name: '全部', count: getBucketCount(goodsStatusCounts.value, 'all') + getBucketCount(serviceStatusCounts.value, 'all') })
tabs.push({ id: 'pending', name: '待付款', count: getBucketCount(goodsStatusCounts.value, 'pending') + getBucketCount(serviceStatusCounts.value, 'pending') })
tabs.push({ id: 'inprogress', name: '进行中', count: getBucketCount(goodsStatusCounts.value, 'inprogress') + getBucketCount(serviceStatusCounts.value, 'accepted') + getBucketCount(serviceStatusCounts.value, 'scheduled') + getBucketCount(serviceStatusCounts.value, 'inservice') })
tabs.push({ id: 'completed', name: '已完成', count: getBucketCount(goodsStatusCounts.value, 'completed') + getBucketCount(serviceStatusCounts.value, 'completed') })
tabs.push({ id: 'aftersale', name: '售后', count: getBucketCount(goodsStatusCounts.value, 'aftersale') + getBucketCount(serviceStatusCounts.value, 'aftersale') })
return tabs
})
const ordersPageStyle = computed((): string => {
if (!isAndroidApp.value) {
return ''
}
if (pageWindowHeight.value <= 0) {
return ''
}
return 'height:' + pageWindowHeight.value + 'px;min-height:' + pageWindowHeight.value + 'px;'
})
const ordersContentStyle = computed((): string => {
if (!isAndroidApp.value) {
return ''
}
if (viewportHeight.value <= 0) {
return ''
}
return 'height:' + viewportHeight.value + 'px;min-height:' + viewportHeight.value + 'px;'
})
const getOptionString = (options: UTSJSONObject, key: string): string => {
try {
const value = options.getString(key)
if (value != null) {
return value
}
} catch (e) {
}
try {
const normalized = JSON.parse(JSON.stringify(options)) as UTSJSONObject
const value = normalized.getString(key)
if (value != null) {
return value
}
} catch (e) {
}
return ''
}
function unwrapJsonScalar(value: UTSJSONObject | string | number | null): string {
if (value == null) return ''
const raw = JSON.stringify(value)
if (raw == 'null') return ''
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
return raw.substring(1, raw.length - 1)
}
return raw
}
function formatSpecObj(obj: UTSJSONObject | string | number | null): string {
if (obj == null) return ''
const scalar = unwrapJsonScalar(obj)
if (scalar == '') return ''
if (!scalar.startsWith('{') && !scalar.startsWith('[')) {
return scalar
}
try {
const objParsed = JSON.parse(scalar)
if (objParsed == null) return ''
const specObj = objParsed as UTSJSONObject
const specObjStr = JSON.stringify(specObj)
const specObjForKeys = JSON.parse(specObjStr) as UTSJSONObject
const parts: string[] = []
const colorVal = specObjForKeys.getString('Color')
if (colorVal != null && colorVal != '') {
parts.push('Color: ' + colorVal)
}
const sizeVal = specObjForKeys.getString('Size')
if (sizeVal != null && sizeVal != '') {
parts.push('Size: ' + sizeVal)
}
const defaultVal = specObjForKeys.getString('默认')
if (defaultVal != null && defaultVal != '') {
parts.push('默认: ' + defaultVal)
}
if (parts.length === 0) {
return specObjStr.replace(/[{}\"]/g, '').replace(/:/g, ': ').replace(/,/g, ' | ')
}
return parts.join(' | ')
} catch (e) {
return ''
}
}
function parseSpecText(specs: UTSJSONObject | string | number | null): string {
if (specs == null) return ''
const scalar = unwrapJsonScalar(specs)
if (scalar == '') return ''
if (scalar.startsWith('{') || scalar.startsWith('[')) {
try {
const parsed = JSON.parse(scalar)
if (parsed == null) return scalar
return formatSpecObj(parsed)
} catch (e) {
return scalar
}
}
return scalar
}
function mapServiceInfo(rawValue: UTSJSONObject | string | number | null): UnifiedServiceInfo | null {
if (rawValue == null) {
return null
}
try {
const rawText = unwrapJsonScalar(rawValue)
if (rawText == '') {
return null
}
let infoObj: UTSJSONObject | null = null
if (rawText.startsWith('{')) {
const parsed = JSON.parse(rawText)
if (parsed != null) {
infoObj = parsed as UTSJSONObject
}
} else if (rawValue instanceof UTSJSONObject) {
infoObj = rawValue
}
if (infoObj == null) {
return null
}
return {
service_name: infoObj.getString('service_name') ?? '',
service_image: infoObj.getString('service_image') ?? '/static/images/default.png',
appointment_time: infoObj.getString('appointment_time') ?? '',
address: infoObj.getString('address') ?? '',
contact_name: infoObj.getString('contact_name') ?? '',
contact_phone: infoObj.getString('contact_phone') ?? '',
provider_name: infoObj.getString('provider_name') ?? ''
}
} catch (e) {
return null
}
}
const mapRawOrdersToViewModels = (rawOrders: UTSJSONObject[]): OrderItem[] => {
const mappedOrders: OrderItem[] = []
for (let i = 0; i < rawOrders.length; i++) {
const order = rawOrders[i]
const orderStr = JSON.stringify(order)
const orderParsed = JSON.parse(orderStr)
if (orderParsed == null) continue
const orderObj = orderParsed as UTSJSONObject
const itemsRaw = orderObj.get('ml_order_items')
const productsList: OrderProduct[] = []
if (itemsRaw != null && Array.isArray(itemsRaw)) {
const items = itemsRaw as UTSJSONObject[]
for (let j = 0; j < items.length; j++) {
const item = items[j]
const itemStr = JSON.stringify(item)
const itemParsed = JSON.parse(itemStr)
if (itemParsed == null) continue
const itemObj = itemParsed as UTSJSONObject
const specRaw = itemObj.get('specifications')
const specText = specRaw != null ? parseSpecText(specRaw) : ''
const productId = itemObj.getString('product_id')
const productName = itemObj.getString('product_name')
const price = itemObj.getNumber('price')
const imageUrl = itemObj.getString('image_url') ?? itemObj.getString('product_image')
const quantity = itemObj.getNumber('quantity')
productsList.push({
id: productId ?? '',
name: productName ?? '订单商品',
price: price ?? 0,
image: imageUrl ?? '/static/images/default.png',
spec: specText,
quantity: quantity ?? 1
})
}
}
const orderId = orderObj.getString('id')
const orderNo = orderObj.getString('order_no')
const orderStatus = orderObj.getNumber('order_status')
const createdAt = orderObj.getString('created_at')
const productAmount = orderObj.getNumber('product_amount')
const shippingFee = orderObj.getNumber('shipping_fee')
const totalAmount = orderObj.getNumber('total_amount')
const paidAmount = orderObj.getNumber('paid_amount')
const merchantId = orderObj.getString('merchant_id')
const bizTypeRaw = orderObj.getString('biz_type')
const bizType = bizTypeRaw != null && bizTypeRaw != '' ? bizTypeRaw : 'goods'
let shopName = '康养商城'
const shopsRaw = orderObj.get('ml_shops')
if (shopsRaw != null) {
const shopStr = JSON.stringify(shopsRaw)
const shopParsed = JSON.parse(shopStr)
if (shopParsed != null) {
const shopObj = shopParsed as UTSJSONObject
const shopNameFromDb = shopObj.getString('shop_name')
if (shopNameFromDb != null && shopNameFromDb != '') {
shopName = shopNameFromDb
}
}
} else if (merchantId != null && merchantId != '') {
shopName = '商家店铺'
}
const serviceInfo = mapServiceInfo(orderObj.get('service_info'))
if (productsList.length === 0 && bizType !== 'service') {
productsList.push({
id: 'placeholder',
name: '订单商品',
price: totalAmount ?? paidAmount ?? 0,
image: '/static/images/default.png',
spec: '',
quantity: 1
})
}
mappedOrders.push({
id: orderId ?? '',
order_no: orderNo ?? '',
biz_type: bizType,
source: orderObj.getString('source') ?? (bizType == 'service' ? 'service' : 'goods'),
status: orderStatus ?? 1,
payment_status: orderObj.getNumber('payment_status') ?? 1,
cancel_reason: orderObj.getString('cancel_reason') ?? '',
pay_expire_at: orderObj.getString('pay_expire_at') ?? '',
create_time: createdAt ?? '',
product_amount: productAmount ?? 0,
shipping_fee: shippingFee ?? 0,
total_amount: totalAmount ?? paidAmount ?? 0,
merchant_id: merchantId ?? '',
shop_name: shopName,
products: productsList,
service_info: serviceInfo
})
}
return mappedOrders
}
const mergeOrders = (currentOrders: OrderItem[], appendOrders: OrderItem[]): OrderItem[] => {
const merged: OrderItem[] = []
for (let i = 0; i < currentOrders.length; i++) {
merged.push(currentOrders[i])
}
for (let i = 0; i < appendOrders.length; i++) {
const nextOrder = appendOrders[i]
let exists = false
for (let j = 0; j < merged.length; j++) {
if (merged[j].id === nextOrder.id) {
merged[j] = nextOrder
exists = true
break
}
}
if (!exists) {
merged.push(nextOrder)
}
}
return merged
}
const resetListState = () => {
loadedOrders.value = []
visibleOrders.value = []
totalLoaded.value = 0
scrollTop.value = 0
startIndex.value = 0
endIndex.value = 0
topSpacerHeight.value = 0
bottomSpacerHeight.value = 0
nextCursor.value = ''
hasMore.value = true
}
const getShortOrderNo = (orderNo: string): string => {
if (orderNo == '') {
return '--'
}
if (orderNo.length <= 12) {
return orderNo
}
return orderNo.substring(orderNo.length - 12)
}
const formatAmount = (value: number): string => {
return value.toFixed(2)
}
function formatOrderHeaderTime(isoString: string): string {
if (isoString == '') return '--'
const date = new Date(isoString)
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}`
}
function getOrderMetaLine(order: OrderItem): string {
return `订单号 ${getShortOrderNo(order.order_no)} · 下单 ${formatOrderHeaderTime(order.create_time)}`
}
const getPrimaryOrderProduct = (order: OrderItem): OrderProduct | null => {
if (order.products.length > 0) {
return order.products[0]
}
return null
}
const getExtraGoodsCount = (order: OrderItem): number => {
const hiddenCount = order.products.length - ORDER_VISIBLE_PRODUCT_LIMIT
if (hiddenCount > 0) {
return hiddenCount
}
return 0
}
const getOrderCover = (order: OrderItem): string => {
if (order.biz_type === 'service' && order.service_info != null && order.service_info.service_image != '') {
return order.service_info.service_image
}
const product = getPrimaryOrderProduct(order)
if (product != null && product.image != '') {
return product.image
}
return '/static/images/default.png'
}
const getOrderTitle = (order: OrderItem): string => {
if (order.biz_type === 'service' && order.service_info != null && order.service_info.service_name != '') {
return order.service_info.service_name
}
const product = getPrimaryOrderProduct(order)
if (product != null && product.name != '') {
return product.name
}
return '订单商品'
}
const getPrimaryProductSpec = (order: OrderItem): string => {
const product = getPrimaryOrderProduct(order)
if (product != null && product.spec != '') {
return product.spec
}
return '默认规格'
}
const getServiceAppointmentText = (order: OrderItem): string => {
if (order.service_info != null && order.service_info.appointment_time != '') {
return order.service_info.appointment_time
}
return '待确认'
}
const getServiceAddressText = (order: OrderItem): string => {
if (order.service_info != null && order.service_info.address != '') {
return order.service_info.address
}
return '服务地址待确认'
}
const getOrderPrimaryPrice = (order: OrderItem): number => {
const product = getPrimaryOrderProduct(order)
if (product != null) {
return product.price
}
return order.total_amount
}
const getOrderPrimaryCountText = (order: OrderItem): string => {
if (order.biz_type === 'service') {
return '服务预约'
}
const product = getPrimaryOrderProduct(order)
if (product != null) {
return 'x' + product.quantity
}
return 'x1'
}
const getOrderItemCount = (order: OrderItem): number => {
if (order.biz_type === 'service') {
return 1
}
if (order.products.length > 0) {
return order.products.length
}
return 1
}
const getOrderSummaryPrefix = (order: OrderItem): string => {
if (order.biz_type === 'service') {
return '共1项服务 '
}
return '含运费¥' + formatAmount(order.shipping_fee) + ' 共' + getOrderItemCount(order) + '件商品 '
}
const getOrderProductBlockHeight = (order: OrderItem): number => {
if (order.biz_type === 'service') {
return ORDER_SERVICE_PRODUCT_HEIGHT
}
return ORDER_PRODUCT_HEIGHT
}
function getOrderCardBodyHeight(order: OrderItem): number {
return ORDER_HEADER_HEIGHT + getOrderProductBlockHeight(order) + ORDER_SUMMARY_HEIGHT + ORDER_ACTIONS_HEIGHT
}
function getOrderCardHeight(order: OrderItem): number {
return getOrderCardBodyHeight(order) + ORDER_CARD_GAP
}
function getAccumulatedHeight(endIndexExclusive: number): number {
let totalHeight = 0
for (let i = 0; i < endIndexExclusive; i++) {
totalHeight += getOrderCardHeight(loadedOrders.value[i])
}
return totalHeight
}
function getIndexByScrollTop(currentScrollTop: number): number {
let accumulatedHeight = 0
for (let i = 0; i < loadedOrders.value.length; i++) {
accumulatedHeight += getOrderCardHeight(loadedOrders.value[i])
if (accumulatedHeight > currentScrollTop) {
return i
}
}
return Math.max(0, loadedOrders.value.length - 1)
}
const updateVisibleWindow = () => {
const total = loadedOrders.value.length
totalLoaded.value = total
if (total === 0) {
visibleOrders.value = []
topSpacerHeight.value = 0
bottomSpacerHeight.value = 0
startIndex.value = 0
endIndex.value = 0
return
}
const rawStart = getIndexByScrollTop(scrollTop.value)
const start = Math.max(0, rawStart - VIRTUAL_BUFFER)
const targetHeight = viewportHeight.value + (VIRTUAL_BUFFER * ORDER_CARD_AVERAGE_HEIGHT)
let end = start
let renderedHeight = 0
while (end < total && renderedHeight < targetHeight) {
renderedHeight += getOrderCardHeight(loadedOrders.value[end])
end++
}
if (end < total) {
end = Math.min(total, end + VIRTUAL_BUFFER)
}
startIndex.value = start
endIndex.value = end
visibleOrders.value = loadedOrders.value.slice(start, end)
topSpacerHeight.value = getAccumulatedHeight(start)
const totalHeight = getAccumulatedHeight(total)
const visibleHeight = getAccumulatedHeight(end) - topSpacerHeight.value
bottomSpacerHeight.value = Math.max(0, totalHeight - topSpacerHeight.value - visibleHeight)
}
function matchesGoodsStatusTab(status: number): boolean {
if (activeStatusTab.value === 'all') return true
if (activeStatusTab.value === 'pending') return status === 1
if (activeStatusTab.value === 'shipping') return status === 2
if (activeStatusTab.value === 'delivering') return status === 3
if (activeStatusTab.value === 'completed') return status === 4
if (activeStatusTab.value === 'aftersale') return status === 6 || status === 7
if (activeStatusTab.value === 'inprogress') return status === 2 || status === 3
return true
}
function matchesServiceStatusTab(status: number): boolean {
if (activeStatusTab.value === 'all') return true
if (activeStatusTab.value === 'pending') return status === 1
if (activeStatusTab.value === 'accepted') return status === 2
if (activeStatusTab.value === 'scheduled') return status === 3
if (activeStatusTab.value === 'inservice') return status === 4
if (activeStatusTab.value === 'completed') return status === 5
if (activeStatusTab.value === 'aftersale') return status === 6 || status === 7
return true
}
function matchesCurrentFilters(order: OrderItem): boolean {
if (activeBizType.value === 'goods' && order.biz_type !== 'goods') {
return false
}
if (activeBizType.value === 'service' && order.biz_type !== 'service') {
return false
}
if (order.biz_type === 'service') {
return matchesServiceStatusTab(order.status)
}
if (isTimeoutOrder(order)) {
return activeStatusTab.value === 'all'
}
return matchesGoodsStatusTab(order.status)
}
const getLoadedOrderById = (orderId: string): OrderItem | null => {
for (let i = 0; i < loadedOrders.value.length; i++) {
const order = loadedOrders.value[i]
if (order.id === orderId) {
return order
}
}
return null
}
const removeLoadedOrder = (orderId: string) => {
loadedOrders.value = loadedOrders.value.filter((order: OrderItem) => order.id !== orderId)
updateVisibleWindow()
}
const updateLoadedOrderStatus = (orderId: string, nextStatus: number, nextPaymentStatus: number = 0, nextCancelReason: string = '', nextPayExpireAt: string = ''): OrderItem | null => {
let targetOrder: OrderItem | null = null
const nextList: OrderItem[] = []
for (let i = 0; i < loadedOrders.value.length; i++) {
const currentOrder = loadedOrders.value[i]
if (currentOrder.id === orderId) {
currentOrder.status = nextStatus
if (nextPaymentStatus > 0) {
currentOrder.payment_status = nextPaymentStatus
}
if (nextCancelReason != '') {
currentOrder.cancel_reason = nextCancelReason
}
if (nextPayExpireAt != '') {
currentOrder.pay_expire_at = nextPayExpireAt
}
targetOrder = currentOrder
if (matchesCurrentFilters(currentOrder)) {
nextList.push(currentOrder)
}
} else {
nextList.push(currentOrder)
}
}
loadedOrders.value = nextList
updateVisibleWindow()
return targetOrder
}
function getGoodsStatusCodeByTab(tabId: string): number {
if (tabId == 'pending') return 1
if (tabId == 'shipping') return 2
if (tabId == 'delivering') return 3
if (tabId == 'completed') return 4
if (tabId == 'aftersale') return 6
return 0
}
async function getUnifiedOrdersCompatByCursor(): Promise<UnifiedOrdersCursorResult> {
const result = await supabaseService.getUnifiedOrdersByCursor({
cursor: nextCursor.value,
limit: pageSize.value,
bizType: activeBizType.value,
statusTab: activeStatusTab.value,
keyword: searchKeyword.value.trim()
})
return {
list: mapRawOrdersToViewModels(result.list),
nextCursor: result.nextCursor,
hasMore: result.hasMore
}
}
const loadOrderCounts = async () => {
const nextGoodsCounts = createCountBucket()
const nextServiceCounts = createCountBucket()
try {
const counts = await supabaseService.getOrderCounts()
setBucketCount(nextGoodsCounts, 'all', counts.all)
setBucketCount(nextGoodsCounts, 'pending', counts.pending)
setBucketCount(nextGoodsCounts, 'shipping', counts.shipping)
setBucketCount(nextGoodsCounts, 'delivering', counts.delivering)
setBucketCount(nextGoodsCounts, 'inprogress', counts.shipping + counts.delivering)
setBucketCount(nextGoodsCounts, 'completed', counts.completed)
setBucketCount(nextGoodsCounts, 'aftersale', counts.aftersale)
const serviceCounts = await supabaseService.getServiceOrderStatusCounts()
setBucketCount(nextServiceCounts, 'all', serviceCounts.getNumber('all') ?? 0)
setBucketCount(nextServiceCounts, 'pending', serviceCounts.getNumber('pending') ?? 0)
setBucketCount(nextServiceCounts, 'accepted', serviceCounts.getNumber('accepted') ?? 0)
setBucketCount(nextServiceCounts, 'scheduled', serviceCounts.getNumber('scheduled') ?? 0)
setBucketCount(nextServiceCounts, 'inservice', serviceCounts.getNumber('inservice') ?? 0)
setBucketCount(nextServiceCounts, 'completed', serviceCounts.getNumber('completed') ?? 0)
setBucketCount(nextServiceCounts, 'aftersale', serviceCounts.getNumber('aftersale') ?? 0)
} catch (e) {
console.error('[orders] 加载订单数量失败:', e)
}
goodsStatusCounts.value = nextGoodsCounts
serviceStatusCounts.value = nextServiceCounts
}
async function loadOrders(reset: boolean): Promise<void> {
if (loading.value || loadingMore.value) {
return
}
if (!reset && !hasMore.value) {
return
}
if (reset) {
loading.value = true
hasMore.value = true
nextCursor.value = ''
} else {
loadingMore.value = true
}
try {
const result = await getUnifiedOrdersCompatByCursor()
if (reset) {
loadedOrders.value = result.list
scrollTop.value = 0
} else {
loadedOrders.value = mergeOrders(loadedOrders.value, result.list)
}
nextCursor.value = result.nextCursor
hasMore.value = result.hasMore
updateVisibleWindow()
initialized.value = true
} catch (err) {
console.error('[orders] 加载订单失败:', err)
uni.showToast({ title: '加载订单失败', icon: 'none' })
} finally {
loading.value = false
loadingMore.value = false
refreshing.value = false
}
}
function loadMore(): void {
if (loadedOrders.value.length > 0 && loadedOrders.value.length % PREFETCH_THRESHOLD == 0) {
loadOrders(false)
return
}
loadOrders(false)
}
const refreshCurrentSlice = async () => {
resetListState()
await loadOrders(true)
await loadOrderCounts()
}
function applyLegacyOptions(status: string, type: string): void {
if (type == 'service') {
activeBizType.value = 'service'
activeStatusTab.value = 'all'
} else if (type == 'goods') {
activeBizType.value = 'goods'
activeStatusTab.value = 'all'
} else if (type == 'pending') {
activeBizType.value = 'all'
activeStatusTab.value = 'pending'
} else if (type == 'shipped') {
activeBizType.value = 'goods'
activeStatusTab.value = 'delivering'
} else if (type == 'review') {
activeBizType.value = 'all'
activeStatusTab.value = 'completed'
} else if (type == 'refund') {
activeBizType.value = 'all'
activeStatusTab.value = 'aftersale'
}
if (status == 'all') {
activeBizType.value = 'all'
activeStatusTab.value = 'all'
} else if (status == 'pending') {
activeStatusTab.value = 'pending'
} else if (status == 'shipping') {
activeBizType.value = 'goods'
activeStatusTab.value = 'shipping'
} else if (status == 'delivering') {
activeBizType.value = 'goods'
activeStatusTab.value = 'delivering'
} else if (status == 'completed') {
activeStatusTab.value = 'completed'
} else if (status == 'aftersale') {
activeStatusTab.value = 'aftersale'
}
}
onLoad((options) => {
uni.$on('orderUpdated', handleOrderUpdated)
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight ?? 0
let windowWidth = systemInfo.windowWidth ?? 375
headerRightReserve.value = 12
navHeight.value = 44
// #ifdef MP-WEIXIN
try {
const menuButton = uni.getMenuButtonBoundingClientRect()
headerRightReserve.value = windowWidth - menuButton.left + 8
navHeight.value = menuButton.height + 8
} catch (e) {
headerRightReserve.value = 96
navHeight.value = 44
}
// #endif
// #ifndef MP-WEIXIN
headerRightReserve.value = 12
navHeight.value = 44
// #endif
pageWindowHeight.value = systemInfo.windowHeight ?? systemInfo.screenHeight ?? 0
const windowHeight = pageWindowHeight.value
const calculatedViewport = windowHeight - statusBarHeight.value - navHeight.value - BIZ_TABS_HEIGHT - STATUS_TABS_HEIGHT - TABBAR_HEIGHT
viewportHeight.value = calculatedViewport > 0 ? calculatedViewport : windowHeight
// #ifdef APP-ANDROID
isAndroidApp.value = true
// #endif
if (options == null) return
const optionsObj = JSON.parse(JSON.stringify(options)) as UTSJSONObject
const status = getOptionString(optionsObj, 'status')
const type = getOptionString(optionsObj, 'type')
applyLegacyOptions(status, type)
})
onUnload(() => {
stopOrderTicker()
uni.$off('orderUpdated', handleOrderUpdated)
})
onHide(() => {
stopOrderTicker()
})
onShow(() => {
startOrderTicker()
if (!initialized.value) {
loadOrders(true)
loadOrderCounts()
}
})
const handleOrderUpdated = (payload: any) => {
try {
const payloadObj = JSON.parse(JSON.stringify(payload)) as UTSJSONObject
const targetOrderId = payloadObj.getString('orderId') ?? ''
if (targetOrderId == '') {
return
}
const nextStatus = payloadObj.getNumber('status') ?? 0
const nextPaymentStatus = payloadObj.getNumber('paymentStatus') ?? 0
const nextCancelReason = payloadObj.getString('cancelReason') ?? ''
updateLoadedOrderStatus(targetOrderId, nextStatus, nextPaymentStatus, nextCancelReason)
loadOrderCounts()
} catch (e) {
refreshCurrentSlice()
}
}
const formatDate = (isoString: string): string => {
if (isoString == '') return ''
const date = new Date(isoString)
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
}
const performSearch = () => {
resetListState()
loadOrders(true)
}
const onSearchInput = (e: UniInputEvent) => {
searchKeyword.value = e.detail.value as string
if (searchTimer > 0) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
performSearch()
}, 500)
}
const onSearchConfirm = () => {
if (searchTimer > 0) {
clearTimeout(searchTimer)
searchTimer = 0
}
performSearch()
}
const clearSearch = () => {
if (searchTimer > 0) {
clearTimeout(searchTimer)
searchTimer = 0
}
searchKeyword.value = ''
performSearch()
}
const switchBizTab = (tabId: string) => {
if (activeBizType.value === tabId) return
activeBizType.value = tabId
activeStatusTab.value = 'all'
resetListState()
loadOrders(true)
loadOrderCounts()
}
const switchStatusTab = (tabId: string) => {
if (activeStatusTab.value === tabId) return
activeStatusTab.value = tabId
resetListState()
loadOrders(true)
loadOrderCounts()
}
const onOrdersScroll = (e: UniScrollEvent) => {
try {
const detail = e.detail
if (detail == null) return
let top = 0
if (typeof detail.scrollTop == 'number') {
top = detail.scrollTop
}
scrollTop.value = top
updateVisibleWindow()
} catch (err) {
console.error('[orders虚拟列表] 处理滚动失败:', err)
}
}
const onRefresh = async () => {
refreshing.value = true
resetListState()
await loadOrders(true)
await loadOrderCounts()
}
const getOrderStatusText = (order: OrderItem): string => {
const displayState = getUnifiedDisplayState(toOrderStatusSource(order))
if (displayState == 'pending_pay') return '待付款'
if (displayState == 'cancelled' || displayState == 'expired') return '已取消'
if (order.biz_type === 'service') {
if (order.status == 1) return '待付款'
if (order.status == 2) return '待接单'
if (order.status == 3) return '待服务'
if (order.status == 4) return '服务中'
if (order.status == 5) return '已完成'
if (order.status == 6) return '退款中'
if (order.status == 7) return '已退款'
if (order.status == 8) return '已取消'
return '服务处理中'
}
if (order.status == 1) return '待付款'
if (order.status == 2) return '待发货'
if (order.status == 3) return '待收货'
if (order.status == 4) return '已完成'
if (order.status == 5) return '已取消'
if (order.status == 6) return '退款中'
if (order.status == 7) return '已退款'
return '未知状态'
}
const getStatusClass = (order: OrderItem): string => {
const displayState = getUnifiedDisplayState(toOrderStatusSource(order))
if (displayState == 'pending_pay') return 'status-pending'
if (displayState == 'cancelled' || displayState == 'expired') return 'status-cancelled'
if (order.status == 2 || order.status == 3 || order.status == 4) return 'status-processing'
if (order.status == 5 || order.status == ORDER_STATUS_TIMEOUT_LEGACY) return 'status-cancelled'
if (order.status == 6 || order.status == 7) return 'status-aftersale'
return 'status-default'
}
const getOrderShopName = (order: OrderItem): string => {
if (order.biz_type === 'service') {
if (order.service_info != null && order.service_info.provider_name != '') {
return order.service_info.provider_name
}
if (order.shop_name != '') {
return order.shop_name
}
return '康养上门服务'
}
if (order.shop_name != '') {
return order.shop_name
}
return '康养商城'
}
const contactSeller = (order: OrderItem) => {
if (order.merchant_id != '') {
uni.navigateTo({ url: `/pages/mall/consumer/chat?merchantId=${order.merchant_id}` })
} else {
uni.showToast({ title: '暂无联系方式', icon: 'none' })
}
}
const doDeleteOrder = async (orderId: string): Promise<void> => {
uni.showLoading({ title: '删除中...' })
try {
const currentOrder = getLoadedOrderById(orderId)
const orderSource = currentOrder != null ? currentOrder.source : 'goods'
const success = await supabaseService.softDeleteUnifiedOrderForConsumer(orderId, orderSource)
uni.hideLoading()
if (success) {
uni.showToast({ title: '订单已删除', icon: 'success' })
removeLoadedOrder(orderId)
loadOrderCounts()
} else {
uni.showToast({ title: '删除失败,请稍后重试', icon: 'none' })
}
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '删除失败,请稍后重试', icon: 'none' })
}
}
const deleteOrder = (orderId: string) => {
uni.showModal({
title: '删除订单',
content: '确定要删除此订单吗?',
success: (res) => {
if (res.confirm) {
doDeleteOrder(orderId)
}
}
})
}
const doCancelOrder = async (orderId: string): Promise<void> => {
uni.showLoading({ title: '取消中...' })
try {
const currentOrder = getLoadedOrderById(orderId)
const oldPayExpireAt = currentOrder != null ? currentOrder.pay_expire_at : ''
const orderSource = currentOrder != null ? currentOrder.source : 'goods'
const success = await supabaseService.cancelUnifiedOrder(orderId, orderSource, '用户取消订单')
uni.hideLoading()
if (success) {
const nextStatus = orderSource == 'service' ? ORDER_STATUS_TIMEOUT_LEGACY : ORDER_STATUS_CANCELLED
uni.showToast({ title: '订单已取消', icon: 'success' })
updateLoadedOrderStatus(orderId, nextStatus, PAYMENT_STATUS_TIMEOUT, '用户取消订单', oldPayExpireAt)
loadOrderCounts()
} else {
uni.showToast({ title: '取消失败', icon: 'none' })
}
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '取消失败', icon: 'none' })
}
}
const cancelOrder = (orderId: string) => {
uni.showModal({
title: '确认取消',
content: '确定要取消此订单吗?',
success: (res) => {
if (res.confirm) {
doCancelOrder(orderId)
}
}
})
}
const payOrder = async (order: OrderItem) => {
nowTick.value = Date.now()
const totalAmount = order.total_amount
const userId = supabaseService.getCurrentUserId()
const paymentUrl = `/pages/mall/consumer/payment?orderId=${encodeURIComponent(order.id)}&bizType=${encodeURIComponent(order.biz_type)}&source=${encodeURIComponent(order.source)}&amount=${totalAmount}`
if (userId == null || userId === '') {
goToLogin(paymentUrl)
return
}
const latestOrder = await supabaseService.getUnifiedOrderDetail(order.id, order.source)
if (latestOrder == null) {
uni.showToast({ title: '订单不存在或已删除', icon: 'none' })
return
}
if (latestOrder != null) {
const latestObj = JSON.parse(JSON.stringify(latestOrder)) as UTSJSONObject
const latestStatus = latestObj.getNumber('order_status') ?? 1
const latestPaymentStatus = latestObj.getNumber('payment_status') ?? 1
const latestCancelReason = latestObj.getString('cancel_reason') ?? ''
const latestPayExpireAt = latestObj.getString('pay_expire_at') ?? ''
const latestViewOrder: OrderItem = {
...order,
status: latestStatus,
payment_status: latestPaymentStatus,
cancel_reason: latestCancelReason,
pay_expire_at: latestPayExpireAt
}
if (isTimeoutOrder(latestViewOrder)) {
await supabaseService.expireUnifiedOrder(order.id, order.source)
const nextStatus = order.source == 'service' ? ORDER_STATUS_TIMEOUT_LEGACY : ORDER_STATUS_CANCELLED
updateLoadedOrderStatus(order.id, nextStatus, PAYMENT_STATUS_TIMEOUT, ORDER_TIMEOUT_CANCEL_REASON, latestPayExpireAt)
uni.showToast({ title: '订单已取消,不能继续支付', icon: 'none' })
loadOrderCounts()
return
}
if (latestStatus != ORDER_STATUS_PENDING || latestPaymentStatus != PAYMENT_STATUS_UNPAID) {
updateLoadedOrderStatus(order.id, latestStatus, latestPaymentStatus, latestCancelReason, latestPayExpireAt)
uni.showToast({ title: '订单状态已变更,不能继续支付', icon: 'none' })
loadOrderCounts()
return
}
}
uni.navigateTo({ url: paymentUrl })
}
const viewSimilar = (order: OrderItem) => {
const firstProduct = order.products.length > 0 ? order.products[0] : null
if (firstProduct == null) {
uni.showToast({ title: '暂无相似商品', icon: 'none' })
return
}
const keyword = firstProduct.name != '' ? firstProduct.name : '商品'
uni.navigateTo({ url: `/pages/mall/consumer/search?keyword=${encodeURIComponent(keyword)}` })
}
const remindShipping = async (orderId: string) => {
uni.showLoading({ title: '正在提醒...' })
try {
const order = getLoadedOrderById(orderId)
if (order != null && order.merchant_id != '') {
const message = `你好,我的订单[${order.order_no}]还没有发货,请尽快安排,谢谢。`
await supabaseService.sendChatMessage(message, order.merchant_id)
}
} catch (e) {
console.error('提醒发货异常:', e)
} finally {
uni.hideLoading()
}
uni.showToast({ title: '已提醒处理', icon: 'success' })
}
const viewLogistics = (orderId: string) => {
uni.navigateTo({ url: `/pages/mall/consumer/logistics?orderId=${orderId}` })
}
const goReview = (order: OrderItem) => {
const productIds = order.products.map((p: OrderProduct): string => {
return p.id
}).join(',')
uni.navigateTo({ url: `/pages/mall/consumer/review?orderId=${order.id}&productIds=${productIds}` })
}
const doConfirmReceipt = async (orderId: string): Promise<void> => {
uni.showLoading({ title: '处理中...' })
try {
const result = await supabaseService.confirmReceipt(orderId)
uni.hideLoading()
if (result.success) {
uni.showToast({ title: '收货成功', icon: 'success' })
const updatedOrder = updateLoadedOrderStatus(orderId, 4)
loadOrderCounts()
setTimeout(() => {
if (updatedOrder != null) {
goReview(updatedOrder)
}
}, 1000)
} else {
uni.showToast({ title: result.error ?? '确认收货失败', icon: 'none' })
}
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '系统异常', icon: 'none' })
}
}
const confirmReceipt = (orderId: string) => {
uni.showModal({
title: '确认收货',
content: '请确认您已收到商品,且商品无误',
success: (res) => {
if (res.confirm) {
doConfirmReceipt(orderId)
}
}
})
}
const confirmServiceFinished = (order: OrderItem) => {
uni.showModal({
title: '确认完成',
content: '确认本次康养服务已完成吗?',
success: (res) => {
if (res.confirm) {
// TODO: 服务订单接入统一接口后,替换为真实完成服务 API。
uni.showToast({ title: '服务完成接口待接入', icon: 'none' })
}
}
})
}
const repurchase = (order: OrderItem) => {
const products = order.products
if (products.length === 0) {
uni.showToast({ title: '订单无商品', icon: 'none' })
return
}
uni.showLoading({ title: '处理中...' })
let completed = 0
const total = products.length
let successCount = 0
for (let i = 0; i < products.length; i++) {
const product = products[i]
const productId = product.id
const merchantId = order.merchant_id
if (productId != null && productId !== '') {
supabaseService.addToCart(productId, 1, '', merchantId ?? '').then((success: boolean) => {
completed++
if (success) successCount++
if (completed === total) {
uni.hideLoading()
if (successCount > 0) {
uni.showToast({ title: `已添加${successCount}件商品`, icon: 'success' })
} else {
uni.showToast({ title: '添加失败', icon: 'none' })
}
}
}).catch(() => {
completed++
if (completed === total) {
uni.hideLoading()
if (successCount > 0) {
uni.showToast({ title: `已添加${successCount}件商品`, icon: 'success' })
} else {
uni.showToast({ title: '添加失败', icon: 'none' })
}
}
})
} else {
completed++
if (completed === total) {
uni.hideLoading()
if (successCount > 0) {
uni.showToast({ title: `已添加${successCount}件商品`, icon: 'success' })
} else {
uni.showToast({ title: '添加失败', icon: 'none' })
}
}
}
}
}
const rebookService = (order: OrderItem) => {
if (order.service_info != null && order.service_info.service_name != '') {
uni.showToast({ title: '再次预约功能待接入', icon: 'none' })
return
}
uni.showToast({ title: '暂无可预约服务', icon: 'none' })
}
const viewSimilarService = (order: OrderItem) => {
const serviceName = order.service_info != null ? order.service_info.service_name : ''
const url = serviceName != ''
? `/pages/mall/consumer/home-service/index?keyword=${encodeURIComponent(serviceName)}`
: '/pages/mall/consumer/home-service/index'
uni.navigateTo({ url })
}
const viewOrderDetail = (order: OrderItem) => {
if (order.biz_type === 'service') {
uni.navigateTo({ url: `/pages/mall/consumer/home-service/order-detail?id=${order.id}&source=${order.source}` })
return
}
uni.navigateTo({ url: `/pages/mall/consumer/order-detail?id=${order.id}&source=${order.source}` })
}
const onApplyRefund = (order: OrderItem) => {
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId === '') {
goToLogin(`/pages/mall/consumer/apply-refund?orderId=${order.id}`)
return
}
uni.navigateTo({ url: `/pages/mall/consumer/apply-refund?orderId=${order.id}` })
}
const viewRefundProgress = (orderId: string) => {
uni.navigateTo({ url: `/pages/mall/consumer/refund?orderId=${orderId}` })
}
const doCancelRefund = async (orderId: string) => {
uni.showLoading({ title: '处理中...' })
const result = await supabaseService.cancelRefund(orderId)
uni.hideLoading()
if (result.success) {
uni.showToast({ title: '已取消退款', icon: 'success' })
refreshCurrentSlice()
} else {
uni.showToast({ title: result.message, icon: 'none' })
}
}
const cancelRefund = (orderId: string) => {
uni.showModal({
title: '确认取消',
content: '确定要取消退款申请吗?',
success: (res) => {
if (res.confirm) {
doCancelRefund(orderId)
}
}
})
}
const contactServiceProvider = (order: OrderItem) => {
contactSeller(order)
}
const getOrderActions = (order: OrderItem): OrderActionItem[] => {
const actions: OrderActionItem[] = []
const displayState = getUnifiedDisplayState(toOrderStatusSource(order))
if (displayState == 'pending_pay') {
actions.push({ id: 'cancel', name: order.biz_type === 'service' ? '取消预约' : '取消订单', kind: 'secondary' })
actions.push({ id: 'pay', name: '立即支付', kind: 'primary' })
return actions
}
if (displayState == 'cancelled' || displayState == 'expired') {
if (order.biz_type === 'service') {
actions.push({ id: 'view-similar-service', name: '看相似服务', kind: 'secondary' })
actions.push({ id: 'rebook', name: '再次预约', kind: 'primary' })
return actions
}
actions.push({ id: 'view-similar', name: '看相似', kind: 'secondary' })
actions.push({ id: 'repurchase', name: '再次购买', kind: 'primary' })
return actions
}
if (order.biz_type === 'service') {
if (order.status === 2) {
actions.push({ id: 'cancel', name: '取消预约', kind: 'secondary' })
actions.push({ id: 'contact-service', name: '联系客服', kind: 'secondary' })
} else if (order.status === 3) {
actions.push({ id: 'view-detail', name: '查看预约', kind: 'secondary' })
actions.push({ id: 'contact-provider', name: '联系服务人员', kind: 'secondary' })
} else if (order.status === 4) {
actions.push({ id: 'contact-provider', name: '联系服务人员', kind: 'secondary' })
actions.push({ id: 'confirm-service', name: '确认完成', kind: 'primary' })
} else if (displayState == 'completed') {
actions.push({ id: 'service-review', name: '评价服务', kind: 'secondary' })
actions.push({ id: 'rebook', name: '再次预约', kind: 'primary' })
} else if (order.status === 6 || order.status === 7) {
actions.push({ id: 'refund-progress', name: '退款进度', kind: 'secondary' })
actions.push({ id: 'view-detail', name: '查看详情', kind: 'secondary' })
}
return actions
}
if (order.status === 2) {
actions.push({ id: 'remind', name: '提醒发货', kind: 'secondary' })
actions.push({ id: 'refund', name: '申请售后', kind: 'secondary' })
} else if (order.status === 3) {
actions.push({ id: 'logistics', name: '查看物流', kind: 'secondary' })
actions.push({ id: 'confirm', name: '确认收货', kind: 'primary' })
actions.push({ id: 'refund', name: '申请售后', kind: 'secondary' })
} else if (displayState == 'completed') {
actions.push({ id: 'refund', name: '申请售后', kind: 'secondary' })
actions.push({ id: 'repurchase', name: '再次购买', kind: 'primary' })
actions.push({ id: 'review', name: '评价晒单', kind: 'secondary' })
} else if (order.status === 6 || order.status === 7) {
actions.push({ id: 'refund-progress', name: '退款进度', kind: 'secondary' })
actions.push({ id: 'view-detail', name: '查看详情', kind: 'secondary' })
}
return actions
}
const onOrderActionTap = (order: OrderItem, actionId: string) => {
if (actionId === 'cancel') {
cancelOrder(order.id)
} else if (actionId === 'pay') {
payOrder(order)
} else if (actionId === 'remind') {
remindShipping(order.id)
} else if (actionId === 'refund') {
onApplyRefund(order)
} else if (actionId === 'logistics') {
viewLogistics(order.id)
} else if (actionId === 'confirm') {
confirmReceipt(order.id)
} else if (actionId === 'review') {
goReview(order)
} else if (actionId === 'repurchase') {
repurchase(order)
} else if (actionId === 'view-similar') {
viewSimilar(order)
} else if (actionId === 'view-similar-service') {
viewSimilarService(order)
} else if (actionId === 'view-detail') {
viewOrderDetail(order)
} else if (actionId === 'refund-progress') {
viewRefundProgress(order.id)
} else if (actionId === 'contact-service' || actionId === 'contact-provider') {
contactServiceProvider(order)
} else if (actionId === 'confirm-service') {
confirmServiceFinished(order)
} else if (actionId === 'service-review') {
uni.showToast({ title: '服务评价功能待接入', icon: 'none' })
} else if (actionId === 'rebook') {
rebookService(order)
}
}
const showOrderMenu = (order: OrderItem) => {
const actions = getOrderActions(order)
if (actions.length === 0) {
uni.showToast({ title: '暂无可执行操作', icon: 'none' })
return
}
const actionNames: string[] = []
for (let i = 0; i < actions.length; i++) {
actionNames.push(actions[i].name)
}
uni.showActionSheet({
itemList: actionNames,
success: (res) => {
const action = actions[res.tapIndex]
onOrderActionTap(order, action.id)
}
})
}
const navigateToProduct = (product: OrderProduct) => {
if (product.id == '' || product.id == 'placeholder') {
return
}
uni.navigateTo({ url: `/pages/mall/consumer/product-detail?id=${product.id}` })
}
const onCardContentTap = (order: OrderItem) => {
if (order.biz_type === 'service') {
viewOrderDetail(order)
return
}
const product = getPrimaryOrderProduct(order)
if (product != null) {
navigateToProduct(product)
}
}
const goShopping = () => {
uni.switchTab({ url: '/pages/main/index' })
}
const getEmptyStateTitle = (): string => {
if (activeBizType.value === 'service') {
return '暂无服务订单'
}
if (activeBizType.value === 'goods') {
return '暂无购物订单'
}
return '暂无订单'
}
const getEmptyStateDescription = (): string => {
if (activeBizType.value === 'service') {
return '康养服务预约记录会展示在这里'
}
return '去逛逛,发现心仪的商品与服务'
}
</script>
<style>
.orders-page {
width: 100%;
height: 100vh;
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #f5f5f5;
overflow: hidden;
}
.orders-header,
.biz-tabs-wrap,
.status-tabs-wrap {
width: 100%;
flex-shrink: 0;
box-sizing: border-box;
}
.orders-header {
background-color: #ffffff;
width: 100%;
box-sizing: border-box;
flex-shrink: 0;
z-index: 20;
}
.custom-nav-row {
height: 44px;
display: flex;
flex-direction: row;
align-items: center;
padding-left: 10px;
box-sizing: border-box;
}
.nav-back-btn {
width: 36px;
height: 36px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 6px;
background-color: #ffffff;
}
.nav-back-icon {
font-size: 30px;
line-height: 32px;
color: #222222;
font-weight: 400;
}
.search-shell {
flex: 1;
height: 36px;
border-radius: 18px;
background-color: #f5f5f5;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 12px;
box-sizing: border-box;
min-width: 0;
}
.search-icon-left {
font-size: 15px;
color: #999999;
margin-right: 6px;
flex-shrink: 0;
}
.search-clear {
width: 20px;
height: 20px;
border-radius: 10px;
line-height: 20px;
text-align: center;
color: #999999;
background-color: #dddddd;
font-size: 16px;
flex-shrink: 0;
}
.search-input {
flex: 1;
height: 36px;
line-height: 36px;
font-size: 14px;
color: #333333;
background-color: transparent;
border: none;
padding: 0;
min-width: 0;
}
.search-input::placeholder {
color: #a0a0a0;
font-size: 13px;
}
.biz-tabs-wrap {
height: 44px;
background-color: #ffffff;
display: flex;
flex-direction: row;
align-items: center;
flex-shrink: 0;
border-bottom: 1px solid #f2f2f2;
}
.biz-tab-item {
flex: 1;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.biz-tab-item.active .biz-tab-text {
color: #ff3b1f;
font-weight: 700;
}
.biz-tab-text {
font-size: 16px;
color: #333333;
}
.biz-tab-badge {
margin-left: 6px;
min-width: 16px;
padding: 0 5px;
height: 16px;
line-height: 16px;
border-radius: 8px;
background-color: #ffebe7;
color: #ff3b1f;
font-size: 10px;
text-align: center;
}
.biz-tab-indicator {
position: absolute;
left: 28px;
right: 28px;
bottom: 4px;
height: 3px;
border-radius: 2px;
background-color: #ff3b1f;
}
.status-tabs-wrap {
height: 42px;
background-color: #ffffff;
flex-shrink: 0;
border-bottom: 1px solid #f0f0f0;
}
.status-tabs-scroll {
width: 100%;
height: 42px;
flex-shrink: 0;
}
.status-tabs-inner {
height: 42px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
padding: 0 10px;
box-sizing: border-box;
width: max-content;
}
.status-tab-item {
height: 30px;
min-width: 72px;
padding: 0 14px;
margin-right: 8px;
border-radius: 15px;
background-color: #f7f7f7;
border-radius: 15px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-sizing: border-box;
}
.status-tab-item.active {
background-color: #fff1ef;
border: 1px solid #ff3b1f;
}
.status-tab-text {
font-size: 13px;
color: #333333;
}
.status-tab-item.active .status-tab-text {
color: #ff3b1f;
font-weight: bold;
}
.status-tab-badge {
margin-left: 4px;
min-width: 16px;
height: 16px;
border-radius: 8px;
padding: 0 4px;
background-color: #ff3b1f;
color: #ffffff;
font-size: 10px;
line-height: 16px;
text-align: center;
}
.orders-content {
flex: 1;
height: 0;
width: 100%;
background-color: #f5f5f5;
}
.empty-orders {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 88px 24px;
}
.empty-icon {
font-size: 72px;
color: #d8d8d8;
margin-bottom: 18px;
}
.empty-title {
font-size: 18px;
color: #444444;
margin-bottom: 8px;
}
.empty-desc {
font-size: 13px;
color: #999999;
margin-bottom: 26px;
}
.go-shopping-btn {
height: 36px;
line-height: 36px;
border-radius: 18px;
background-color: #ff3b1f;
color: #ffffff;
font-size: 14px;
padding: 0 28px;
border: none;
}
.virtual-list,
.virtual-top-spacer,
.virtual-bottom-spacer {
width: 100%;
}
.virtual-order-item {
width: 100%;
padding: 10px 12px 0 12px;
box-sizing: border-box;
}
.order-card {
width: 100%;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.04);
overflow: hidden;
display: flex;
flex-direction: column;
}
.order-card-header {
height: 44px;
padding: 8px 12px 4px 12px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
box-sizing: border-box;
}
.shop-info {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
overflow: hidden;
}
.shop-title-row {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
}
.shop-icon {
font-size: 14px;
margin-right: 6px;
}
.shop-name {
font-size: 14px;
font-weight: 600;
color: #222222;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
lines: 1;
}
.arrow-right {
font-size: 12px;
color: #bbbbbb;
margin-left: 4px;
}
.order-meta-line {
margin-top: 2px;
font-size: 11px;
color: #9a9a9a;
overflow: hidden;
text-overflow: ellipsis;
lines: 1;
}
.status-row {
display: flex;
flex-direction: row;
align-items: center;
flex-shrink: 0;
}
.status-main {
display: flex;
flex-direction: row;
align-items: center;
}
.order-status {
font-size: 13px;
font-weight: 600;
}
.order-status-countdown {
font-size: 13px;
font-weight: 600;
margin-left: 4px;
}
.status-pending,
.status-processing,
.status-aftersale {
color: #ff3b1f;
}
.status-cancelled,
.status-expired,
.status-default {
color: #999999;
}
.delete-order-icon {
font-size: 16px;
color: #999999;
margin-left: 8px;
padding: 0 2px;
}
.more-btn {
font-size: 18px;
color: #999999;
margin-left: 6px;
padding: 0 4px;
}
.order-main {
height: 88px;
padding: 8px 12px;
display: flex;
flex-direction: row;
align-items: stretch;
box-sizing: border-box;
}
.order-cover {
width: 72px;
height: 72px;
border-radius: 8px;
background-color: #f2f2f2;
margin-right: 10px;
flex-shrink: 0;
}
.order-main-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
}
.order-main-title {
font-size: 14px;
color: #262626;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
lines: 2;
}
.goods-meta-wrap,
.service-meta-wrap {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: 4px;
}
.goods-spec-chip {
max-width: 190px;
padding: 2px 8px;
border-radius: 10px;
background-color: #f3f3f3;
color: #888888;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
lines: 1;
}
.goods-extra-text {
margin-top: 4px;
font-size: 11px;
color: #999999;
}
.service-meta-text {
font-size: 11px;
color: #8a8a8a;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
lines: 1;
}
.order-main-footer {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-top: auto;
}
.order-main-price {
font-size: 15px;
color: #222222;
font-weight: 700;
}
.order-main-count {
font-size: 12px;
color: #999999;
}
.order-summary {
height: 36px;
padding: 0 12px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
box-sizing: border-box;
}
.order-time {
font-size: 11px;
color: #9a9a9a;
}
.summary-right {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
flex: 1;
overflow: hidden;
}
.summary-text,
.summary-price-label {
font-size: 12px;
color: #666666;
}
.summary-price-label {
margin-left: 4px;
margin-right: 4px;
}
.summary-price {
font-size: 15px;
color: #222222;
font-weight: 700;
}
.order-actions {
height: 54px;
padding: 8px 12px 12px 12px;
box-sizing: border-box;
}
.action-buttons {
width: 100%;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
.action-btn {
min-width: 74px;
height: 32px;
line-height: 32px;
border-radius: 16px;
font-size: 12px;
padding: 0 14px;
margin-left: 8px;
background-color: #ffffff;
}
.action-btn.secondary {
color: #666666;
border: 1px solid #d8d8d8;
}
.action-btn.primary {
color: #ff3b1f;
border: 1px solid #ff3b1f;
background-color: #fff6f4;
}
.loading-more,
.no-more {
width: 100%;
padding: 18px 0 8px 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
color: #999999;
font-size: 12px;
}
.loading-spinner {
width: 14px;
height: 14px;
border-radius: 7px;
border: 2px solid #ffd4cc;
border-top-color: #ff3b1f;
margin-right: 8px;
}
.safe-area,
.tabbar-placeholder {
width: 100%;
height: 50px;
flex-shrink: 0;
}
</style>