优化订单交接和结账界面

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
+22 -9
View File
@@ -2,7 +2,7 @@
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Close, Loading, Picture } from '@element-plus/icons-vue'
import { ArrowLeft, Close, DocumentChecked, Loading, Picture } from '@element-plus/icons-vue'
import {
fetchChat,
fetchChatMessages,
@@ -33,6 +33,9 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
const conversationID = computed(() => Number(route.params.id || 0))
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
const activeOrderId = computed(
() => conversation.value?.order_id || conversation.value?.latest_order_id || null
)
const memberText = computed(() => {
const participants = conversation.value?.participants || []
if (participants.length === 0)
@@ -252,6 +255,11 @@ function handleKeydown(e: KeyboardEvent) {
handleSend()
}
}
function goOrderHandoff() {
if (!activeOrderId.value) return
router.push({ path: `/orders/${activeOrderId.value}`, query: { focus: 'handoff' } })
}
</script>
<template>
@@ -267,14 +275,11 @@ function handleKeydown(e: KeyboardEvent) {
<h2>{{ conversation?.title || '客服会话' }}</h2>
<p>{{ memberText }}</p>
</div>
<el-button
v-if="conversation?.order_id"
type="primary"
link
@click="router.push(`/orders/${conversation.order_id}`)"
>
查看订单
</el-button>
<div v-if="activeOrderId" class="chat-header-actions">
<el-button type="primary" :icon="DocumentChecked" @click="goOrderHandoff">
订单交接
</el-button>
</div>
</div>
<!-- Messages -->
@@ -450,6 +455,14 @@ function handleKeydown(e: KeyboardEvent) {
font-size: 12px;
}
.chat-header-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
flex-shrink: 0;
}
.message-list {
flex: 1;
min-height: 0;
@@ -34,65 +34,100 @@ const props = withDefaults(
<h2>交接记录</h2>
<span>商品编号 {{ props.listingCode }}</span>
</div>
<el-empty v-if="props.records.length === 0" description="暂无交接记录" />
<el-timeline v-else>
<el-timeline-item
v-for="record in props.records"
:key="record.id"
:timestamp="formatDateTime(record.created_at)"
placement="top"
>
<div class="timeline-content">
<div class="timeline-title">{{ formatHandoffRecordType(record.type) }}</div>
<div class="timeline-body">{{ record.content }}</div>
<div class="timeline-card">
<div v-if="props.records.length === 0" class="empty-log">暂无交接记录</div>
<div v-else class="compact-timeline">
<div v-for="record in props.records" :key="record.id" class="timeline-row">
<span class="timeline-dot"></span>
<span class="timeline-time">{{ formatDateTime(record.created_at) }}</span>
<strong class="timeline-title">{{ formatHandoffRecordType(record.type) }}</strong>
<p class="timeline-body">{{ record.content }}</p>
</div>
</el-timeline-item>
</el-timeline>
</div>
</div>
</div>
</template>
<style scoped>
.timeline-section {
margin-bottom: 32px;
margin-bottom: 20px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
margin-bottom: 10px;
}
.section-header h2 {
font-size: 20px;
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.timeline-section :deep(.el-timeline) {
padding: 24px;
.timeline-card {
padding: 12px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
border-radius: 8px;
}
.timeline-content {
.empty-log {
padding: 14px;
color: #8a94a6;
font-size: 13px;
text-align: center;
}
.compact-timeline {
display: grid;
gap: 8px;
}
.timeline-row {
position: relative;
display: grid;
grid-template-columns: 150px 92px minmax(0, 1fr);
gap: 10px;
align-items: start;
padding: 10px 12px 10px 22px;
border: 1px solid #eef1f5;
border-radius: 6px;
background: #fbfcfe;
}
.timeline-dot {
position: absolute;
left: 10px;
top: 15px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #ff6a00;
}
.timeline-time {
color: #8a94a6;
font-size: 12px;
line-height: 1.5;
}
.timeline-title {
font-size: 15px;
font-size: 13px;
font-weight: 600;
color: #1f2937;
line-height: 1.5;
}
.timeline-body {
font-size: 14px;
margin: 0;
font-size: 13px;
color: #64748b;
line-height: 1.6;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.card-section {
@@ -161,14 +161,15 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
</section>
<div v-else class="form-section checkout-form-section">
<div class="section-header">
<h2>发起结账</h2>
<div class="section-header compact">
<h2>当前待办发起结账</h2>
<span>使用完毕后填写租后状态和消耗明细</span>
</div>
<div class="form-card">
<div class="form-card checkout-task-card">
<el-input
:model-value="props.checkoutForm.content"
type="textarea"
:rows="4"
:rows="3"
placeholder="填写结账说明、租后资产状态或注意事项"
@update:model-value="updateCheckoutForm({ content: String($event) })"
/>
@@ -177,7 +178,9 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
<strong>额外消耗品</strong>
<span class="amount-highlight">已用金额¥{{ money(props.resourceChargeAmount) }}</span>
</div>
<el-empty v-if="props.resources.length === 0" description="订单快照中暂无额外消耗品" />
<div v-if="props.resources.length === 0" class="checkout-resource-empty">
订单快照中暂无额外消耗品
</div>
<div v-for="item in props.resources" v-else :key="item.key" class="checkout-resource-row">
<div class="checkout-resource-meta">
<strong>{{ item.label }}</strong>
@@ -194,36 +197,52 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
<span class="checkout-resource-amount">¥{{ money(props.resourceLineAmount(item)) }}</span>
</div>
</div>
<el-form class="form-grid" label-position="top">
<el-form-item label="消耗哈夫币(M">
<el-input-number
:model-value="props.checkoutForm.coin_consumed_m"
class="full-control"
:min="0"
:max="props.snapshotHafCoinM"
:precision="2"
controls-position="right"
@update:model-value="updateCheckoutForm({ coin_consumed_m: Number($event) || 0 })"
/>
<span class="field-hint">
订单快照 {{ quantity(props.snapshotHafCoinM) }}M预计剩余
{{ quantity(props.remainingHafCoinM) }}M
</span>
</el-form-item>
<el-form-item label="其他押金赔付(元)">
<el-input-number
:model-value="props.checkoutForm.otherAmountYuan"
class="full-control"
:min="0"
:precision="0"
controls-position="right"
@update:model-value="updateCheckoutForm({ otherAmountYuan: Number($event) || 0 })"
/>
<span class="field-hint">
不含上方额外消耗品仅填写封禁违规资产损坏等需要从押金赔付的费用
</span>
</el-form-item>
</el-form>
<div class="checkout-number-grid">
<div class="checkout-number-card coin">
<div class="checkout-number-copy">
<div class="checkout-number-title">
<strong>哈夫币消耗</strong>
<span>默认全部使用完成</span>
</div>
<p>
快照 {{ quantity(props.snapshotHafCoinM) }}M预计剩余
{{ quantity(props.remainingHafCoinM) }}M
</p>
</div>
<div class="checkout-number-control">
<el-input-number
:model-value="props.checkoutForm.coin_consumed_m"
class="checkout-number-input"
:min="0"
:max="props.snapshotHafCoinM"
:precision="2"
controls-position="right"
@update:model-value="updateCheckoutForm({ coin_consumed_m: Number($event) || 0 })"
/>
<span>M</span>
</div>
</div>
<div class="checkout-number-card deposit">
<div class="checkout-number-copy">
<div class="checkout-number-title">
<strong>其他押金赔付</strong>
<span>仅押金扣除项</span>
</div>
<p>封禁违规资产损坏等需要从押金赔付的费用不含额外消耗品</p>
</div>
<div class="checkout-number-control">
<el-input-number
:model-value="props.checkoutForm.otherAmountYuan"
class="checkout-number-input"
:min="0"
:precision="0"
controls-position="right"
@update:model-value="updateCheckoutForm({ otherAmountYuan: Number($event) || 0 })"
/>
<span></span>
</div>
</div>
</div>
<div class="checkout-deduct-preview">
<div>
<span>额外消耗品已用</span>
@@ -239,12 +258,12 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
<el-input
:model-value="props.checkoutForm.evidenceText"
type="textarea"
:rows="3"
:rows="2"
placeholder="结账证据链接,一行一个。可填写截图地址或备注链接"
@update:model-value="updateCheckoutForm({ evidenceText: String($event) })"
/>
<el-button type="primary" size="large" :loading="props.returning" @click="emit('submit')">
发起结账
提交结账
</el-button>
</div>
</div>
@@ -252,18 +271,23 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
<style scoped>
.form-section {
margin-bottom: 32px;
margin-bottom: 20px;
}
.section-header {
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;
@@ -271,17 +295,22 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
.form-card {
display: grid;
gap: 16px;
padding: 24px;
gap: 12px;
padding: 16px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
border-radius: 8px;
}
.checkout-task-card {
border-color: #bfdbfe;
background: linear-gradient(180deg, #f8fbff 0%, #eff6ff 100%);
}
.checkout-resource-panel {
display: grid;
gap: 12px;
padding: 16px;
gap: 10px;
padding: 12px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
@@ -290,10 +319,10 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
.checkout-resource-head {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
gap: 10px;
align-items: center;
padding-bottom: 12px;
border-bottom: 2px solid #e5e7eb;
padding-bottom: 10px;
border-bottom: 1px solid #e5e7eb;
}
.checkout-resource-head strong {
@@ -304,15 +333,25 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
.amount-highlight {
color: #ff6a00;
font-weight: 700;
font-size: 14px;
font-size: 13px;
}
.checkout-resource-empty {
padding: 12px;
border: 1px dashed #d8dee8;
border-radius: 6px;
background: #ffffff;
color: #8a94a6;
font-size: 13px;
text-align: center;
}
.checkout-resource-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 12px;
gap: 10px;
align-items: center;
padding: 12px;
padding: 10px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 6px;
@@ -334,17 +373,102 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
}
.checkout-resource-amount {
min-width: 80px;
min-width: 72px;
text-align: right;
color: #ff6a00;
font-weight: 700;
font-size: 16px;
font-size: 15px;
}
.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;
}
.checkout-number-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.checkout-number-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
min-height: 106px;
padding: 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
}
.checkout-number-card.coin {
border-color: #bfdbfe;
background: #f8fbff;
}
.checkout-number-card.deposit {
border-color: #fed7aa;
background: #fffaf3;
}
.checkout-number-copy {
display: grid;
gap: 8px;
min-width: 0;
}
.checkout-number-title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.checkout-number-title strong {
color: #1f2937;
font-size: 14px;
font-weight: 800;
}
.checkout-number-title span {
padding: 2px 8px;
color: #2563eb;
font-size: 12px;
font-weight: 700;
background: #dbeafe;
border-radius: 999px;
}
.checkout-number-card.deposit .checkout-number-title span {
color: #c2410c;
background: #ffedd5;
}
.checkout-number-copy p {
margin: 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.checkout-number-control {
display: grid;
grid-template-columns: 170px 24px;
gap: 8px;
align-items: center;
}
.checkout-number-input {
width: 170px;
}
.checkout-number-control span {
color: #475569;
font-size: 13px;
font-weight: 700;
}
.field-hint {
@@ -357,7 +481,7 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
.checkout-deduct-preview,
.checkout-fee-preview {
display: grid;
gap: 12px;
gap: 10px;
}
.checkout-deduct-preview {
@@ -367,8 +491,8 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
.checkout-deduct-preview div,
.checkout-fee-preview div {
display: grid;
gap: 6px;
padding: 12px;
gap: 4px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
@@ -377,14 +501,14 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
.checkout-deduct-preview span,
.checkout-fee-preview span {
color: #4b5563;
font-size: 13px;
font-size: 12px;
font-weight: 700;
}
.checkout-deduct-preview strong,
.checkout-fee-preview strong {
color: #ff6a00;
font-size: 18px;
font-size: 16px;
font-weight: 800;
}
@@ -543,5 +667,18 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
.checkout-deduct-preview {
grid-template-columns: 1fr;
}
.checkout-number-grid,
.checkout-number-card {
grid-template-columns: 1fr;
}
.checkout-number-control {
grid-template-columns: minmax(0, 1fr) 24px;
}
.checkout-number-input {
width: 100%;
}
}
</style>
@@ -48,9 +48,9 @@ export interface UseOrderActionsOptions {
notifyError: (message: string) => void
/** 警告提示,用于表单校验未通过等非致命场景。 */
notifyWarning: (message: string) => void
/** 二次确认。返回 true 表示用户确认继续,false 表示取消。PC 注入直接 resolve(true)Mobile 注入 showDialog。 */
/** 二次确认。返回 true 表示用户确认继续,false 表示取消。 */
confirm: (opts: { title: string; message: string }) => Promise<boolean>
/** 需要走二次确认的 handler 名集合。Mobile 传 ['cancel','confirmCheckout','acceptCheckout','cancelDispute']PC 传 []。 */
/** 需要走二次确认的 handler 名集合。 */
actionsNeedingConfirm: readonly string[]
/** 支付前选择支付方式。返回 null 表示用户取消支付。 */
selectPayWay: () => Promise<PaymentPayWay | null>
@@ -78,6 +78,7 @@ export const CONFIRMABLE_ACTIONS = [
'confirmCheckout',
'acceptCheckout',
'cancelDispute',
'submitCheckout',
] as const
export interface CheckoutFormState {
@@ -152,6 +153,7 @@ export function useOrderActions(options: UseOrderActionsOptions) {
const disputeType = ref('cannot_login')
const disputeDescription = ref('')
const disputeEvidenceText = ref('')
let checkoutDefaultsHydratedOrderID: number | null = null
// 结账快照能力(资源金额、内容摘要、资源使用量规范化)
const {
@@ -217,12 +219,28 @@ export function useOrderActions(options: UseOrderActionsOptions) {
counterForm.value = hydrateCounterFormFromCheckout(order.value.checkout)
}
function hydrateCheckoutDefaults() {
if (!order.value || !isRenter.value || !['renting', 'overdue'].includes(order.value.status)) {
return
}
if (checkoutDefaultsHydratedOrderID === order.value.id) return
const defaultCoinConsumedM = snapshotHafCoinM.value
if (defaultCoinConsumedM > 0 && Number(checkoutForm.value.coin_consumed_m || 0) <= 0) {
checkoutForm.value = {
...checkoutForm.value,
coin_consumed_m: defaultCoinConsumedM,
}
}
checkoutDefaultsHydratedOrderID = order.value.id
}
async function loadOrder() {
loading.value = true
try {
order.value = await fetchOrder(String(route.params.id))
handoffRecords.value = await fetchHandoffRecords(String(route.params.id))
hydrateResourceUsage()
hydrateCheckoutDefaults()
hydrateCounterForm()
if (
!autoPayHandled &&
@@ -317,6 +335,14 @@ export function useOrderActions(options: UseOrderActionsOptions) {
async function handleSubmitCheckout() {
if (!order.value) return
if (needsConfirm('submitCheckout')) {
const ok = await options.confirm({
title: '确认提交结账',
message:
'结账数据会影响租金、押金赔付和双方结算金额。请确认哈夫币消耗、额外消耗品、押金赔付和证据链接已认真核对,提交后将发送给号主确认。',
})
if (!ok) return
}
returning.value = true
try {
const consumableAmount = resourceChargeAmount.value
@@ -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;
}