Files
hfb_sys/frontend/src/features/orders/views/MobileOrdersView.vue
T
2026-06-09 19:04:11 +08:00

584 lines
13 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 { onMounted, ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showDialog, showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import {
fetchOrders,
startOrderPayment,
type Order,
type PaymentOrder,
} from '@/features/orders/api/orders'
import { centToYuan, formatMoney } from '@/shared/utils/money'
import { useSessionStore } from '@/stores/session'
import { formatDateMinute } from '@/utils/time'
import { formatListingNo } from '@/utils/listingDisplay'
const router = useRouter()
const route = useRoute()
const session = useSessionStore()
const loading = ref(false)
const orders = ref<Order[]>([])
const activeTab = ref('all')
const payingOrderId = ref<number | null>(null)
onMounted(() => {
loadOrders()
if (route.query.tab) {
activeTab.value = String(route.query.tab)
}
})
async function loadOrders() {
loading.value = true
try {
orders.value = await fetchOrders()
} 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: 'pending_checkout_confirm', label: '待结账' },
{ key: 'completed', label: '已完成' },
]
const displayOrders = computed(() => {
if (activeTab.value === 'all') return orders.value
return orders.value.filter(o => o.status === activeTab.value)
})
function statusLabel(status: string) {
const map: Record<string, string> = {
pending_payment: '待支付',
pending_handoff: '待交接',
renting: '使用中',
overdue: '已逾期',
pending_return_confirm: '待结账',
pending_checkout_confirm: '待号主确认',
pending_checkout_accept: '待租客确认',
checkout_disputing: '结账争议中',
completed: '已完成',
cancelled: '已取消',
disputing: '申诉中',
abnormal: '异常',
closed: '已关闭',
}
return map[status] || status
}
function goDetail(id: number) {
router.push(`/m/orders/${id}`)
}
async function handlePay(order: Order) {
payingOrderId.value = order.id
try {
const payment = await startOrderPayment(order.id)
if (payment.paid) {
showToast({ message: '支付成功,等待号主交接', icon: 'passed' })
await loadOrders()
} else {
openPaymentCashier(payment)
}
} catch (error) {
showToast({ message: readError(error, '支付失败'), icon: 'cross' })
} finally {
payingOrderId.value = null
}
}
function openPaymentCashier(payment: PaymentOrder) {
const payURL = payment.jspay_url || payment.td_code || payment.jspay_info || ''
if (payURL && /^https?:\/\//i.test(payURL)) {
window.location.href = payURL
return
}
showDialog({
title: '订单支付',
message: payURL || '支付单已创建,请在订单详情页刷新支付状态。',
confirmButtonText: '查看详情',
}).then(() => {
router.push(`/m/orders/${payment.order_id}`)
})
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
function money(value: unknown) {
return formatMoney(Number(value || 0))
}
function isOwner(order: Order) {
return order.owner_id === session.userId
}
function amountLabel(order: Order) {
return isOwner(order) ? '预计租金' : '支付租金'
}
function amountYuan(cent: unknown) {
if (cent !== undefined && cent !== null) return centToYuan(Number(cent || 0))
return 0
}
function orderRentAmount(order: Order) {
if (isOwner(order)) return amountYuan(order.owner_rent_amount_cent)
return amountYuan(order.rent_amount_cent)
}
function ownerActualIncome(order: Order) {
if (!isOwner(order)) return null
const value = order.checkout?.owner_income_amount_cent
if (typeof value === 'number') return centToYuan(value)
return null
}
function formatListingCode(order: Order) {
return formatListingNo(order.listing_no, order.listing_id)
}
async function copyListingCode(order: Order) {
try {
await navigator.clipboard.writeText(formatListingCode(order))
showToast({ message: '商品编号已复制', icon: 'passed' })
} catch {
showToast({ message: '复制失败', icon: 'cross' })
}
}
</script>
<template>
<main class="mobile-orders">
<!-- 顶部导航 -->
<header class="page-header">
<button class="back-btn" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>我的订单</h1>
<span class="header-spacer"></span>
</header>
<!-- 状态筛选条 - Vant 滑动标签页 -->
<van-tabs
v-model:active="activeTab"
class="custom-tabs"
line-width="20px"
line-height="3px"
color="#1477ff"
title-active-color="#1477ff"
title-inactive-color="#6b7280"
:border="false"
swipeable
animated
>
<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>
加载中...
</van-loading>
<div v-else-if="displayOrders.length === 0" class="empty-state-wrap">
<van-empty description="暂无相关订单" image="search" />
</div>
<div
v-else
v-for="order in displayOrders"
:key="order.id"
class="order-card"
@click="goDetail(order.id)"
>
<!-- 卡片头订单号 + 状态 -->
<div class="card-header">
<div class="header-left">
<span class="order-no">{{ order.order_no }}</span>
</div>
<span class="status-badge" :class="'badge-' + order.status">
{{ statusLabel(order.status) }}
</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>
</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>
<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
>
</span>
</div>
</div>
</div>
<!-- 卡片底时间 + 操作 -->
<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>
</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>
</div>
</div>
</section>
<!-- 底部导航 -->
<MobileBottomNav />
</main>
</template>
<style scoped>
.mobile-orders {
min-height: 100dvh;
background: #f6f8fa;
padding-bottom: calc(64px + env(safe-area-inset-bottom));
}
/* ========== 顶部导航 ========== */
.page-header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
height: 48px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(243, 244, 246, 0.8);
}
.page-header h1 {
flex: 1;
margin: 0;
font-size: 17px;
font-weight: 700;
text-align: center;
color: #111827;
}
.back-btn {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: none;
background: none;
color: #374151;
cursor: pointer;
}
.header-spacer {
width: 36px;
}
/* ========== Vant Tabs 自定义样式 ========== */
.custom-tabs {
position: sticky;
top: 48px;
z-index: 99;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(243, 244, 246, 0.6);
}
:deep(.van-tabs__nav) {
background: transparent;
padding-bottom: 4px;
}
:deep(.van-tab) {
font-size: 13px;
font-weight: 600;
}
/* ========== 订单列表 ========== */
.order-list {
padding: 14px 16px;
}
.center-loading {
display: flex;
justify-content: center;
padding: 60px 0;
}
.empty-state-wrap {
padding: 40px 0;
}
/* ========== 订单卡片 ========== */
.order-card {
background: #ffffff;
border-radius: 16px;
margin-bottom: 14px;
padding: 16px;
box-shadow:
0 4px 18px rgba(0, 0, 0, 0.02),
0 1px 4px rgba(0, 0, 0, 0.02);
transition:
transform 0.1s ease,
box-shadow 0.1s ease;
border: 1px solid rgba(243, 244, 246, 0.9);
cursor: pointer;
}
.order-card:active {
transform: scale(0.98);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 10px;
border-bottom: 1px dashed #f3f4f6;
}
.order-no {
font-size: 11px;
color: #9ca3af;
font-family: monospace;
}
.status-badge {
padding: 4px 8px;
border-radius: 8px;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.listing-code-chip {
margin-bottom: 8px;
padding: 4px 9px;
border: 1px solid #dbeafe;
border-radius: 999px;
background: #eff6ff;
color: #2563eb;
font-size: 12px;
font-weight: 800;
}
/* 状态徽章颜色 - 现代轻量化配色 */
.badge-pending_payment {
color: #ff6a00;
background: rgba(255, 106, 0, 0.08);
}
.badge-pending_handoff {
color: #d97706;
background: rgba(217, 119, 6, 0.08);
}
.badge-renting {
color: #1477ff;
background: rgba(20, 119, 255, 0.08);
}
.badge-overdue {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
}
.badge-pending_return_confirm {
color: #8b5cf6;
background: rgba(139, 92, 246, 0.08);
}
.badge-pending_checkout_confirm {
color: #8b5cf6;
background: rgba(139, 92, 246, 0.08);
}
.badge-pending_checkout_accept {
color: #a855f7;
background: rgba(168, 85, 247, 0.08);
}
.badge-checkout_disputing {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
}
.badge-completed {
color: #10b981;
background: rgba(16, 185, 129, 0.08);
}
.badge-cancelled {
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;
}
.order-title {
margin: 0 0 8px;
font-size: 15px;
font-weight: 700;
color: #111827;
line-height: 1.4;
}
.tag-row {
display: flex;
gap: 6px;
margin-bottom: 12px;
}
.info-tag {
font-size: 11px;
color: #6b7280;
background: #f3f4f6;
padding: 3px 8px;
border-radius: 6px;
font-weight: 500;
}
.price-row {
display: flex;
background: #f9fafb;
border-radius: 12px;
padding: 10px 14px;
justify-content: space-between;
gap: 12px;
}
.price-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.price-label {
font-size: 10px;
color: #9ca3af;
}
.price-val {
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 15px;
font-weight: 800;
color: #ff5f00;
}
.price-val em {
font-style: normal;
font-size: 10px;
font-weight: 600;
color: #2563eb;
}
.price-val.deposit {
color: #374151;
}
.price-sub {
color: #10b981;
font-size: 11px;
font-weight: 700;
}
/* ========== 卡片底部 ========== */
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.time-box {
display: flex;
align-items: center;
gap: 4px;
color: #9ca3af;
}
.clock-icon {
font-size: 13px;
}
.order-time {
font-size: 11px;
}
.detail-link {
font-size: 12px;
color: #1477ff;
font-weight: 600;
display: flex;
align-items: center;
gap: 2px;
}
.pay-btn {
height: 28px !important;
padding: 0 16px !important;
font-size: 12px !important;
font-weight: 700 !important;
background: linear-gradient(135deg, #ff8c00, #ff5f00) !important;
border: none !important;
box-shadow: 0 4px 10px rgba(255, 95, 0, 0.2) !important;
}
</style>