优化订单交接和结账界面

This commit is contained in:
yml
2026-06-19 07:01:40 +08:00
parent ae28858318
commit 438a4259f9
5 changed files with 752 additions and 295 deletions
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ChatDotRound, CopyDocument, Loading } from '@element-plus/icons-vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ChatDotRound, CopyDocument, Loading, Service } from '@element-plus/icons-vue'
import {
amountYuan,
@@ -18,6 +19,8 @@ import { formatDateTime } from '@/shared/utils/time'
import type { PaymentPayWay } from '@/features/orders/api/orders'
// 支付收银台:二维码渲染 + 轮询查询。PC 不做 App 浏览器跳转,直接渲染二维码弹窗。
const route = useRoute()
const disputeDialogVisible = ref(false)
const cashier = useOrderPaymentCashier({
notifySuccess: message => ElMessage.success(message),
notifyError: message => ElMessage.error(message),
@@ -88,12 +91,25 @@ const {
notifySuccess: message => ElMessage.success(message),
notifyError: message => ElMessage.error(message),
notifyWarning: message => ElMessage.warning(message),
confirm: async () => true, // PC 不做二次确认,直接放行。
actionsNeedingConfirm: [],
confirm: async ({ title, message }) => {
try {
await ElMessageBox.confirm(message, title, {
confirmButtonText: '确认提交',
cancelButtonText: '返回检查',
type: 'warning',
dangerouslyUseHTMLString: false,
})
return true
} catch {
return false
}
},
actionsNeedingConfirm: ['submitCheckout'],
selectPayWay,
ordersPath: '/orders',
chatPathPrefix: '/messages/',
startCashier: (payment, onPaid) => cashier.openPaymentCashier(payment, onPaid),
onCreateDisputeSuccess: closeDisputeDialog,
})
// 支付弹窗别名:保持 template 字段名不变。
@@ -104,6 +120,34 @@ const qrGenerating = cashier.qrGenerating
const checkingPayment = cashier.checkingPayment
const paymentPayURL = cashier.payURL
const activePayWayLabel = computedPayWayLabel(activePayment)
const handoffFocused = ref(false)
const hasSidebarActions = computed(() => {
if (!order.value) return false
return (
(isOwner.value && order.value.status === 'pending_checkout_confirm') ||
(isRenter.value && order.value.status === 'pending_checkout_accept')
)
})
const disputeSupportTitle = computed(() => {
if (canCancelDispute.value) {
return order.value?.status === 'checkout_disputing' ? '结账争议处理中' : '申诉处理中'
}
return isCheckoutDisputeStage.value ? '结账金额有异议?' : '遇到交接问题?'
})
const disputeSupportMessage = computed(() => {
if (canCancelDispute.value) {
return '客服正在处理当前争议,如问题已解决可取消申诉。'
}
return isCheckoutDisputeStage.value
? '可提交结账争议,客服会核查双方证据。'
: '无法登录、超时交接等情况可申请客服介入。'
})
const disputeSupportActionText = computed(() => {
if (canCancelDispute.value) {
return order.value?.status === 'checkout_disputing' ? '取消结账争议' : '取消申诉'
}
return isCheckoutDisputeStage.value ? '发起结账争议' : '发起申诉'
})
// selectPayWay 在创建支付单前让用户选择支付宝或微信。
// 通过自定义对话框展示两个平级渠道卡片,返回 null 表示用户取消。
@@ -161,30 +205,35 @@ function getOrderStep(status: string) {
return stepMap[status] ?? 0
}
function scrollToDispute() {
const disputeSection = document.querySelector('.dispute-section')
if (disputeSection) {
disputeSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function openDisputeDialog() {
disputeDialogVisible.value = true
}
function scrollToCheckout() {
const checkoutSection = document.querySelector('.checkout-form-section')
if (checkoutSection) {
checkoutSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function closeDisputeDialog() {
disputeDialogVisible.value = false
}
function scrollToCounterCheckout() {
const counterSection = document.querySelector('.counter-checkout-section')
if (counterSection) {
counterSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
scrollToSection('.counter-checkout-section')
}
function scrollToRejectCheckout() {
const rejectSection = document.querySelector('.reject-checkout-section')
if (rejectSection) {
rejectSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
scrollToSection('.reject-checkout-section')
}
function scrollToHandoff() {
scrollToSection('.handoff-workspace')
}
function scrollToSection(selector: string) {
const section = document.querySelector(selector)
if (section) {
section.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
async function focusHandoffFromQuery() {
if (handoffFocused.value || loading.value || !order.value || route.query.focus !== 'handoff') {
return
}
handoffFocused.value = true
await nextTick()
scrollToHandoff()
}
async function copyListingCode() {
if (!order.value) return
@@ -195,11 +244,19 @@ async function copyListingCode() {
ElMessage.error('复制失败')
}
}
onMounted(() => {
void focusHandoffFromQuery()
})
watch([() => route.query.focus, order, loading], () => {
void focusHandoffFromQuery()
})
</script>
<template>
<section class="page order-detail-page" v-loading="loading">
<div v-if="order" class="page-header">
<div v-if="order" class="page-header" :class="{ centered: !hasSidebarActions }">
<div class="header-top">
<div class="header-info">
<p class="eyebrow">订单号{{ order.order_no }}</p>
@@ -216,7 +273,7 @@ async function copyListingCode() {
</div>
</div>
<div v-if="order" class="content-layout">
<div v-if="order" class="content-layout" :class="{ centered: !hasSidebarActions }">
<div class="main-content">
<div v-if="order" class="order-progress-section">
<el-steps
@@ -251,6 +308,61 @@ async function copyListingCode() {
</el-steps>
</div>
<div
v-if="
isOwner &&
order.status === 'pending_handoff' &&
order.handoff_status === 'pending_owner'
"
class="form-section handoff-action-section"
>
<div class="section-header compact">
<h2>当前待办:提交交接说明</h2>
<span>填写后租客即可确认收号</span>
</div>
<div class="handoff-action-card">
<el-input
v-model="handoffContent"
type="textarea"
:rows="4"
placeholder="填写登录方式、注意事项和交接说明"
/>
<el-button type="primary" :loading="handoffing" @click="handleSubmitHandoff">
提交交接
</el-button>
</div>
</div>
<div
v-if="
isRenter &&
order.status === 'pending_handoff' &&
order.handoff_status === 'pending_renter_confirm'
"
class="handoff-confirm-strip"
>
<div>
<strong>当前待办:确认收号</strong>
<span>确认账号可以正常登录后,订单会进入使用中并重新计算预计截止时间。</span>
</div>
<el-button type="primary" :loading="confirming" @click="handleConfirmReceive">
确认已收到账号
</el-button>
</div>
<OrderResourceUsageEditor
v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)"
v-model:checkout-form="checkoutForm"
v-model:resource-usage="resourceUsage"
:resources="checkoutResources"
:resource-charge-amount="resourceChargeAmount"
:snapshot-haf-coin-m="snapshotHafCoinM"
:remaining-haf-coin-m="remainingHafCoinM"
:returning="returning"
:resource-line-amount="resourceLineAmount"
@submit="handleSubmitCheckout"
/>
<div v-if="order" class="detail-grid">
<div class="metric-card primary">
<span class="metric-label">订单状态</span>
@@ -309,29 +421,43 @@ async function copyListingCode() {
</div>
</div>
<div v-if="order.status === 'pending_handoff'" class="action-card info">
<div class="action-buttons">
<div
v-if="order.status === 'pending_handoff' || canOpenDispute || canCancelDispute"
class="secondary-actions"
>
<div v-if="order.status === 'pending_handoff'" class="secondary-action-card danger">
<div>
<strong>不继续交接?</strong>
<span>取消后将释放账号,订单会回到可租状态。</span>
</div>
<el-button type="danger" plain :loading="cancelling" @click="handleCancel">
取消订单并释放账号
取消订单
</el-button>
</div>
<div v-if="canOpenDispute || canCancelDispute" class="secondary-action-card support">
<span class="support-dispute-icon">
<el-icon><Service /></el-icon>
</span>
<div class="support-dispute-copy">
<strong>{{ disputeSupportTitle }}</strong>
<span>{{ disputeSupportMessage }}</span>
</div>
<el-button
type="warning"
plain
:loading="cancellingDispute"
@click="canCancelDispute ? handleCancelDispute() : openDisputeDialog()"
>
{{ disputeSupportActionText }}
</el-button>
</div>
</div>
</div>
<OrderHandoffTimeline v-if="order" :records="handoffRecords" :listing-code="listingCode" />
<OrderResourceUsageEditor
v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)"
v-model:checkout-form="checkoutForm"
v-model:resource-usage="resourceUsage"
:resources="checkoutResources"
:resource-charge-amount="resourceChargeAmount"
:snapshot-haf-coin-m="snapshotHafCoinM"
:remaining-haf-coin-m="remainingHafCoinM"
:returning="returning"
:resource-line-amount="resourceLineAmount"
@submit="handleSubmitCheckout"
/>
<div class="handoff-workspace">
<OrderHandoffTimeline v-if="order" :records="handoffRecords" :listing-code="listingCode" />
</div>
<OrderCheckoutSummary
v-if="
@@ -442,63 +568,7 @@ async function copyListingCode() {
</div>
<!-- 右侧悬浮操作区 -->
<div v-if="order" class="action-sidebar">
<!-- 交接操作 -->
<div
v-if="
isOwner &&
order.status === 'pending_handoff' &&
order.handoff_status === 'pending_owner'
"
class="sidebar-card"
>
<h3 class="sidebar-title">提交交接说明</h3>
<el-input
v-model="handoffContent"
type="textarea"
:rows="5"
placeholder="填写登录方式、注意事项和交接说明"
/>
<el-button type="primary" size="large" :loading="handoffing" @click="handleSubmitHandoff"
>提交交接</el-button
>
</div>
<!-- 确认收号 -->
<div
v-if="
isRenter &&
order.status === 'pending_handoff' &&
order.handoff_status === 'pending_renter_confirm'
"
class="sidebar-card"
>
<h3 class="sidebar-title">确认收号</h3>
<p class="sidebar-hint">
确认账号可以正常登录后,订单会进入使用中并重新计算预计截止时间。
</p>
<el-button type="primary" size="large" :loading="confirming" @click="handleConfirmReceive"
>确认已收到账号</el-button
>
</div>
<!-- 发起结账 -->
<div
v-if="isRenter && ['renting', 'overdue'].includes(order.status)"
class="sidebar-card highlight-card"
>
<h3 class="sidebar-title">快捷结账</h3>
<el-button type="primary" size="large" :loading="returning" @click="handleSubmitCheckout">
立即提交结账
</el-button>
<p class="sidebar-hint">
如需修改消耗品、哈夫币等详细信息,请先向下滚动填写完整表单后再提交
</p>
<el-button type="default" size="small" @click="scrollToCheckout">
查看详细结账表单
</el-button>
</div>
<div v-if="order && hasSidebarActions" class="action-sidebar">
<!-- 确认结账 -->
<div v-if="isOwner && order.status === 'pending_checkout_confirm'" class="sidebar-card">
<h3 class="sidebar-title">确认结账</h3>
@@ -539,38 +609,25 @@ async function copyListingCode() {
<p class="sidebar-hint">不同意修正时需填写原因,将进入争议处理</p>
</div>
<!-- 发起申诉 -->
<div v-if="canOpenDispute" class="sidebar-card dispute-card">
<h3 class="sidebar-title">{{ isCheckoutDisputeStage ? '发起结账争议' : '发起申诉' }}</h3>
<el-button type="warning" size="large" @click="scrollToDispute">
{{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }}
</el-button>
<p class="sidebar-hint">点击后滚动到申诉表单填写详细信息</p>
</div>
<div v-if="canCancelDispute" class="sidebar-card dispute-card">
<h3 class="sidebar-title">
{{ order.status === 'checkout_disputing' ? '取消结账争议' : '取消申诉' }}
</h3>
<el-button
type="warning"
plain
size="large"
:loading="cancellingDispute"
@click="handleCancelDispute"
>
{{ order.status === 'checkout_disputing' ? '取消结账争议' : '取消申诉' }}
</el-button>
<p class="sidebar-hint">取消后订单将恢复到发起申诉前的流程</p>
</div>
</div>
</div>
<div v-if="order && canOpenDispute" class="form-section dispute-section">
<div class="section-header">
<h2>{{ isCheckoutDisputeStage ? '发起结账争议' : '发起申诉' }}</h2>
</div>
<div class="form-card">
<el-dialog
v-model="disputeDialogVisible"
:title="isCheckoutDisputeStage ? '发起结账争议' : '发起申诉'"
width="720px"
append-to-body
class="dispute-dialog"
>
<div v-if="order && canOpenDispute" class="dispute-dialog-body">
<div class="dispute-dialog-tip">
<span class="support-dispute-icon">
<el-icon><Service /></el-icon>
</span>
<p>
{{ isCheckoutDisputeStage ? '提交后客服会核查结账金额和双方证据。' : '请尽量写清楚时间点、问题经过和证据,便于客服快速处理。' }}
</p>
</div>
<el-select
v-if="!isCheckoutDisputeStage"
v-model="disputeType"
@@ -605,11 +662,16 @@ async function copyListingCode() {
@change="handleEvidenceUpload"
/>
</div>
<el-button type="warning" size="large" :loading="disputing" @click="handleCreateDispute">
{{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }}
</el-button>
</div>
</div>
<template #footer>
<div class="dispute-dialog-footer">
<el-button @click="closeDisputeDialog">取消</el-button>
<el-button type="warning" :loading="disputing" @click="handleCreateDispute">
{{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }}
</el-button>
</div>
</template>
</el-dialog>
<el-dialog
v-model="payWayDialogVisible"
@@ -684,72 +746,85 @@ async function copyListingCode() {
<style scoped>
.order-detail-page {
max-width: 1400px;
max-width: 1280px;
margin: 0 auto;
}
.page-header {
margin-bottom: 32px;
margin-bottom: 18px;
}
.page-header.centered {
max-width: 940px;
margin-right: auto;
margin-left: auto;
}
.header-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
gap: 16px;
}
.header-info {
flex: 1;
}
.header-info h1 {
margin: 4px 0 0;
font-size: 26px;
line-height: 1.25;
}
.order-meta {
color: #64748b;
margin-top: 8px;
margin: 6px 0 10px;
}
.content-layout {
display: grid;
grid-template-columns: 1fr 320px;
gap: 24px;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 16px;
align-items: start;
}
.content-layout.centered {
grid-template-columns: minmax(0, 1fr);
max-width: 940px;
margin: 0 auto;
}
.main-content {
min-width: 0;
}
.action-sidebar {
position: sticky;
top: 24px;
top: 16px;
display: flex;
flex-direction: column;
gap: 16px;
gap: 12px;
}
.sidebar-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
gap: 10px;
padding: 14px;
background: #ffffff;
border: 2px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.05);
}
.sidebar-card.highlight-card {
background: #eff6ff;
background: #f8fbff;
border-color: #3b82f6;
}
.sidebar-card.dispute-card {
background: #fef2f2;
border-color: #fecaca;
}
.sidebar-title {
font-size: 16px;
font-size: 14px;
font-weight: 700;
color: #1f2937;
margin: 0;
@@ -757,37 +832,69 @@ async function copyListingCode() {
.sidebar-hint {
margin: 0;
font-size: 13px;
font-size: 12px;
color: #64748b;
line-height: 1.5;
line-height: 1.45;
}
.sidebar-card .el-button {
width: 100%;
}
.support-dispute-icon {
display: grid;
flex: none;
width: 32px;
height: 32px;
place-items: center;
border-radius: 8px;
background: #fff1d6;
color: #d97706;
font-size: 18px;
}
.support-dispute-copy {
display: grid;
gap: 2px;
min-width: 0;
flex: 1;
}
.support-dispute-copy strong {
color: #1f2937;
font-size: 14px;
line-height: 1.3;
}
.support-dispute-copy span {
color: #7c5a16;
font-size: 12px;
line-height: 1.45;
}
.order-progress-section {
margin-bottom: 32px;
padding: 24px;
margin-bottom: 12px;
padding: 16px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
border-radius: 8px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.metric-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px;
gap: 6px;
min-height: 86px;
padding: 14px;
border: 1px solid #e5e7eb;
border-radius: 12px;
border-radius: 8px;
background: #ffffff;
transition: all 0.2s;
}
@@ -839,15 +946,16 @@ async function copyListingCode() {
}
.metric-label {
font-size: 13px;
font-size: 12px;
color: #64748b;
font-weight: 500;
}
.metric-value {
font-size: 24px;
font-size: 20px;
font-weight: 700;
color: #1f2937;
line-height: 1.2;
}
.metric-value.amount {
@@ -860,48 +968,46 @@ async function copyListingCode() {
}
.info-section {
margin-bottom: 32px;
margin-bottom: 16px;
}
.info-card {
display: grid;
gap: 12px;
padding: 20px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 14px 16px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
border-radius: 8px;
}
.info-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.info-row:last-child {
border-bottom: none;
gap: 12px;
padding: 0;
}
.info-label {
font-size: 14px;
font-size: 13px;
color: #64748b;
font-weight: 500;
}
.info-value {
font-size: 14px;
font-size: 13px;
color: #1f2937;
font-weight: 600;
text-align: right;
}
.action-card {
margin-top: 16px;
padding: 20px;
margin-top: 12px;
padding: 14px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
border-radius: 8px;
}
.action-card.warning {
@@ -909,13 +1015,8 @@ async function copyListingCode() {
border-color: #fde68a;
}
.action-card.info {
background: #eff6ff;
border-color: #bfdbfe;
}
.action-message {
margin: 0 0 16px 0;
margin: 0 0 12px 0;
color: #1f2937;
font-size: 14px;
}
@@ -930,37 +1031,135 @@ async function copyListingCode() {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
margin-bottom: 10px;
}
.section-header.compact span {
color: #64748b;
font-size: 12px;
}
.section-header h2 {
font-size: 20px;
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.form-section {
margin-bottom: 32px;
margin-bottom: 20px;
}
.form-card {
display: grid;
gap: 16px;
padding: 24px;
gap: 12px;
padding: 16px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
border-radius: 8px;
}
.handoff-action-section {
margin-bottom: 14px;
}
.handoff-action-card {
display: grid;
grid-template-columns: minmax(0, 1fr) 160px;
gap: 12px;
align-items: stretch;
padding: 16px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: linear-gradient(180deg, #f8fbff 0%, #eff6ff 100%);
}
.handoff-action-card .el-button {
height: 100%;
min-height: 96px;
}
.handoff-confirm-strip {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
padding: 16px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: linear-gradient(180deg, #f8fbff 0%, #eff6ff 100%);
}
.handoff-confirm-strip div {
display: grid;
gap: 4px;
flex: 1;
min-width: 0;
}
.handoff-confirm-strip strong {
color: #1f2937;
font-size: 14px;
}
.handoff-confirm-strip span {
color: #475569;
font-size: 12px;
line-height: 1.45;
}
.secondary-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 10px;
margin-top: 12px;
}
.secondary-action-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 8px;
}
.secondary-action-card > div {
display: grid;
gap: 2px;
min-width: 0;
flex: 1;
}
.secondary-action-card strong {
color: #1f2937;
font-size: 13px;
line-height: 1.3;
}
.secondary-action-card span {
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.secondary-action-card.danger {
border: 1px solid #fecaca;
background: #fff7f7;
}
.secondary-action-card.support {
border: 1px solid #f8d99b;
background: #fffaf0;
}
.form-hint {
margin: 0;
padding: 12px 16px;
padding: 10px 12px;
background: #f8fafc;
border-left: 3px solid #3b82f6;
color: #475569;
font-size: 14px;
line-height: 1.6;
font-size: 13px;
line-height: 1.5;
border-radius: 4px;
}
@@ -1011,16 +1210,16 @@ async function copyListingCode() {
}
.sub-title {
font-size: 16px;
font-size: 15px;
font-weight: 600;
color: #374151;
margin: 0 0 16px 0;
margin: 0 0 6px 0;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 12px;
}
.field-hint {
@@ -1030,9 +1229,32 @@ async function copyListingCode() {
font-size: 12px;
}
.dispute-section .form-card {
background: #fef2f2;
border-color: #fecaca;
.dispute-dialog-body {
display: grid;
gap: 12px;
}
.dispute-dialog-tip {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 12px;
border: 1px solid #f8d99b;
border-radius: 8px;
background: #fffaf0;
}
.dispute-dialog-tip p {
margin: 0;
color: #7c5a16;
font-size: 13px;
line-height: 1.5;
}
.dispute-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.upload-line {
@@ -1047,6 +1269,18 @@ async function copyListingCode() {
font-size: 14px;
}
:global(.dispute-dialog) {
border-radius: 12px;
}
:global(.dispute-dialog .el-dialog__body) {
padding: 14px 20px 8px;
}
:global(.dispute-dialog .el-dialog__footer) {
padding: 12px 20px 18px;
}
.pay-dialog-body {
display: grid;
gap: 20px;
@@ -1260,6 +1494,18 @@ async function copyListingCode() {
grid-template-columns: 1fr;
}
.handoff-action-card,
.handoff-confirm-strip,
.secondary-action-card {
grid-template-columns: 1fr;
flex-direction: column;
align-items: stretch;
}
.handoff-action-card .el-button {
min-height: 40px;
}
.action-buttons {
flex-direction: column;
}