Files
hfb_sys/frontend/src/features/orders/views/OrdersView.vue
T
yml2213 369b0647bb 合并 main 分支并融合订单列表最小化与 SQL 治理
- Auth/权限中间件取 main 演进版(错误细分、token 来源),保留 dev 的用户 cookie 与登出撤销语义
- 后台订单列表:融合 main 的首页免 COUNT 优化与关键词索引路由,保留 dev 的最小化 AdminOrderListItemDTO(结账明细仍由详情接口返回,同步调整对应集成测试)
- 保留 dev 的 token_version 冻结/解冻递增与 main 的状态变化判断及通知
- 前端:保留 dev 登出接口与 main 的 fetchMe silent 参数、SSE 登录跳转;后台订单表按 dev 最小化 DTO 移除号主到手列
2026-09-05 11:43:13 +08:00

584 lines
19 KiB
Vue
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.
<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>