Files
hfb_sys/frontend/src/features/orders/views/OrdersView.vue
T

608 lines
21 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'
import { useDistributionSiteStore } from '@/stores/distributionSite'
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 siteStore = useDistributionSiteStore()
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 rentalPromise = fetchOrders(rentalPage.value, rentalPageSize)
const crashPromise = !siteStore.isDistribution
? fetchMyMohongOrders({ page: 1, page_size: 50 })
: Promise.resolve(null)
const [rentalResult, crashResult] = await Promise.allSettled([rentalPromise, crashPromise])
if (rentalResult.status === 'fulfilled') {
rentalOrders.value = rentalResult.value?.items || []
rentalTotal.value = rentalResult.value?.total || 0
} else {
rentalOrders.value = []
rentalTotal.value = 0
ElMessage.error('租赁订单加载失败')
}
if (!siteStore.isDistribution && crashResult.status === 'fulfilled' && crashResult.value) {
crashOrders.value = crashResult.value.items || []
} else if (!siteStore.isDistribution && crashResult.status === 'rejected') {
crashOrders.value = []
ElMessage.error(readError(crashResult.reason, '撞车订单加载失败'))
} else {
crashOrders.value = []
}
} 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="orders-page-redesign">
<!-- 头部横幅区 -->
<div class="orders-hero-header">
<div class="hero-header-left">
<div class="hero-title-row">
<h1 class="hero-title">我的订单</h1>
<span v-if="siteStore.isDistribution" class="site-order-tag">
{{ siteStore.siteName }} · 专属订单
</span>
<span class="orders-total-badge">{{ unifiedOrders.length }} 笔订单</span>
</div>
<p v-if="siteStore.isDistribution" class="hero-subtitle">
当前为【{{ siteStore.siteName }}】专属分销商城,仅展示在此分销站产生的订单记录
</p>
<p v-else class="hero-subtitle">租赁与撞车订单统一管理 · 实时交接与进度追踪</p>
</div>
<div class="orders-search-box">
<el-input
v-model="searchKeyword"
placeholder="搜索订单号 / 商品名称 / 编号"
:prefix-icon="Search"
clearable
class="orders-search-input"
/>
</div>
</div>
<!-- 状态筛选胶囊条 -->
<div class="orders-filter-strip">
<div class="orders-pill-tabs">
<button
v-for="tab in statusTabs"
:key="tab.key"
type="button"
class="orders-pill-tab"
:class="{ active: activeTab === tab.key }"
@click="handleTabChange(tab.key)"
>
<span class="tab-name">{{ tab.label }}</span>
<span
v-if="(tabCounts?.[tab.key] ?? 0) > 0"
class="tab-badge"
:class="{ 'is-active': activeTab === tab.key }"
>
{{ tabCounts?.[tab.key] }}
</span>
</button>
</div>
</div>
<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="130">
<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="100" align="center">
<template #default="{ row }"
><span
v-if="row.bizType === 'rental' && row.rental"
class="role-tag"
:class="isRenter(row.rental) ? 'renter' : 'owner'"
>{{ orderRole(row.rental) }}</span
><span v-else class="role-tag buyer">买家</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"
round
:disabled="!isRenter(row.rental)"
:loading="payingOrderId === row.rental.id"
@click="handlePay(row.rental)"
>
去支付
</el-button>
<el-button size="small" round @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>
<span
v-if="item.bizType === 'rental' && item.rental"
class="role-tag"
:class="isRenter(item.rental) ? 'renter' : 'owner'"
>{{ orderRole(item.rental) }}</span
>
<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>