Files
hfb_sys/frontend/src/features/orders/views/MobileOrdersView.vue
T
2026-06-18 23:19:15 +08:00

611 lines
14 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 { onMounted, ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import {
fetchOrders,
startOrderPayment,
type Order,
} from '@/features/orders/api/orders'
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySelectPopup.vue'
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 { useSessionStore } from '@/stores/session'
import { formatDateMinute } from '@/shared/utils/time'
import { formatListingNo } from '@/shared/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)
const {
paymentPopupVisible,
activePayment,
payURL,
paymentQRCodeURL,
qrGenerating,
checkingPayment,
openMobilePaymentCashier,
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: '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) {
const payWay = await selectPayWay()
if (!payWay) return
payingOrderId.value = order.id
try {
const payment = await startOrderPayment(order.id, { pay_way: payWay })
if (payment.paid) {
showToast({ message: '支付成功,正在进入群消息', icon: 'passed' })
await openOrderChatAfterPayment(order)
} else {
await openMobilePaymentCashier(payment, () => openOrderChatAfterPayment(order))
}
} catch (error) {
showToast({ message: readError(error, '支付失败'), icon: 'cross' })
} finally {
payingOrderId.value = null
}
}
async function openOrderChatAfterPayment(order: Order) {
try {
const chat = await fetchOrderChat(order.id)
await router.push(`/m/chats/${chat.id}`)
} catch {
showToast({ message: '支付成功,但群消息暂不可用,请稍后从消息进入', icon: 'warning-o' })
await loadOrders()
}
}
// selectPayWay 在列表快捷支付前让用户选择支付宝或微信(见 payWaySelect)。
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>
<!-- 底部导航 -->
<MobilePayWaySelectPopup
v-model:show="payWaySelect.visible.value"
@choose="payWaySelect.choose"
@closed="payWaySelect.handleClosed"
/>
<MobilePaymentCashierPopup
v-model:show="paymentPopupVisible"
:payment="activePayment"
:pay-url="payURL"
:qr-code-url="paymentQRCodeURL"
:qr-generating="qrGenerating"
:checking="checkingPayment"
@refresh="refreshPaymentStatus(false)"
/>
<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>