- Auth/权限中间件取 main 演进版(错误细分、token 来源),保留 dev 的用户 cookie 与登出撤销语义 - 后台订单列表:融合 main 的首页免 COUNT 优化与关键词索引路由,保留 dev 的最小化 AdminOrderListItemDTO(结账明细仍由详情接口返回,同步调整对应集成测试) - 保留 dev 的 token_version 冻结/解冻递增与 main 的状态变化判断及通知 - 前端:保留 dev 登出接口与 main 的 fetchMe silent 参数、SSE 登录跳转;后台订单表按 dev 最小化 DTO 移除号主到手列
584 lines
19 KiB
Vue
584 lines
19 KiB
Vue
<script setup lang="ts">
|
||
import { readError } from '@/shared/utils/error'
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { CopyDocument, Search } from '@element-plus/icons-vue'
|
||
|
||
import { fetchOrders, type UserOrderListItem } from '@/features/orders'
|
||
import {
|
||
fetchMyMohongOrders,
|
||
mohongOrderStatusLabel,
|
||
type MohongOrder,
|
||
} from '@/features/mohong/api/mohong'
|
||
import { centToYuan, formatCent, formatMoney } from '@/shared/utils/money'
|
||
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?: UserOrderListItem
|
||
crash?: MohongOrder
|
||
}
|
||
|
||
const loading = ref(false)
|
||
const rentalOrders = ref<UserOrderListItem[]>([])
|
||
const crashOrders = ref<MohongOrder[]>([])
|
||
const payingOrderId = ref<number | null>(null)
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const searchKeyword = ref('')
|
||
const fallbackPendingPaymentMinutes = 15
|
||
const rentalTotal = ref(0)
|
||
const rentalPage = ref(1)
|
||
const rentalPageSize = 20
|
||
|
||
const statusTabs = [
|
||
{ key: 'all', label: '全部' },
|
||
{ key: 'pending_payment', label: '待支付' },
|
||
{ key: 'pending_handoff', label: '待交接' },
|
||
{ key: 'renting', label: '使用中' },
|
||
{ key: 'paid', label: '已支付' },
|
||
{ key: 'receiving', 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 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 = unifiedOrders.value
|
||
|
||
if (activeTab.value !== 'all') {
|
||
filtered = filtered.filter(order => order.status === activeTab.value)
|
||
}
|
||
|
||
if (searchKeyword.value.trim()) {
|
||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||
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)
|
||
)
|
||
}
|
||
return false
|
||
})
|
||
}
|
||
|
||
return filtered
|
||
})
|
||
|
||
const tabCounts = computed(() => {
|
||
const counts: Record<string, number> = { all: unifiedOrders.value.length }
|
||
statusTabs.forEach(tab => {
|
||
if (tab.key !== 'all') {
|
||
counts[tab.key] = unifiedOrders.value.filter(order => order.status === tab.key).length
|
||
}
|
||
})
|
||
return counts
|
||
})
|
||
|
||
onMounted(loadOrders)
|
||
|
||
watch(
|
||
() => route.query.tab,
|
||
tab => {
|
||
activeTab.value = readTab(tab)
|
||
}
|
||
)
|
||
|
||
function readTab(tab: unknown) {
|
||
const value = typeof tab === 'string' ? tab : 'all'
|
||
return tabKeys.has(value) ? value : 'all'
|
||
}
|
||
|
||
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 [rentalResult, crashResult] = await Promise.allSettled([
|
||
fetchOrders(rentalPage.value, rentalPageSize),
|
||
fetchMyMohongOrders({ page: 1, page_size: 50 }),
|
||
])
|
||
|
||
if (rentalResult.status === 'fulfilled') {
|
||
rentalOrders.value = rentalResult.value?.items || []
|
||
rentalTotal.value = rentalResult.value?.total || 0
|
||
} else {
|
||
rentalOrders.value = []
|
||
rentalTotal.value = 0
|
||
ElMessage.error('租赁订单加载失败')
|
||
}
|
||
|
||
if (crashResult.status === 'fulfilled') {
|
||
crashOrders.value = crashResult.value?.items || []
|
||
} else {
|
||
crashOrders.value = []
|
||
ElMessage.error(readError(crashResult.reason, '撞车订单加载失败'))
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function handleRentalPageChange(page: number) {
|
||
rentalPage.value = page
|
||
void loadOrders()
|
||
}
|
||
|
||
function goDetail(item: UnifiedOrderItem) {
|
||
if (item.bizType === 'crash') {
|
||
router.push(`/crash/orders/${item.id}`)
|
||
return
|
||
}
|
||
router.push(`/orders/${item.id}`)
|
||
}
|
||
|
||
async function handlePay(order: UserOrderListItem) {
|
||
payingOrderId.value = order.id
|
||
try {
|
||
await router.push(`/orders/${order.id}?pay=1`)
|
||
} catch (error) {
|
||
ElMessage.error(readError(error, '打开支付失败'))
|
||
} finally {
|
||
payingOrderId.value = null
|
||
}
|
||
}
|
||
|
||
function orderRole(order: UserOrderListItem) {
|
||
return order.role === 'owner' ? '号主' : '租客'
|
||
}
|
||
|
||
function isRenter(order: UserOrderListItem) {
|
||
return order.role === 'renter'
|
||
}
|
||
|
||
function amountLabel(order: UserOrderListItem) {
|
||
return isRenter(order) ? '支付租金' : '预计租金'
|
||
}
|
||
|
||
function amountYuan(cent: unknown) {
|
||
if (cent !== undefined && cent !== null) return centToYuan(Number(cent || 0))
|
||
return 0
|
||
}
|
||
|
||
function orderRentAmount(order: UserOrderListItem) {
|
||
return amountYuan(order.display_amount_cent)
|
||
}
|
||
|
||
function money(value: unknown) {
|
||
return formatMoney(Number(value || 0))
|
||
}
|
||
|
||
function textValue(value: unknown) {
|
||
return typeof value === 'string' ? value : ''
|
||
}
|
||
|
||
function orderNoText(orderNo: unknown) {
|
||
return textValue(orderNo) || '订单号未生成'
|
||
}
|
||
|
||
function shortenOrderNo(orderNo: unknown) {
|
||
const value = orderNoText(orderNo)
|
||
if (value === '订单号未生成') return value
|
||
if (value.length <= 20) return value
|
||
return `${value.slice(0, 12)}...${value.slice(-6)}`
|
||
}
|
||
|
||
async function copyOrderNo(orderNo: unknown) {
|
||
const value = textValue(orderNo)
|
||
if (!value) {
|
||
ElMessage.warning('订单号未生成,暂无法复制')
|
||
return
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(value)
|
||
ElMessage.success('订单号已复制')
|
||
} catch {
|
||
ElMessage.error('复制失败')
|
||
}
|
||
}
|
||
|
||
function formatListingCode(order: UserOrderListItem) {
|
||
return formatListingNo(order.listing_no, order.listing_id)
|
||
}
|
||
|
||
async function copyListingCode(order: UserOrderListItem) {
|
||
try {
|
||
await navigator.clipboard.writeText(formatListingCode(order))
|
||
ElMessage.success('商品编号已复制')
|
||
} catch {
|
||
ElMessage.error('复制失败')
|
||
}
|
||
}
|
||
|
||
function getPaymentDeadline(order: UserOrderListItem) {
|
||
if (order.status !== 'pending_payment' || !order.created_at) return null
|
||
if (order.payment_deadline_at) {
|
||
const serverDeadline = new Date(order.payment_deadline_at)
|
||
if (!Number.isNaN(serverDeadline.getTime())) return serverDeadline
|
||
}
|
||
const created = new Date(order.created_at)
|
||
return new Date(created.getTime() + fallbackPendingPaymentMinutes * 60 * 1000)
|
||
}
|
||
|
||
function getCountdownMinutes(order: UserOrderListItem) {
|
||
const deadline = getPaymentDeadline(order)
|
||
if (!deadline) return 0
|
||
const now = new Date()
|
||
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>
|
||
<section class="page">
|
||
<div class="page-header">
|
||
<p class="eyebrow">ORDERS</p>
|
||
<h1>我的订单</h1>
|
||
<p>租赁与撞车订单统一展示,按创建时间倒序排列。</p>
|
||
</div>
|
||
|
||
<div class="orders-toolbar">
|
||
<el-input
|
||
v-model="searchKeyword"
|
||
placeholder="搜索订单号 / 商品 / 账号"
|
||
:prefix-icon="Search"
|
||
clearable
|
||
class="search-input"
|
||
/>
|
||
</div>
|
||
|
||
<el-tabs v-model="activeTab" class="order-tabs" @tab-change="handleTabChange">
|
||
<el-tab-pane v-for="tab in statusTabs" :key="tab.key" :name="tab.key">
|
||
<template #label>
|
||
<span class="tab-label">
|
||
{{ tab.label }}
|
||
<span v-if="(tabCounts?.[tab.key] ?? 0) > 0" class="tab-count">{{
|
||
tabCounts?.[tab.key]
|
||
}}</span>
|
||
</span>
|
||
</template>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
|
||
<div v-if="displayOrders.length === 0 && !loading" class="empty-state">
|
||
<el-empty :description="searchKeyword ? '没有找到匹配的订单' : '暂无订单'" />
|
||
</div>
|
||
|
||
<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.orderNo">{{
|
||
shortenOrderNo(row.orderNo)
|
||
}}</span>
|
||
<el-icon class="copy-icon" @click="copyOrderNo(row.orderNo)">
|
||
<CopyDocument />
|
||
</el-icon>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="商品 / 账号" min-width="220">
|
||
<template #default="{ row }">
|
||
<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 label="金额" width="140">
|
||
<template #default="{ row }">
|
||
<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>
|
||
</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="110">
|
||
<template #default="{ row }">
|
||
<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
|
||
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>{{ statusText(row) }}</span>
|
||
<span
|
||
v-if="
|
||
row.bizType === 'rental' &&
|
||
row.rental &&
|
||
row.rental.status === 'pending_payment' &&
|
||
getCountdownMinutes(row.rental) > 0
|
||
"
|
||
class="countdown-badge"
|
||
>
|
||
{{ getCountdownMinutes(row.rental) }}分钟后超时
|
||
</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="创建时间" width="160">
|
||
<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.bizType === 'rental' && row.rental && row.rental.status === 'pending_payment'
|
||
"
|
||
size="small"
|
||
type="primary"
|
||
:disabled="!isRenter(row.rental)"
|
||
:loading="payingOrderId === row.rental.id"
|
||
@click="handlePay(row.rental)"
|
||
>
|
||
去支付
|
||
</el-button>
|
||
<el-button size="small" @click="goDetail(row)">详情</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<div class="mobile-cards">
|
||
<div
|
||
v-for="item in displayOrders"
|
||
:key="item.key"
|
||
class="order-card"
|
||
@click="goDetail(item)"
|
||
>
|
||
<div class="card-header">
|
||
<div class="order-no-row">
|
||
<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(item.orderNo)">
|
||
<CopyDocument />
|
||
</el-icon>
|
||
</div>
|
||
<el-tag
|
||
v-if="item.bizType === 'rental' && item.rental"
|
||
:type="isRenter(item.rental) ? 'primary' : 'success'"
|
||
size="small"
|
||
>
|
||
{{ 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 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>
|
||
<el-pagination
|
||
v-if="rentalTotal > rentalPageSize"
|
||
v-model:current-page="rentalPage"
|
||
class="orders-pagination"
|
||
layout="prev, pager, next"
|
||
:page-size="rentalPageSize"
|
||
:total="rentalTotal"
|
||
@current-change="handleRentalPageChange"
|
||
/>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped src="./OrdersView.css"></style>
|