统一订单列表混排租赁与撞车,后台撞车订单展示真实手机号

PC/移动端「我的订单」并行拉取两类订单并按时间混排展示;后台撞车订单买家手机号不再脱敏,便于履约联系。
This commit is contained in:
yml2213
2026-07-19 16:01:45 +08:00
parent ed4d3f011c
commit d93795a4b4
10 changed files with 749 additions and 228 deletions
@@ -7,6 +7,10 @@ const { unreadCount, unreadLabel } = useChatUnreadCount(route)
function isNavActive(path: string) {
if (path === '/m') return route.path === '/m'
// 订单 Tab 同时覆盖租赁订单与撞车订单路径
if (path === '/m/orders') {
return route.path.startsWith('/m/orders') || route.path.startsWith('/m/crash/orders')
}
return route.path.startsWith(path)
}
</script>
@@ -224,7 +224,7 @@ async function handleSupportClick() {
>
{{ supportLoading ? '...' : '客服' }}
</button>
<button type="button" class="orders-btn" @click="router.push('/crash/orders')">订单</button>
<button type="button" class="orders-btn" @click="router.push('/m/orders')">订单</button>
</header>
<div class="shop-body">
@@ -46,7 +46,7 @@ async function loadOrders() {
:key="item.id"
type="button"
class="card"
@click="router.push(`/crash/orders/${item.id}`)"
@click="router.push(`/m/crash/orders/${item.id}`)"
>
<div class="top">
<span>{{ item.order_no }}</span>
@@ -228,7 +228,7 @@ async function handleSupportClick() {
<el-icon><ShoppingCart /></el-icon>
<span>购物车{{ cartCount > 0 ? `(${cartCount})` : '' }}</span>
</button>
<button type="button" class="toolbar-btn outline" @click="router.push('/crash/orders')">
<button type="button" class="toolbar-btn outline" @click="router.push('/orders')">
<el-icon><Tickets /></el-icon>
<span>我的订单</span>
</button>
@@ -135,7 +135,7 @@ async function copyText() {
<h1>{{ statusText }}</h1>
<p class="sub">订单号 {{ order.order_no }} · {{ formatDateTime(order.created_at) }}</p>
</div>
<button type="button" class="toolbar-btn outline" @click="router.push('/crash/orders')">
<button type="button" class="toolbar-btn outline" @click="router.push('/orders')">
返回列表
</button>
</header>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { readError } from '@/shared/utils/error'
import { onMounted, ref, computed } from 'vue'
import { onMounted, ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
@@ -11,16 +11,40 @@ import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySe
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
import { useMobilePayWaySelect } from '@/features/orders/composables/useMobilePayWaySelect'
import { fetchOrderChat } from '@/features/chats/api/chats'
import { centToYuan, formatMoney } from '@/shared/utils/money'
import {
fetchMyMohongOrders,
mohongOrderStatusLabel,
type MohongOrder,
} from '@/features/mohong/api/mohong'
import { centToYuan, formatCent, formatMoney } from '@/shared/utils/money'
import { useSessionStore } from '@/stores/session'
import { formatDateMinute } from '@/shared/utils/time'
import { formatListingNo } from '@/shared/utils/listingDisplay'
type BizType = 'rental' | 'crash'
interface UnifiedOrderItem {
key: string
bizType: BizType
id: number
orderNo: string
title: string
status: string
createdAt: string
coverUrl?: string
quantity?: number
unit?: string
amountText: string
rental?: Order
crash?: MohongOrder
}
const router = useRouter()
const route = useRoute()
const session = useSessionStore()
const loading = ref(false)
const orders = ref<Order[]>([])
const rentalOrders = ref<Order[]>([])
const crashOrders = ref<MohongOrder[]>([])
const activeTab = ref('all')
const payingOrderId = ref<number | null>(null)
const {
@@ -34,45 +58,113 @@ const {
refreshPaymentStatus,
} = useMobilePaymentCashier()
// 支付方式选择底部弹层。
const payWaySelect = useMobilePayWaySelect()
const selectPayWay = payWaySelect.select
onMounted(() => {
loadOrders()
if (route.query.tab) {
activeTab.value = String(route.query.tab)
}
})
async function loadOrders() {
loading.value = true
try {
const items = await fetchOrders()
orders.value = Array.isArray(items) ? items : []
} catch {
showToast({ message: '订单加载失败,请稍后重试', icon: 'warning-o' })
} finally {
loading.value = false
}
}
/* 状态筛选 */
const statusTabs = [
{ key: 'all', label: '全部' },
{ key: 'pending_payment', label: '待支付' },
{ key: 'pending_handoff', label: '待交接' },
{ key: 'renting', label: '使用中' },
{ key: 'paid', label: '已支付' },
{ key: 'pending_checkout_confirm', label: '待结账' },
{ key: 'completed', label: '已完成' },
{ key: 'cancelled', label: '已取消' },
]
const displayOrders = computed(() => {
if (activeTab.value === 'all') return orders.value
return orders.value.filter(o => o.status === activeTab.value)
const unifiedOrders = computed<UnifiedOrderItem[]>(() => {
const rentalItems: UnifiedOrderItem[] = rentalOrders.value.map(order => ({
key: `rental-${order.id}`,
bizType: 'rental',
id: order.id,
orderNo: order.order_no,
title: order.title,
status: order.status,
createdAt: order.created_at,
amountText: money(orderRentAmount(order)),
rental: order,
}))
const crashItems: UnifiedOrderItem[] = crashOrders.value.map(order => ({
key: `crash-${order.id}`,
bizType: 'crash',
id: order.id,
orderNo: order.order_no,
title: order.product_title,
status: order.status,
createdAt: order.created_at,
coverUrl: order.product_cover_url,
quantity: order.quantity,
unit: order.product_unit,
amountText: order.amount || formatCent(order.amount_cent),
crash: order,
}))
return [...rentalItems, ...crashItems].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
})
function statusLabel(status: string) {
const displayOrders = computed(() => {
if (activeTab.value === 'all') return unifiedOrders.value
return unifiedOrders.value.filter(item => item.status === activeTab.value)
})
onMounted(() => {
if (route.query.tab) {
activeTab.value = String(route.query.tab)
}
loadOrders()
})
watch(
() => route.query.tab,
tab => {
if (tab) activeTab.value = String(tab)
}
)
function onStatusTabChange(name: string | number) {
activeTab.value = String(name)
router.replace({
path: '/m/orders',
query: {
tab: activeTab.value === 'all' ? undefined : activeTab.value,
},
})
}
async function loadOrders() {
loading.value = true
try {
const [rentalResult, crashResult] = await Promise.allSettled([
fetchOrders(),
fetchMyMohongOrders({ page: 1, page_size: 50 }),
])
if (rentalResult.status === 'fulfilled') {
rentalOrders.value = Array.isArray(rentalResult.value) ? rentalResult.value : []
} else {
rentalOrders.value = []
showToast({ message: '租赁订单加载失败', icon: 'warning-o' })
}
if (crashResult.status === 'fulfilled') {
crashOrders.value = crashResult.value?.items || []
} else {
crashOrders.value = []
showToast({
message: readError(crashResult.reason, '撞车订单加载失败'),
icon: 'warning-o',
})
}
} finally {
loading.value = false
}
}
function statusLabel(item: UnifiedOrderItem) {
if (item.bizType === 'crash') return mohongOrderStatusLabel(item.status)
const map: Record<string, string> = {
pending_payment: '待支付',
pending_handoff: '待交接',
@@ -88,11 +180,19 @@ function statusLabel(status: string) {
abnormal: '异常',
closed: '已关闭',
}
return map[status] || status
return map[item.status] || item.status
}
function goDetail(id: number) {
router.push(`/m/orders/${id}`)
function bizTypeLabel(bizType: BizType) {
return bizType === 'crash' ? '撞车' : '租赁'
}
function goDetail(item: UnifiedOrderItem) {
if (item.bizType === 'crash') {
router.push(`/m/crash/orders/${item.id}`)
return
}
router.push(`/m/orders/${item.id}`)
}
async function handlePay(order: Order) {
@@ -124,8 +224,6 @@ async function openOrderChatAfterPayment(order: Order) {
}
}
// selectPayWay 在列表快捷支付前让用户选择支付宝或微信(见 payWaySelect)。
function money(value: unknown) {
return formatMoney(Number(value || 0))
}
@@ -171,7 +269,6 @@ async function copyListingCode(order: Order) {
<template>
<main class="mobile-orders">
<!-- 顶部导航 -->
<header class="page-header">
<button class="back-btn" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
@@ -180,7 +277,6 @@ async function copyListingCode(order: Order) {
<span class="header-spacer"></span>
</header>
<!-- 状态筛选条 - Vant 滑动标签页 -->
<van-tabs
v-model:active="activeTab"
class="custom-tabs"
@@ -192,11 +288,11 @@ async function copyListingCode(order: Order) {
:border="false"
swipeable
animated
@change="onStatusTabChange"
>
<van-tab v-for="tab in statusTabs" :key="tab.key" :title="tab.label" :name="tab.key" />
</van-tabs>
<!-- 订单列表 -->
<section class="order-list">
<van-loading v-if="loading" class="center-loading" size="24px" vertical>
加载中...
@@ -208,77 +304,124 @@ async function copyListingCode(order: Order) {
<div
v-else
v-for="order in displayOrders"
:key="order.id"
v-for="item in displayOrders"
:key="item.key"
class="order-card"
@click="goDetail(order.id)"
@click="goDetail(item)"
>
<!-- 卡片头订单号 + 状态 -->
<div class="card-header">
<div class="header-left">
<span class="order-no">{{ order.order_no }}</span>
<span class="biz-tag" :class="item.bizType">{{ bizTypeLabel(item.bizType) }}</span>
<span class="order-no">{{ item.orderNo }}</span>
</div>
<span class="status-badge" :class="'badge-' + order.status">
{{ statusLabel(order.status) }}
<span class="status-badge" :class="'badge-' + item.status">
{{ statusLabel(item) }}
</span>
</div>
<!-- 卡片关键信息 -->
<div class="card-body">
<button class="listing-code-chip" type="button" @click.stop="copyListingCode(order)">
商品编号 {{ formatListingCode(order) }}
</button>
<h3 class="order-title">{{ order.title }}</h3>
<div class="tag-row">
<span class="info-tag">{{ order.server_region }}</span>
<span class="info-tag">{{ order.login_platform }}</span>
<!-- 租赁卡片 -->
<template v-if="item.bizType === 'rental' && item.rental">
<div class="card-body">
<button
class="listing-code-chip"
type="button"
@click.stop="copyListingCode(item.rental)"
>
商品编号 {{ formatListingCode(item.rental) }}
</button>
<h3 class="order-title">{{ item.title }}</h3>
<div class="tag-row">
<span class="info-tag">{{ item.rental.server_region }}</span>
<span class="info-tag">{{ item.rental.login_platform }}</span>
</div>
<div class="price-row">
<div class="price-item">
<span class="price-label">{{ amountLabel(item.rental) }}</span>
<span class="price-val">¥{{ money(orderRentAmount(item.rental)) }}</span>
<span v-if="ownerActualIncome(item.rental) !== null" class="price-sub"
>实际到手 ¥{{ money(ownerActualIncome(item.rental)) }}</span
>
</div>
<div class="price-item">
<span class="price-label">押金金额</span>
<span class="price-val deposit">
¥{{ money(amountYuan(item.rental.deposit_amount_cent)) }}
<em v-if="amountYuan(item.rental.deposit_waived_amount_cent) > 0"
> ¥{{ money(amountYuan(item.rental.deposit_waived_amount_cent)) }}</em
>
</span>
</div>
</div>
</div>
<div class="price-row">
<div class="price-item">
<span class="price-label">{{ amountLabel(order) }}</span>
<span class="price-val">¥{{ money(orderRentAmount(order)) }}</span>
<span v-if="ownerActualIncome(order) !== null" class="price-sub"
>实际到手 ¥{{ money(ownerActualIncome(order)) }}</span
>
<div class="card-footer">
<div class="time-box">
<van-icon name="clock-o" class="clock-icon" />
<span class="order-time">{{ formatDateMinute(item.createdAt) }}</span>
</div>
<div class="price-item">
<span class="price-label">押金金额</span>
<span class="price-val deposit">
¥{{ money(amountYuan(order.deposit_amount_cent)) }}
<em v-if="amountYuan(order.deposit_waived_amount_cent) > 0"
> ¥{{ money(amountYuan(order.deposit_waived_amount_cent)) }}</em
>
<div class="footer-action">
<van-button
v-if="
item.rental.status === 'pending_payment' &&
item.rental.renter_id === session.userId
"
size="small"
type="warning"
round
class="pay-btn"
:loading="payingOrderId === item.rental.id"
@click.stop="handlePay(item.rental)"
>
去支付
</van-button>
<span v-else class="detail-link">
查看详情 <van-icon name="arrow" :size="10" />
</span>
</div>
</div>
</div>
</template>
<!-- 卡片底时间 + 操作 -->
<div class="card-footer">
<div class="time-box">
<van-icon name="clock-o" class="clock-icon" />
<span class="order-time">{{ formatDateMinute(order.created_at) }}</span>
<!-- 撞车卡片 -->
<template v-else>
<div class="card-body crash-body">
<div class="crash-cover">
<img v-if="item.coverUrl" :src="item.coverUrl" alt="" />
<span v-else>图</span>
</div>
<div class="crash-info">
<h3 class="order-title">{{ item.title }}</h3>
<p class="crash-meta">
x{{ item.quantity || 1 }}
<template v-if="item.unit"> · {{ item.unit }}</template>
</p>
<div class="price-row crash-price">
<div class="price-item">
<span class="price-label">订单金额</span>
<span class="price-val">¥{{ item.amountText }}</span>
</div>
</div>
</div>
</div>
<div class="footer-action">
<van-button
v-if="order.status === 'pending_payment' && order.renter_id === session.userId"
size="small"
type="warning"
round
class="pay-btn"
:loading="payingOrderId === order.id"
@click.stop="handlePay(order)"
>
去支付
</van-button>
<span v-else class="detail-link"> 查看详情 <van-icon name="arrow" :size="10" /> </span>
<div class="card-footer">
<div class="time-box">
<van-icon name="clock-o" class="clock-icon" />
<span class="order-time">{{ formatDateMinute(item.createdAt) }}</span>
</div>
<div class="footer-action">
<span v-if="item.status === 'pending_payment'" class="detail-link pay-hint">
去支付 <van-icon name="arrow" :size="10" />
</span>
<span v-else class="detail-link">
查看详情 <van-icon name="arrow" :size="10" />
</span>
</div>
</div>
</div>
</template>
</div>
</section>
<!-- 底部导航 -->
<MobilePayWaySelectPopup
v-model:show="payWaySelect.visible.value"
@choose="payWaySelect.choose"
@@ -306,7 +449,6 @@ async function copyListingCode(order: Order) {
padding-bottom: calc(64px + env(safe-area-inset-bottom));
}
/* ========== 顶部导航 ========== */
.page-header {
position: sticky;
top: 0;
@@ -344,7 +486,6 @@ async function copyListingCode(order: Order) {
width: 36px;
}
/* ========== Vant Tabs 自定义样式 ========== */
.custom-tabs {
position: sticky;
top: 48px;
@@ -364,7 +505,6 @@ async function copyListingCode(order: Order) {
font-weight: 600;
}
/* ========== 订单列表 ========== */
.order-list {
padding: 14px 16px;
}
@@ -379,7 +519,6 @@ async function copyListingCode(order: Order) {
padding: 40px 0;
}
/* ========== 订单卡片 ========== */
.order-card {
background: #ffffff;
border-radius: 16px;
@@ -406,15 +545,46 @@ async function copyListingCode(order: Order) {
justify-content: space-between;
padding-bottom: 10px;
border-bottom: 1px dashed #f3f4f6;
gap: 8px;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.biz-tag {
flex-shrink: 0;
padding: 2px 6px;
border-radius: 6px;
font-size: 10px;
font-weight: 800;
line-height: 1.2;
}
.biz-tag.rental {
color: #1477ff;
background: rgba(20, 119, 255, 0.1);
}
.biz-tag.crash {
color: #ff6a00;
background: rgba(255, 106, 0, 0.1);
}
.order-no {
font-size: 11px;
color: #9ca3af;
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-badge {
flex-shrink: 0;
padding: 4px 8px;
border-radius: 8px;
font-size: 11px;
@@ -433,7 +603,6 @@ async function copyListingCode(order: Order) {
font-weight: 800;
}
/* 状态徽章颜色 - 现代轻量化配色 */
.badge-pending_payment {
color: #ff6a00;
background: rgba(255, 106, 0, 0.08);
@@ -450,10 +619,7 @@ async function copyListingCode(order: Order) {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
}
.badge-pending_return_confirm {
color: #8b5cf6;
background: rgba(139, 92, 246, 0.08);
}
.badge-pending_return_confirm,
.badge-pending_checkout_confirm {
color: #8b5cf6;
background: rgba(139, 92, 246, 0.08);
@@ -462,30 +628,26 @@ async function copyListingCode(order: Order) {
color: #a855f7;
background: rgba(168, 85, 247, 0.08);
}
.badge-checkout_disputing {
.badge-checkout_disputing,
.badge-disputing,
.badge-abnormal {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
}
.badge-paid {
color: #1477ff;
background: rgba(20, 119, 255, 0.08);
}
.badge-completed {
color: #10b981;
background: rgba(16, 185, 129, 0.08);
}
.badge-cancelled {
.badge-cancelled,
.badge-closed,
.badge-refunded {
color: #9ca3af;
background: rgba(156, 163, 175, 0.08);
}
.badge-disputing {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
}
.badge-abnormal {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
}
.badge-closed {
color: #6b7280;
background: rgba(107, 114, 128, 0.08);
}
.card-body {
padding: 12px 0 0;
@@ -560,7 +722,49 @@ async function copyListingCode(order: Order) {
font-weight: 700;
}
/* ========== 卡片底部 ========== */
.crash-body {
display: grid;
grid-template-columns: 64px 1fr;
gap: 12px;
}
.crash-cover {
width: 64px;
height: 64px;
border-radius: 10px;
overflow: hidden;
background: #f0f3f8;
display: flex;
align-items: center;
justify-content: center;
color: #a0aec0;
font-size: 12px;
}
.crash-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.crash-info {
min-width: 0;
}
.crash-meta {
margin: 0 0 8px;
font-size: 13px;
color: #8a94a6;
}
.crash-price {
padding: 8px 12px;
}
.crash-price .price-val {
align-items: flex-start;
}
.card-footer {
display: flex;
align-items: center;
@@ -594,6 +798,10 @@ async function copyListingCode(order: Order) {
gap: 2px;
}
.pay-hint {
color: #ff5f00;
}
.pay-btn {
height: 28px !important;
padding: 0 16px !important;
+416 -108
View File
@@ -6,33 +6,93 @@ import { useRoute, useRouter } from 'vue-router'
import { CopyDocument, Search } from '@element-plus/icons-vue'
import { fetchOrders, type Order } from '@/features/orders'
import { centToYuan, formatMoney } from '@/shared/utils/money'
import {
fetchMyMohongOrders,
mohongOrderStatusLabel,
type MohongOrder,
} from '@/features/mohong/api/mohong'
import { centToYuan, formatCent, formatMoney } from '@/shared/utils/money'
import { useSessionStore } from '@/stores/session'
import { orderStatusLabel } from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
import { formatListingNo } from '@/shared/utils/listingDisplay'
type BizType = 'rental' | 'crash'
interface UnifiedOrderItem {
key: string
bizType: BizType
id: number
orderNo: string
title: string
status: string
createdAt: string
amountText: string
coverUrl?: string
quantity?: number
unit?: string
rental?: Order
crash?: MohongOrder
}
const loading = ref(false)
const orders = ref<Order[]>([])
const rentalOrders = ref<Order[]>([])
const crashOrders = ref<MohongOrder[]>([])
const payingOrderId = ref<number | null>(null)
const session = useSessionStore()
const route = useRoute()
const router = useRouter()
const searchKeyword = ref('')
const fallbackPendingPaymentMinutes = 15
const statusTabs = [
{ key: 'all', label: '全部' },
{ key: 'pending_payment', label: '待支付' },
{ key: 'pending_handoff', label: '待交接' },
{ key: 'renting', label: '使用中' },
{ key: 'paid', label: '已支付' },
{ key: 'pending_checkout_confirm', label: '待结账' },
{ key: 'completed', label: '已完成' },
{ key: 'cancelled', label: '已取消' },
]
const tabKeys = new Set(statusTabs.map(item => item.key))
const activeTab = ref(readTab(route.query.tab))
const fallbackPendingPaymentMinutes = 15
const unifiedOrders = computed<UnifiedOrderItem[]>(() => {
const rentalItems: UnifiedOrderItem[] = rentalOrders.value.map(order => ({
key: `rental-${order.id}`,
bizType: 'rental',
id: order.id,
orderNo: order.order_no,
title: order.title,
status: order.status,
createdAt: order.created_at,
amountText: money(orderRentAmount(order)),
rental: order,
}))
const crashItems: UnifiedOrderItem[] = crashOrders.value.map(order => ({
key: `crash-${order.id}`,
bizType: 'crash',
id: order.id,
orderNo: order.order_no,
title: order.product_title,
status: order.status,
createdAt: order.created_at,
amountText: order.amount || formatCent(order.amount_cent),
coverUrl: order.product_cover_url,
quantity: order.quantity,
unit: order.product_unit,
crash: order,
}))
return [...rentalItems, ...crashItems].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
})
const displayOrders = computed(() => {
let filtered = orders.value
let filtered = unifiedOrders.value
if (activeTab.value !== 'all') {
filtered = filtered.filter(order => order.status === activeTab.value)
@@ -40,24 +100,28 @@ const displayOrders = computed(() => {
if (searchKeyword.value.trim()) {
const keyword = searchKeyword.value.trim().toLowerCase()
filtered = filtered.filter(
order =>
textValue(order.order_no).toLowerCase().includes(keyword) ||
formatListingCode(order).toLowerCase().includes(keyword) ||
String(order.listing_id).includes(keyword) ||
String(order.account_id).includes(keyword) ||
textValue(order.title).toLowerCase().includes(keyword)
)
filtered = filtered.filter(order => {
if (textValue(order.orderNo).toLowerCase().includes(keyword)) return true
if (textValue(order.title).toLowerCase().includes(keyword)) return true
if (order.bizType === 'rental' && order.rental) {
return (
formatListingCode(order.rental).toLowerCase().includes(keyword) ||
String(order.rental.listing_id).includes(keyword) ||
String(order.rental.account_id).includes(keyword)
)
}
return false
})
}
return filtered
})
const tabCounts = computed(() => {
const counts: Record<string, number> = { all: orders.value.length }
const counts: Record<string, number> = { all: unifiedOrders.value.length }
statusTabs.forEach(tab => {
if (tab.key !== 'all') {
counts[tab.key] = orders.value.filter(order => order.status === tab.key).length
counts[tab.key] = unifiedOrders.value.filter(order => order.status === tab.key).length
}
})
return counts
@@ -79,19 +143,44 @@ function readTab(tab: unknown) {
function handleTabChange(tab: string | number) {
const nextTab = readTab(String(tab))
activeTab.value = nextTab
router.replace({ path: '/orders', query: nextTab === 'all' ? {} : { tab: nextTab } })
}
async function loadOrders() {
loading.value = true
try {
const items = await fetchOrders()
orders.value = Array.isArray(items) ? items : []
const [rentalResult, crashResult] = await Promise.allSettled([
fetchOrders(),
fetchMyMohongOrders({ page: 1, page_size: 50 }),
])
if (rentalResult.status === 'fulfilled') {
rentalOrders.value = Array.isArray(rentalResult.value) ? rentalResult.value : []
} else {
rentalOrders.value = []
ElMessage.error('租赁订单加载失败')
}
if (crashResult.status === 'fulfilled') {
crashOrders.value = crashResult.value?.items || []
} else {
crashOrders.value = []
ElMessage.error(readError(crashResult.reason, '撞车订单加载失败'))
}
} finally {
loading.value = false
}
}
function goDetail(item: UnifiedOrderItem) {
if (item.bizType === 'crash') {
router.push(`/crash/orders/${item.id}`)
return
}
router.push(`/orders/${item.id}`)
}
async function handlePay(order: Order) {
payingOrderId.value = order.id
try {
@@ -202,6 +291,15 @@ function getCountdownMinutes(order: Order) {
const diff = deadline.getTime() - now.getTime()
return Math.max(0, Math.ceil(diff / 60000))
}
function statusText(item: UnifiedOrderItem) {
if (item.bizType === 'crash') return mohongOrderStatusLabel(item.status)
return orderStatusLabel(item.status)
}
function bizTypeLabel(bizType: BizType) {
return bizType === 'crash' ? '撞车' : '租赁'
}
</script>
<template>
@@ -209,13 +307,13 @@ function getCountdownMinutes(order: Order) {
<div class="page-header">
<p class="eyebrow">ORDERS</p>
<h1>我的订单</h1>
<p>跟踪待支付待交接使用中待归还申诉中和已完成订单</p>
<p>租赁与撞车订单统一展示按创建时间倒序排列</p>
</div>
<div class="orders-toolbar">
<el-input
v-model="searchKeyword"
placeholder="搜索订单号 / 商品编号 / 账号"
placeholder="搜索订单号 / 商品 / 账号"
:prefix-icon="Search"
clearable
class="search-input"
@@ -241,89 +339,130 @@ function getCountdownMinutes(order: Order) {
<div v-else class="orders-container">
<el-table v-loading="loading" class="table-panel orders-table" :data="displayOrders">
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">
<span class="biz-tag" :class="row.bizType">{{ bizTypeLabel(row.bizType) }}</span>
</template>
</el-table-column>
<el-table-column label="订单号" min-width="180">
<template #default="{ row }">
<div class="order-no-cell">
<span class="order-no-text" :title="row.order_no">{{
shortenOrderNo(row.order_no)
<span class="order-no-text" :title="row.orderNo">{{
shortenOrderNo(row.orderNo)
}}</span>
<el-icon class="copy-icon" @click="copyOrderNo(row.order_no)">
<el-icon class="copy-icon" @click="copyOrderNo(row.orderNo)">
<CopyDocument />
</el-icon>
</div>
</template>
</el-table-column>
<el-table-column label="商品编号" width="150">
<el-table-column label="商品 / 账号" min-width="220">
<template #default="{ row }">
<div class="order-no-cell">
<span class="order-no-text">{{ formatListingCode(row) }}</span>
<el-icon class="copy-icon" @click="copyListingCode(row)">
<CopyDocument />
</el-icon>
<div v-if="row.bizType === 'crash'" class="crash-product-cell">
<div class="crash-cover">
<img v-if="row.coverUrl" :src="row.coverUrl" alt="" />
<span v-else></span>
</div>
<div class="crash-product-info">
<strong>{{ row.title }}</strong>
<span
>x{{ row.quantity || 1 }}{{ row.unit ? ` · ${row.unit}` : '' }}</span
>
</div>
</div>
<div v-else class="rental-product-cell">
<button
v-if="row.rental"
class="listing-code-line compact"
type="button"
@click="copyListingCode(row.rental)"
>
{{ formatListingCode(row.rental) }}
<el-icon><CopyDocument /></el-icon>
</button>
<span class="rental-title">{{ row.title }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="title" label="账号" min-width="180" />
<el-table-column label="金额" width="132">
<el-table-column label="金额" width="140">
<template #default="{ row }">
<div class="amount-cell">
<span class="amount-value">¥{{ money(orderRentAmount(row)) }}</span>
<span class="amount-label">{{ amountLabel(row) }}</span>
<span v-if="ownerActualIncome(row) !== null" class="amount-sub"
>实际到手 ¥{{ money(ownerActualIncome(row)) }}</span
<div v-if="row.bizType === 'rental' && row.rental" class="amount-cell">
<span class="amount-value">¥{{ money(orderRentAmount(row.rental)) }}</span>
<span class="amount-label">{{ amountLabel(row.rental) }}</span>
<span v-if="ownerActualIncome(row.rental) !== null" class="amount-sub"
>实际到手 ¥{{ money(ownerActualIncome(row.rental)) }}</span
>
</div>
<div v-else class="amount-cell">
<span class="amount-value">¥{{ row.amountText }}</span>
<span class="amount-label">订单金额</span>
</div>
</template>
</el-table-column>
<el-table-column label="押金" width="100">
<el-table-column label="押金 / 数量" width="110">
<template #default="{ row }">
<div class="amount-cell">
<span class="amount-value">¥{{ money(amountYuan(row.deposit_amount_cent)) }}</span>
<span v-if="amountYuan(row.deposit_waived_amount_cent) > 0" class="amount-label"
> ¥{{ money(amountYuan(row.deposit_waived_amount_cent)) }}</span
<div v-if="row.bizType === 'rental' && row.rental" class="amount-cell">
<span class="amount-value">¥{{ money(amountYuan(row.rental.deposit_amount_cent)) }}</span>
<span v-if="amountYuan(row.rental.deposit_waived_amount_cent) > 0" class="amount-label"
> ¥{{ money(amountYuan(row.rental.deposit_waived_amount_cent)) }}</span
>
</div>
<span v-else class="amount-value"
>x{{ row.quantity || 1 }}{{ row.unit ? row.unit : '' }}</span
>
</template>
</el-table-column>
<el-table-column label="身份" width="80">
<template #default="{ row }">
<el-tag :type="isRenter(row) ? 'primary' : 'success'" size="small">
{{ orderRole(row) }}
<el-tag
v-if="row.bizType === 'rental' && row.rental"
:type="isRenter(row.rental) ? 'primary' : 'success'"
size="small"
>
{{ orderRole(row.rental) }}
</el-tag>
<span v-else class="muted">买家</span>
</template>
</el-table-column>
<el-table-column label="状态" width="140">
<template #default="{ row }">
<div class="status-cell">
<span>{{ orderStatusLabel(row.status) }}</span>
<span>{{ statusText(row) }}</span>
<span
v-if="row.status === 'pending_payment' && getCountdownMinutes(row) > 0"
v-if="
row.bizType === 'rental' &&
row.rental &&
row.rental.status === 'pending_payment' &&
getCountdownMinutes(row.rental) > 0
"
class="countdown-badge"
>
{{ getCountdownMinutes(row) }}分钟后超时
{{ getCountdownMinutes(row.rental) }}分钟后超时
</span>
</div>
</template>
</el-table-column>
<el-table-column label="创建时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button
v-if="row.status === 'pending_payment'"
v-if="
row.bizType === 'rental' &&
row.rental &&
row.rental.status === 'pending_payment'
"
size="small"
type="primary"
:disabled="row.renter_id !== session.userId"
:loading="payingOrderId === row.id"
@click="handlePay(row)"
:disabled="row.rental.renter_id !== session.userId"
:loading="payingOrderId === row.rental.id"
@click="handlePay(row.rental)"
>
使用中
去支付
</el-button>
<RouterLink :to="`/orders/${row.id}`">
<el-button size="small">详情</el-button>
</RouterLink>
<el-button size="small" @click="goDetail(row)">详情</el-button>
</div>
</template>
</el-table-column>
@@ -331,72 +470,115 @@ function getCountdownMinutes(order: Order) {
<div class="mobile-cards">
<div
v-for="order in displayOrders"
:key="order.id"
v-for="item in displayOrders"
:key="item.key"
class="order-card"
@click="router.push(`/orders/${order.id}`)"
@click="goDetail(item)"
>
<div class="card-header">
<div class="order-no-row">
<span class="order-no-text" :title="order.order_no">{{
shortenOrderNo(order.order_no)
<span class="biz-tag" :class="item.bizType">{{ bizTypeLabel(item.bizType) }}</span>
<span class="order-no-text" :title="item.orderNo">{{
shortenOrderNo(item.orderNo)
}}</span>
<el-icon class="copy-icon" @click.stop="copyOrderNo(order.order_no)">
<el-icon class="copy-icon" @click.stop="copyOrderNo(item.orderNo)">
<CopyDocument />
</el-icon>
</div>
<el-tag :type="isRenter(order) ? 'primary' : 'success'" size="small">
{{ orderRole(order) }}
</el-tag>
</div>
<button class="listing-code-line" type="button" @click.stop="copyListingCode(order)">
商品编号 {{ formatListingCode(order) }}
<el-icon><CopyDocument /></el-icon>
</button>
<div class="card-title">{{ order.title }}</div>
<div class="card-meta">
<div class="meta-row">
<span class="meta-label">{{ amountLabel(order) }}</span>
<span class="meta-value amount">¥{{ money(orderRentAmount(order)) }}</span>
</div>
<div v-if="ownerActualIncome(order) !== null" class="meta-row">
<span class="meta-label">实际到手</span>
<span class="meta-value amount">¥{{ money(ownerActualIncome(order)) }}</span>
</div>
<div class="meta-row">
<span class="meta-label">押金</span>
<span class="meta-value">
¥{{ money(amountYuan(order.deposit_amount_cent)) }}
<em v-if="amountYuan(order.deposit_waived_amount_cent) > 0"
> ¥{{ money(amountYuan(order.deposit_waived_amount_cent)) }}</em
>
</span>
</div>
<div class="meta-row">
<span class="meta-label">创建时间</span>
<span class="meta-value">{{ formatDateTime(order.created_at) }}</span>
</div>
</div>
<div class="card-footer">
<div class="status-info">
<span class="status-label">{{ orderStatusLabel(order.status) }}</span>
<span
v-if="order.status === 'pending_payment' && getCountdownMinutes(order) > 0"
class="countdown-badge"
>
{{ getCountdownMinutes(order) }}分钟后超时
</span>
</div>
<el-button
v-if="order.status === 'pending_payment' && isRenter(order)"
<el-tag
v-if="item.bizType === 'rental' && item.rental"
:type="isRenter(item.rental) ? 'primary' : 'success'"
size="small"
type="primary"
:loading="payingOrderId === order.id"
@click.stop="handlePay(order)"
>
立即支付
</el-button>
{{ orderRole(item.rental) }}
</el-tag>
<span v-else class="crash-status" :class="item.status">{{ statusText(item) }}</span>
</div>
<template v-if="item.bizType === 'rental' && item.rental">
<button
class="listing-code-line"
type="button"
@click.stop="copyListingCode(item.rental)"
>
商品编号 {{ formatListingCode(item.rental) }}
<el-icon><CopyDocument /></el-icon>
</button>
<div class="card-title">{{ item.title }}</div>
<div class="card-meta">
<div class="meta-row">
<span class="meta-label">{{ amountLabel(item.rental) }}</span>
<span class="meta-value amount">¥{{ money(orderRentAmount(item.rental)) }}</span>
</div>
<div v-if="ownerActualIncome(item.rental) !== null" class="meta-row">
<span class="meta-label">实际到手</span>
<span class="meta-value amount">¥{{ money(ownerActualIncome(item.rental)) }}</span>
</div>
<div class="meta-row">
<span class="meta-label">押金</span>
<span class="meta-value">
¥{{ money(amountYuan(item.rental.deposit_amount_cent)) }}
<em v-if="amountYuan(item.rental.deposit_waived_amount_cent) > 0"
>免 ¥{{ money(amountYuan(item.rental.deposit_waived_amount_cent)) }}</em
>
</span>
</div>
<div class="meta-row">
<span class="meta-label">创建时间</span>
<span class="meta-value">{{ formatDateTime(item.createdAt) }}</span>
</div>
</div>
<div class="card-footer">
<div class="status-info">
<span class="status-label">{{ statusText(item) }}</span>
<span
v-if="
item.rental.status === 'pending_payment' && getCountdownMinutes(item.rental) > 0
"
class="countdown-badge"
>
{{ getCountdownMinutes(item.rental) }}分钟后超时
</span>
</div>
<el-button
v-if="item.rental.status === 'pending_payment' && isRenter(item.rental)"
size="small"
type="primary"
:loading="payingOrderId === item.rental.id"
@click.stop="handlePay(item.rental)"
>
立即支付
</el-button>
</div>
</template>
<template v-else>
<div class="crash-card-body">
<div class="crash-cover lg">
<img v-if="item.coverUrl" :src="item.coverUrl" alt="" />
<span v-else>图</span>
</div>
<div>
<div class="card-title">{{ item.title }}</div>
<div class="card-meta">
<div class="meta-row">
<span class="meta-label">数量</span>
<span class="meta-value"
>x{{ item.quantity || 1 }}{{ item.unit ? ` · ${item.unit}` : '' }}</span
>
</div>
<div class="meta-row">
<span class="meta-label">金额</span>
<span class="meta-value amount">¥{{ item.amountText }}</span>
</div>
<div class="meta-row">
<span class="meta-label">创建时间</span>
<span class="meta-value">{{ formatDateTime(item.createdAt) }}</span>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
@@ -472,6 +654,27 @@ function getCountdownMinutes(order: Order) {
display: none;
}
.biz-tag {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.biz-tag.rental {
color: #1477ff;
background: rgba(20, 119, 255, 0.1);
}
.biz-tag.crash {
color: #ff6a00;
background: rgba(255, 106, 0, 0.1);
}
.order-no-cell {
display: flex;
align-items: center;
@@ -500,6 +703,11 @@ function getCountdownMinutes(order: Order) {
cursor: pointer;
}
.listing-code-line.compact {
margin-top: 0;
margin-bottom: 4px;
}
.copy-icon {
cursor: pointer;
color: #9ca3af;
@@ -554,6 +762,105 @@ function getCountdownMinutes(order: Order) {
gap: 8px;
}
.muted {
color: #9ca3af;
font-size: 13px;
}
.crash-product-cell,
.rental-product-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.crash-product-cell {
flex-direction: row;
align-items: center;
gap: 12px;
}
.crash-cover {
width: 48px;
height: 48px;
border-radius: 10px;
overflow: hidden;
background: #f0f3f8;
display: flex;
align-items: center;
justify-content: center;
color: #a0aec0;
font-size: 12px;
flex-shrink: 0;
}
.crash-cover.lg {
width: 64px;
height: 64px;
}
.crash-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.crash-product-info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.crash-product-info strong,
.rental-title {
font-size: 14px;
color: #1f2937;
}
.crash-product-info span {
font-size: 12px;
color: #8a94a6;
}
.crash-status {
display: inline-flex;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: #f1f5f9;
color: #64748b;
}
.crash-status.pending_payment {
background: #fff7ed;
color: #ff6a00;
}
.crash-status.paid {
background: #ecfdf5;
color: #059669;
}
.crash-status.completed {
background: #eff6ff;
color: #2563eb;
}
.crash-status.cancelled,
.crash-status.refunded {
background: #f8fafc;
color: #94a3b8;
}
.crash-card-body {
display: grid;
grid-template-columns: 64px 1fr;
gap: 12px;
}
.order-card {
border: 1px solid #e5e7eb;
border-radius: 12px;
@@ -580,6 +887,7 @@ function getCountdownMinutes(order: Order) {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.card-title {
+2 -2
View File
@@ -98,10 +98,10 @@ export const mobileRoutes: RouteRecordRaw[] = [
meta: { layout: 'blank' },
},
{
// 撞车订单列表统一到底栏「订单」页(与租赁订单混排)
path: '/m/crash/orders',
name: 'mobile-crash-orders',
component: () => import('@/features/mohong/views/MobileMohongOrdersView.vue'),
meta: { layout: 'blank', requiresAuth: true },
redirect: '/m/orders',
},
{
path: '/m/crash/orders/:id',
+2 -2
View File
@@ -23,10 +23,10 @@ export const publicRoutes: RouteRecordRaw[] = [
component: () => import('@/features/mohong/views/MohongListView.vue'),
},
{
// 撞车订单列表统一到「我的订单」页(与租赁订单混排)
path: '/crash/orders',
name: 'crash-orders',
component: () => import('@/features/mohong/views/MohongOrdersView.vue'),
meta: { requiresAuth: true },
redirect: '/orders',
},
{
path: '/crash/orders/:id',