595 lines
14 KiB
Plaintext
595 lines
14 KiB
Plaintext
<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 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)
|
||
}
|
||
|
||
const filteredOrders = computed((): Array<DeliveryOrderType> => {
|
||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||
let result: Array<DeliveryOrderType>
|
||
if (keyword == '') {
|
||
result = orders.value.slice()
|
||
} else {
|
||
result = orders.value.filter((item) => item.orderNo.toLowerCase().includes(keyword))
|
||
}
|
||
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 {
|
||
return status == 'pending_assignment' || status == 'pending_accept'
|
||
}
|
||
|
||
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) {
|
||
if (status == 'pending_assignment' || status == 'pending_accept') {
|
||
await acceptServiceOrder(orderId)
|
||
uni.showToast({ title: '接单成功', icon: 'success' })
|
||
loadData()
|
||
return
|
||
}
|
||
if (status == 'accepted' || status == 'waiting_departure') {
|
||
uni.navigateTo({ url: '/pages/mall/delivery/orders/route?id=' + orderId })
|
||
return
|
||
}
|
||
if (status == 'departed' || status == 'on_the_way') {
|
||
uni.navigateTo({ url: '/pages/mall/delivery/orders/route?id=' + orderId })
|
||
return
|
||
}
|
||
if (status == 'arrived') {
|
||
uni.navigateTo({ url: '/pages/mall/delivery/orders/checkin?id=' + orderId })
|
||
return
|
||
}
|
||
if (status == 'in_service' || status == 'serving' || status == 'completed') {
|
||
goRecord(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>
|