Files
medical-mall/pages/mall/delivery/orders/index.uvue

677 lines
18 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>
<ServicePageScaffold title="订单" fallback-url="/pages/mall/delivery/home/index" :hide-header="true">
<view class="page">
<view class="hero">
<text class="hero-title">服务订单</text>
<text class="hero-subtitle">待接单、今日订单、历史订单统一查看</text>
</view>
<view class="tab-row">
<view v-for="item in tabs" :key="item.value" class="tab-item" :class="currentTab == item.value ? 'tab-item-active' : ''" @click="switchTab(item.value)">
<text class="tab-text" :class="currentTab == item.value ? 'tab-text-active' : ''">{{ item.label }}</text>
</view>
</view>
<view class="search-section">
<view class="search-bar">
<view class="search-input-wrap">
<text class="search-icon">🔍</text>
<input class="search-input" placeholder="搜索订单号" :value="searchKeyword" @input="onSearchInput" @confirm="onSearchConfirm" />
<view v-if="searchKeyword != ''" class="search-clear" @tap="clearSearch">
<text class="clear-icon">✕</text>
</view>
</view>
<view class="sort-wrap">
<view class="sort-btn" @tap="openSortActionSheet">
<text class="sort-check-inline">✔</text>
<text class="sort-btn-text">{{ currentSortLabel }}</text>
<text class="sort-arrow">⌄</text>
</view>
</view>
</view>
<view v-if="searchKeyword != ''" class="search-count-row">
<text class="search-count-text">{{ filteredOrders.length }} 条结果</text>
</view>
</view>
<view class="list-card">
<view v-if="filteredOrders.length == 0" class="empty-box"><text class="empty-text">{{ emptyText }}</text></view>
<view v-for="item in filteredOrders" :key="item.id" class="order-card">
<view class="order-top" @click="goDetail(item.id)">
<view class="order-main">
<text class="order-title">{{ item.serviceName }}</text>
<text class="order-subtitle">{{ item.elderName }} · {{ formatDateTime(item.appointmentTime) }}</text>
<text class="order-id">订单号:{{ item.orderNo }}</text>
</view>
<text class="order-status">{{ item.statusText }}</text>
</view>
<view class="meta-box" @click="goDetail(item.id)">
<text class="meta-text">地址:{{ item.address }} {{ item.addressDetail }}</text>
<text class="meta-text">时长:{{ item.duration }} 分钟 · 预计收入:¥{{ item.staffIncome }}</text>
<text class="meta-text">风险:{{ formatTags(item.riskTags) }}</text>
<text v-if="item.acceptedByName != ''" class="meta-text accepted-by">接单人:{{ item.acceptedByName }}</text>
</view>
<view class="action-row">
<view v-if="getActionText(item.status) != '查看详情'" class="outline-btn" @click="goDetail(item.id)"><text class="outline-btn-text">查看详情</text></view>
<view v-if="showReject(item.status)" class="warn-btn" @click="rejectOrder(item.id)"><text class="warn-btn-text">拒单</text></view>
<view class="primary-btn" @click="handleAction(item.id, item.status)"><text class="primary-btn-text">{{ getActionText(item.status) }}</text></view>
</view>
</view>
</view>
</view>
</ServicePageScaffold>
</template>
<script setup lang="uts">
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import ServicePageScaffold from '@/components/homeService/ServicePageScaffold.uvue'
import type { DeliveryOrderStatus, DeliveryOrderType } from '@/types/delivery.uts'
import {
acceptServiceOrder,
getHistoryServiceOrders,
getPendingServiceOrders,
getTodayServiceOrders,
rejectServiceOrder
} from '@/services/deliveryService.uts'
import { getDeliveryOrderTabs, getPrimaryActionText } from '@/utils/deliveryCareUi.uts'
import { requireDeliveryAuth } from '@/utils/deliveryAuth.uts'
import { formatDateTime } from '@/utils/utils.uts'
const tabs = getDeliveryOrderTabs()
const currentTab = ref('pending')
const orders = ref([] as Array<DeliveryOrderType>)
const searchKeyword = ref('')
const sortMode = ref('appointment_desc')
const sortIndex = ref(0)
const currentSortLabel = ref('晚到早')
const sortLabels = ['晚到早', '早到晚', '最新接单'] as Array<string>
const sortValues = ['appointment_desc', 'appointment_asc', 'created_desc'] as Array<string>
function onSortPickerCancel() {
console.warn('[orders/sort] picker cancel')
}
function openSortActionSheet() {
console.warn('[orders/sort] open action sheet')
uni.showActionSheet({
itemList: sortLabels,
success: (res) => {
const index = res.tapIndex
if (index < 0 || index >= sortValues.length) {
console.warn('[orders/sort] invalid action sheet index:', index)
return
}
sortIndex.value = index
sortMode.value = sortValues[index]
currentSortLabel.value = sortLabels[index]
console.warn('[orders/sort] action sheet change:', sortMode.value, currentSortLabel.value)
},
fail: (err) => {
console.warn('[orders/sort] action sheet cancel/fail:', err)
}
})
}
function parseTime(value: string | null | undefined): number {
if (value == null) return 0
const raw = value.trim()
if (raw == '') return 0
let normalized = raw
// 接口返回 "2026-06-10 15:28:21"iOS/小程序不兼容,统一转成 "2026/06/10 15:28:21"
// ISO 格式 "2026-06-10T15:28:21" 不要替换
if (normalized.indexOf('T') < 0) {
normalized = normalized.replace(/-/g, '/')
}
const time = new Date(normalized).getTime()
// NaN 判断NaN 不等于自己
if (time != time) {
console.warn('[orders/sort] invalid time:', value)
return 0
}
return time
}
function getSortTime(item: DeliveryOrderType): number {
if (sortMode.value == 'created_desc') {
if (item.acceptTime != '') return parseTime(item.acceptTime)
if (item.updatedAt != '') return parseTime(item.updatedAt)
return parseTime(item.createdAt)
}
if (item.appointmentTime != '') return parseTime(item.appointmentTime)
if (item.appointmentStartTime != '') return parseTime(item.appointmentStartTime)
return 0
}
function sortOrderList(list: Array<DeliveryOrderType>): Array<DeliveryOrderType> {
const wrapped = list.map((item, index) => {
return {
item,
index,
time: getSortTime(item)
} as { item: DeliveryOrderType, index: number, time: number }
})
wrapped.sort((a, b) => {
if (a.time == 0 && b.time != 0) return 1
if (a.time != 0 && b.time == 0) return -1
if (sortMode.value == 'appointment_asc') {
if (a.time != b.time) return a.time - b.time
} else {
if (a.time != b.time) return b.time - a.time
}
return a.index - b.index
})
return wrapped.map((entry) => entry.item)
}
function normalizeStatus(status: string): string {
return String(status ?? '').trim().toLowerCase()
}
function isPendingStatus(status: string): boolean {
const s = normalizeStatus(status)
return s == 'pending' || s == 'pending_assignment' || s == 'pending_accept' || s == 'pending_assign' || s == 'pending_dispatch' || s == 'pending_acceptance_new'
}
function isOngoingStatus(status: string): boolean {
const s = normalizeStatus(status)
return s == 'assigned' || s == 'accepted' || s == 'waiting_departure' || s == 'departed' || s == 'on_the_way' || s == 'arrived' || s == 'in_service' || s == 'serving' || s == 'pending_acceptance' || s == 'arrival_pending' || s == 'order_accepted' || s == 'order_departed' || s == 'order_checked_in' || s == 'order_in_service'
}
function isHistoryStatus(status: string): boolean {
const s = normalizeStatus(status)
return s == 'completed' || s == 'settled' || s == 'archived' || s == 'cancelled' || s == 'canceled' || s == 'rejected' || s == 'abnormal' || s == 'exception_pending' || s == 'order_completed' || s == 'order_cancelled'
}
function getOrderCanonicalKey(item: DeliveryOrderType): string {
const orderNo = String(item.orderNo ?? '').trim()
if (orderNo != '') return 'orderNo:' + orderNo
const sourceOrderId = String(item.sourceOrderId ?? '').trim()
if (sourceOrderId != '') return 'sourceOrderId:' + sourceOrderId
const legacyServiceOrderId = String(item.legacyServiceOrderId ?? '').trim()
if (legacyServiceOrderId != '') return 'legacyServiceOrderId:' + legacyServiceOrderId
return 'id:' + String(item.id ?? '')
}
function getOrderUpdateTime(item: DeliveryOrderType): number {
if (item.updatedAt != '') return parseTime(item.updatedAt)
if (item.createdAt != '') return parseTime(item.createdAt)
return 0
}
function dedupeOrders(list: Array<DeliveryOrderType>): Array<DeliveryOrderType> {
const map = new Map<string, DeliveryOrderType>()
for (let i = 0; i < list.length; i++) {
const item = list[i]
const key = getOrderCanonicalKey(item)
const old = map.get(key)
if (old == null) {
map.set(key, item)
continue
}
if (isPendingStatus(old.status) && isOngoingStatus(item.status)) {
map.set(key, item)
continue
}
if (isOngoingStatus(old.status) && isPendingStatus(item.status)) {
continue
}
if (getOrderUpdateTime(item) > getOrderUpdateTime(old)) {
map.set(key, item)
}
}
return Array.from(map.values())
}
const filteredOrders = computed((): Array<DeliveryOrderType> => {
const keyword = searchKeyword.value.trim().toLowerCase()
let result = orders.value.slice()
if (keyword == '') {
result = result.slice()
} else {
result = result.filter((item) => item.orderNo.toLowerCase().includes(keyword))
}
if (currentTab.value == 'pending') {
result = result.filter((item) => isPendingStatus(item.status))
}
if (currentTab.value == 'today' || currentTab.value == 'ongoing') {
result = result.filter((item) => isOngoingStatus(item.status))
}
if (currentTab.value == 'history') {
result = result.filter((item) => isHistoryStatus(item.status))
}
result = dedupeOrders(result)
return sortOrderList(result)
})
const emptyText = computed((): string => {
if (searchKeyword.value.trim() != '' && filteredOrders.value.length == 0) {
return '暂无匹配订单'
}
if (currentTab.value == 'pending') return '暂无待接单订单'
if (currentTab.value == 'today') return '暂无今日订单'
return '暂无历史订单'
})
async function loadData() {
const authResult = await requireDeliveryAuth({ redirectOnFail: true, toastOnFail: true })
if (!authResult.ok) {
return
}
if (currentTab.value == 'pending') {
orders.value = await getPendingServiceOrders()
console.warn('[orders/index] pending orders:', JSON.stringify(orders.value))
return
}
if (currentTab.value == 'history') {
orders.value = await getHistoryServiceOrders()
console.warn('[orders/index] history orders:', JSON.stringify(orders.value))
return
}
orders.value = await getTodayServiceOrders()
console.warn('[orders/index] today orders:', JSON.stringify(orders.value))
}
function consumeStoredTab(): void {
const storedTab = uni.getStorageSync('delivery_order_tab') as string | null
if (storedTab != null && storedTab != '') {
currentTab.value = storedTab
uni.removeStorageSync('delivery_order_tab')
}
}
function switchTab(tab: string) {
currentTab.value = tab
searchKeyword.value = ''
loadData()
}
function onSearchInput(e: any) {
searchKeyword.value = e.detail.value as string
}
function onSearchConfirm() {
uni.showToast({ title: '已搜索', icon: 'none' })
}
function clearSearch() {
searchKeyword.value = ''
}
function formatTags(tags: Array<string>): string {
if (tags.length == 0) return '常规'
return tags.join(' / ')
}
function showReject(status: DeliveryOrderStatus): boolean {
// 仅待接单状态可拒单assigned已派单不可拒单只能查看详情/出发
return isPendingStatus(String(status))
}
function getActionText(status: DeliveryOrderStatus): string {
return getPrimaryActionText(status)
}
function goDetail(orderId: string) {
uni.navigateTo({ url: '/pages/mall/delivery/orders/detail?id=' + orderId })
}
function goRecord(orderId: string) {
uni.navigateTo({ url: '/pages/mall/delivery/service-record/index?id=' + orderId })
}
function rejectOrder(orderId: string) {
uni.showActionSheet({
itemList: ['时间冲突', '距离过远', '技能不匹配', '其他原因'],
success: async (result) => {
const reasons = ['时间冲突', '距离过远', '技能不匹配', '其他原因']
const reason = reasons[result.tapIndex]
await rejectServiceOrder(orderId, reason)
uni.showToast({ title: '已拒单', icon: 'success' })
loadData()
}
})
}
async function handleAction(orderId: string, status: DeliveryOrderStatus) {
const s = normalizeStatus(String(status))
// 待接单:可以接单
if (isPendingStatus(s)) {
const result = await acceptServiceOrder(orderId)
if (!result.ok) {
uni.showToast({ title: result.message != '' ? result.message : '接单失败', icon: 'none' })
return
}
uni.showToast({ title: '接单成功', icon: 'success' })
await loadData()
return
}
// 已派单:不显示接单按钮,显示查看详情;如果需要出发则显示出发
if (s == 'assigned' || s == 'accepted' || s == 'waiting_departure' || s == 'order_accepted') {
// 已派单状态:当前流程需要出发时,跳转到出发页;否则显示查看详情
uni.navigateTo({ url: '/pages/mall/delivery/orders/route?id=' + orderId })
return
}
if (status == 'accepted' || status == 'waiting_departure') {
uni.navigateTo({ url: '/pages/mall/delivery/orders/route?id=' + orderId })
return
}
if (s == 'departed' || s == 'on_the_way' || s == 'order_departed') {
uni.navigateTo({ url: '/pages/mall/delivery/orders/checkin?id=' + orderId })
return
}
if (s == 'arrival_pending') {
uni.showToast({ title: '等待消费者确认到达', icon: 'none' })
return
}
if (status == 'in_service' || status == 'serving' || status == 'ORDER_IN_SERVICE' || status == 'completed' || status == 'ORDER_COMPLETED') {
goDetail(orderId)
return
}
if (status == 'pending_confirm' || status == 'pending_acceptance' || status == 'pending_submit') {
uni.showToast({ title: '已完成服务,等待用户验收', icon: 'none' })
return
}
goDetail(orderId)
}
onLoad(() => {
consumeStoredTab()
loadData()
})
onShow(() => {
consumeStoredTab()
loadData()
})
</script>
<style scoped>
.page {
min-height: 100%;
margin-left: -24rpx;
margin-right: -24rpx;
margin-top: -24rpx;
padding: 0 24rpx 36rpx;
background-color: #f3f8fb;
}
.hero {
padding: 68rpx 28rpx 26rpx;
border-bottom-left-radius: 32rpx;
border-bottom-right-radius: 32rpx;
background: linear-gradient(180deg, #1f7db4 0%, #1aa67f 100%);
}
.hero-title {
font-size: 40rpx;
font-weight: 700;
color: #ffffff;
}
.hero-subtitle,
.meta-text,
.empty-text,
.outline-btn-text,
.tab-text {
font-size: 24rpx;
line-height: 36rpx;
}
.hero-subtitle {
margin-top: 10rpx;
color: rgba(255, 255, 255, 0.9);
}
.tab-row,
.order-top,
.action-row {
display: flex;
flex-direction: row;
}
.tab-row {
justify-content: space-between;
margin-top: 20rpx;
padding: 10rpx;
border-radius: 24rpx;
background-color: #ffffff;
box-shadow: 0 10rpx 28rpx rgba(15, 35, 55, 0.06);
}
.search-section {
position: relative;
z-index: 30;
margin-top: 16rpx;
}
.search-bar {
display: flex;
flex-direction: row;
align-items: center;
padding: 0 4rpx;
}
.search-input-wrap {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
padding: 14rpx 20rpx;
border-radius: 24rpx;
background-color: #ffffff;
box-shadow: 0 4rpx 12rpx rgba(15, 35, 55, 0.04);
}
.sort-wrap {
margin-left: 16rpx;
flex-shrink: 0;
}
.sort-btn {
display: flex;
flex-direction: row;
align-items: center;
padding: 14rpx 18rpx;
border-radius: 24rpx;
background-color: #ffffff;
box-shadow: 0 4rpx 12rpx rgba(15, 35, 55, 0.04);
}
.sort-check-inline {
font-size: 24rpx;
color: #18a058;
font-weight: 700;
margin-right: 6rpx;
}
.sort-btn-text {
font-size: 24rpx;
color: #16324f;
margin-right: 6rpx;
}
.sort-arrow {
font-size: 22rpx;
color: #5e758c;
}
.search-icon {
font-size: 28rpx;
margin-right: 12rpx;
}
.search-input {
flex: 1;
font-size: 26rpx;
color: #16324f;
background-color: transparent;
}
.search-clear {
padding: 4rpx;
margin-left: 8rpx;
}
.clear-icon {
font-size: 24rpx;
color: #9aa8b8;
}
.search-count {
font-size: 22rpx;
color: #176e97;
margin-left: 12rpx;
padding: 8rpx 16rpx;
background-color: #e6f3fa;
border-radius: 16rpx;
}
.search-count-row {
margin-top: 10rpx;
padding-left: 8rpx;
}
.search-count-text {
font-size: 22rpx;
color: #176e97;
}
.tab-item {
width: 32%;
padding: 18rpx 0;
border-radius: 18rpx;
align-items: center;
justify-content: center;
}
.tab-item-active {
background-color: #e6f3fa;
}
.tab-text {
color: #5e758c;
}
.tab-text-active {
font-weight: 700;
color: #176e97;
}
.list-card {
margin-top: 22rpx;
padding: 24rpx;
border-radius: 28rpx;
background-color: #ffffff;
box-shadow: 0 10rpx 28rpx rgba(15, 35, 55, 0.06);
position: relative;
z-index: 1;
}
.order-card {
padding: 24rpx;
margin-bottom: 18rpx;
border-radius: 22rpx;
background-color: #f7fbfd;
}
.order-top,
.action-row {
justify-content: space-between;
align-items: center;
}
.order-main {
flex: 1;
padding-right: 18rpx;
}
.order-title {
font-size: 30rpx;
font-weight: 700;
color: #16324f;
}
.order-subtitle,
.order-status,
.meta-text,
.empty-text,
.outline-btn-text,
.warn-btn-text,
.primary-btn-text,
.order-id {
margin-top: 10rpx;
}
.order-subtitle,
.meta-text,
.empty-text,
.outline-btn-text {
color: #5e758c;
}
.order-id {
font-size: 22rpx;
color: #8a9bae;
font-family: 'Courier New', monospace;
}
.order-status {
font-size: 24rpx;
color: #176e97;
}
.meta-box {
margin-top: 14rpx;
padding: 18rpx;
border-radius: 18rpx;
background-color: #eef7fb;
}
.action-row {
margin-top: 18rpx;
flex-wrap: wrap;
}
.outline-btn,
.warn-btn,
.primary-btn {
width: 31%;
padding: 18rpx 0;
border-radius: 18rpx;
align-items: center;
justify-content: center;
}
.outline-btn {
background-color: #eef6fa;
}
.warn-btn {
background-color: #fff3e6;
}
.warn-btn-text {
font-size: 26rpx;
font-weight: 700;
color: #c77413;
}
.primary-btn {
background-color: #1f7db4;
}
.primary-btn-text {
font-size: 26rpx;
font-weight: 700;
color: #ffffff;
}
.empty-box {
padding: 24rpx 0;
}
.accepted-by {
color: #1f7db4;
font-weight: 600;
}
</style>