feat(checkout): 修复后置损耗结算全额归号主、支持角色隔离实时试算与全流程UI重构

- fix(pricing): 后置消耗/损耗金额 100% 划归号主收益,超出预收部分平台不抽成,避免被平台费吸收
- feat(checkout): 前端新增结账实时试算预览组件 (CheckoutTrialPreview + useCheckoutSimulator),角色隔离展示(仅见自身收入/退款,屏蔽平台服务费)
- refactor(admin): 重构交易全流程时间线 (ProcessTimeline.vue),将平铺挤压指标重构成4大分类仪表盘微型卡片矩阵,升级可视化业务状态流转徽章与阶段色彩主题
- style(admin): 优化支付与交接流水元信息网格布局,防止超宽屏下长单行拉伸
This commit is contained in:
yml2213
2026-09-13 10:19:33 +08:00
parent 7734f315ce
commit dff0577385
10 changed files with 1426 additions and 153 deletions
@@ -0,0 +1,265 @@
<script setup lang="ts">
import type { CheckoutSimulation } from '../composables/useCheckoutSimulator'
import { money } from '../composables/useOrderSnapshot'
const props = withDefaults(
defineProps<{
simulation: CheckoutSimulation
variant?: 'desktop' | 'mobile'
}>(),
{ variant: 'desktop' }
)
</script>
<template>
<div v-if="props.simulation" :class="['trial-preview-container', props.variant]">
<!-- ───────────── 号主视角 ───────────── -->
<template v-if="props.simulation.role === 'owner'">
<div class="trial-header">
<span class="trial-title"> <i class="trial-icon">💰</i> 结账到手试算(仅您可见) </span>
<span v-if="props.simulation.isDepositSufficient" class="badge success">押金充足</span>
<span v-else class="badge danger">押金不足</span>
</div>
<div class="trial-body">
<div class="main-metric">
<span class="label">预计最终到手总额</span>
<strong class="value income">¥{{ money(props.simulation.totalIncomeYuan) }}</strong>
</div>
<div class="metric-breakdown">
<div class="breakdown-item">
<span>租金收益</span>
<span>¥{{ money(props.simulation.rentIncomeYuan) }}</span>
</div>
<div v-if="props.simulation.damageCompensationYuan > 0" class="breakdown-item highlight">
<span>损耗/损坏赔偿 (全额到手)</span>
<span class="text-green">+¥{{ money(props.simulation.damageCompensationYuan) }}</span>
</div>
</div>
<div v-if="props.simulation.isDepositSufficient" class="trial-note info">
<span>💡 租客押金充足,方案确认后款项将全额转入您的可用余额,平台 0 抽成。</span>
</div>
<div v-else class="trial-note warning">
<span
>⚠️ 租客押金不足差额 ¥{{
money(props.simulation.shortfallYuan)
}},无法自动完结,需协商调低金额或发起争议。</span
>
</div>
</div>
</template>
<!-- ───────────── 租客视角 ───────────── -->
<template v-else-if="props.simulation.role === 'renter'">
<div class="trial-header">
<span class="trial-title"> <i class="trial-icon">🧾</i> 费用与退款试算(仅您可见) </span>
<span v-if="props.simulation.isDepositSufficient" class="badge info">正常结算</span>
<span v-else class="badge danger">需补差额</span>
</div>
<div class="trial-body">
<div class="metrics-grid">
<div class="main-metric">
<span class="label">实际租用消费</span>
<strong class="value">¥{{ money(props.simulation.actualCostYuan) }}</strong>
</div>
<div class="main-metric">
<span class="label">预计原路退还</span>
<strong class="value refund">¥{{ money(props.simulation.totalRefundYuan) }}</strong>
</div>
</div>
<div class="metric-breakdown">
<div class="breakdown-item">
<span>实际使用租金</span>
<span>¥{{ money(props.simulation.buyerRentYuan) }}</span>
</div>
<div v-if="props.simulation.damageDeductYuan > 0" class="breakdown-item">
<span>损耗赔偿扣除 (从押金)</span>
<span class="text-danger">-¥{{ money(props.simulation.damageDeductYuan) }}</span>
</div>
<div v-if="props.simulation.rentRefundYuan > 0" class="breakdown-item">
<span>未用租金退还</span>
<span class="text-green">+¥{{ money(props.simulation.rentRefundYuan) }}</span>
</div>
<div class="breakdown-item">
<span>剩余押金退还</span>
<span class="text-green">+¥{{ money(props.simulation.depositRefundYuan) }}</span>
</div>
</div>
<div v-if="props.simulation.overshootYuan > 0" class="trial-note warning">
<span
>ℹ️ 哈夫币使用已打超预收,超出金额 ¥{{
money(props.simulation.overshootYuan)
}}
已自动从押金抵扣。</span
>
</div>
<div v-if="!props.simulation.isDepositSufficient" class="trial-note danger">
<span
>⚠️ 押金不足以抵扣打超与赔偿,差额 ¥{{
money(props.simulation.shortfallYuan)
}},无法自动同意。</span
>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.trial-preview-container {
margin-top: 12px;
padding: 12px 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #334155;
transition: all 0.2s ease;
}
.trial-preview-container.mobile {
margin: 10px 12px 16px;
padding: 10px 12px;
font-size: 12px;
}
.trial-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #e2e8f0;
}
.trial-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: #1e293b;
}
.trial-icon {
font-style: normal;
}
.badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
}
.badge.success {
background: #dcfce7;
color: #15803d;
}
.badge.info {
background: #e0f2fe;
color: #0369a1;
}
.badge.danger {
background: #fee2e2;
color: #b91c1c;
}
.metrics-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 8px;
}
.main-metric {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 6px 10px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 6px;
}
.main-metric .label {
font-size: 12px;
color: #64748b;
}
.main-metric .value {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.main-metric .value.income {
color: #16a34a;
}
.main-metric .value.refund {
color: #0284c7;
}
.metric-breakdown {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 6px;
}
.breakdown-item {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: #475569;
}
.breakdown-item.highlight {
font-weight: 600;
}
.text-green {
color: #16a34a;
font-weight: 600;
}
.text-danger {
color: #dc2626;
font-weight: 600;
}
.trial-note {
margin-top: 8px;
padding: 6px 10px;
border-radius: 6px;
font-size: 11px;
line-height: 1.4;
}
.trial-note.info {
background: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
}
.trial-note.warning {
background: #fffbeb;
color: #92400e;
border: 1px solid #fde68a;
}
.trial-note.danger {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
</style>
@@ -0,0 +1,169 @@
import { computed, type Ref } from 'vue'
import type { Order } from '../api/orders'
import type { CounterFormState } from './useOrderActions'
import { readSnapshot, roundMoney, roundQuantity, readNumber } from './useOrderSnapshot'
export interface OwnerSimulation {
role: 'owner'
totalIncomeYuan: number
rentIncomeYuan: number
damageCompensationYuan: number
lossCompensationYuan: number
shortfallYuan: number
isDepositSufficient: boolean
}
export interface RenterSimulation {
role: 'renter'
actualCostYuan: number
buyerRentYuan: number
damageDeductYuan: number
totalRefundYuan: number
rentRefundYuan: number
depositRefundYuan: number
overshootYuan: number
shortfallYuan: number
isDepositSufficient: boolean
}
export type CheckoutSimulation = OwnerSimulation | RenterSimulation | null
export function useCheckoutSimulator(options: {
order: Ref<Order | null>
isOwner: Ref<boolean>
isRenter: Ref<boolean>
form: Ref<CounterFormState>
}) {
return computed<CheckoutSimulation>(() => {
const o = options.order.value
if (!o) return null
const consumedM = roundQuantity(Math.max(Number(options.form.value.coin_consumed_m || 0), 0))
const consumableYuan = roundMoney(
Math.max(Number(options.form.value.consumableAmountYuan || 0), 0)
)
const depositDeductYuan = roundMoney(
Math.max(Number(options.form.value.depositDeductAmountYuan || 0), 0)
)
const snapshot = readSnapshot(o)
const assetSummary = snapshot?.asset_summary as Record<string, unknown> | undefined
const priceBreakdown =
((assetSummary?.price_breakdown || snapshot?.price_breakdown) as
| Record<string, unknown>
| undefined) || {}
const totalCoinM = roundQuantity(readNumber(snapshot?.haf_coin_amount) / 1000000)
const buyerRatio = readNumber(priceBreakdown['buyer_ratio'])
const sellerRatio = readNumber(priceBreakdown['seller_ratio'])
const buyerCoinBaseYuan = readNumber(priceBreakdown['buyer_coin_base_price'])
const sellerCoinBaseYuan = readNumber(priceBreakdown['seller_coin_base_price'])
const prepaidConsumableYuan = readNumber(priceBreakdown['consumable_price'])
const coinUseRatio = totalCoinM > 0 ? roundQuantity(consumedM / totalCoinM) : 1
const isCoinOvershoot = totalCoinM > 0 && consumedM > totalCoinM
const depositYuan = Number(o.deposit_amount_cent || 0) / 100
if (options.isOwner.value) {
// ────────── 号主视角试算 ──────────
let ownerCoinYuan =
sellerRatio > 0 && (totalCoinM > 0 || consumedM > 0)
? roundMoney((consumedM * 100) / sellerRatio)
: roundMoney(sellerCoinBaseYuan * coinUseRatio)
if (!isCoinOvershoot) {
ownerCoinYuan = Math.min(
ownerCoinYuan,
sellerCoinBaseYuan > 0 ? sellerCoinBaseYuan : (o.owner_rent_amount_cent || 0) / 100
)
}
// 消耗品/后置损耗计算
let ownerConsumableYuan: number
if (prepaidConsumableYuan > 0) {
const ownerRentBaseYuan = (o.owner_rent_amount_cent || 0) / 100
const prepaidOwnerConsumableYuan = Math.max(
roundMoney(ownerRentBaseYuan - sellerCoinBaseYuan),
0
)
if (consumableYuan <= prepaidConsumableYuan) {
ownerConsumableYuan = roundMoney(
prepaidOwnerConsumableYuan * (consumableYuan / prepaidConsumableYuan)
)
} else {
// 超出预收部分全额归号主
const excessYuan = roundMoney(consumableYuan - prepaidConsumableYuan)
ownerConsumableYuan = roundMoney(prepaidOwnerConsumableYuan + excessYuan)
}
} else {
// 无预收消耗品:后置申报损耗 100% 归号主
ownerConsumableYuan = consumableYuan
}
const ownerRentIncomeYuan = roundMoney(ownerCoinYuan + ownerConsumableYuan)
// 押金赔付覆盖与差额
const damageCoveredYuan = Math.min(depositDeductYuan, depositYuan)
const shortfallYuan = Math.max(roundMoney(depositDeductYuan - depositYuan), 0)
const totalIncomeYuan = roundMoney(ownerRentIncomeYuan + damageCoveredYuan)
return {
role: 'owner',
totalIncomeYuan,
rentIncomeYuan: ownerRentIncomeYuan,
damageCompensationYuan: damageCoveredYuan,
lossCompensationYuan: ownerConsumableYuan,
shortfallYuan,
isDepositSufficient: shortfallYuan <= 0,
}
}
if (options.isRenter.value) {
// ────────── 租客视角试算 ──────────
let buyerCoinYuan =
buyerRatio > 0 && (totalCoinM > 0 || consumedM > 0)
? roundMoney((consumedM * 100) / buyerRatio)
: roundMoney(buyerCoinBaseYuan * coinUseRatio)
if (!isCoinOvershoot) {
buyerCoinYuan = Math.min(
buyerCoinYuan,
buyerCoinBaseYuan > 0 ? buyerCoinBaseYuan : (o.rent_amount_cent || 0) / 100
)
}
const actualBuyerRentYuan = roundMoney(buyerCoinYuan + consumableYuan)
const prepaidRentYuan = (o.rent_amount_cent || 0) / 100
const rentOvershootYuan = Math.max(roundMoney(actualBuyerRentYuan - prepaidRentYuan), 0)
const rentRefundYuan = Math.max(roundMoney(prepaidRentYuan - actualBuyerRentYuan), 0)
const damageAppliedYuan = Math.min(depositDeductYuan, depositYuan)
const depositLeftAfterDamage = Math.max(roundMoney(depositYuan - damageAppliedYuan), 0)
const overshootCoveredYuan = Math.min(rentOvershootYuan, depositLeftAfterDamage)
const depositRefundYuan = Math.max(
roundMoney(depositLeftAfterDamage - overshootCoveredYuan),
0
)
const shortfallYuan = roundMoney(
Math.max(roundMoney(depositDeductYuan - damageAppliedYuan), 0) +
Math.max(roundMoney(rentOvershootYuan - overshootCoveredYuan), 0)
)
const totalRefundYuan = roundMoney(rentRefundYuan + depositRefundYuan)
const actualCostYuan = roundMoney(actualBuyerRentYuan + damageAppliedYuan)
return {
role: 'renter',
actualCostYuan,
buyerRentYuan: actualBuyerRentYuan,
damageDeductYuan: depositDeductYuan,
totalRefundYuan,
rentRefundYuan,
depositRefundYuan,
overshootYuan: rentOvershootYuan,
shortfallYuan,
isDepositSufficient: shortfallYuan <= 0,
}
}
return null
})
}
@@ -5,6 +5,8 @@ import { showDialog, showToast } from 'vant'
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySelectPopup.vue'
import CheckoutTrialPreview from '@/features/orders/components/CheckoutTrialPreview.vue'
import { useCheckoutSimulator } from '@/features/orders/composables/useCheckoutSimulator'
import { useOrderActions, CONFIRMABLE_ACTIONS } from '@/features/orders/composables/useOrderActions'
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
import { useMobilePayWaySelect } from '@/features/orders/composables/useMobilePayWaySelect'
@@ -129,6 +131,13 @@ const showCheckoutCounterAction = computed(
(isRenter.value && order.value.status === 'pending_checkout_accept'))
)
const checkoutSimulation = useCheckoutSimulator({
order,
isOwner,
isRenter,
form: counterForm,
})
// 状态颜色映射(vant 标签主题)。
function getStatusTagType(status: string) {
if (['completed', 'received'].includes(status)) return 'success'
@@ -505,7 +514,7 @@ async function copyListingCode() {
}}。可填实际哈夫币消耗(允许超过发布量),打超按比例从押金扣。提案将交给对方确认。
</p>
<van-cell-group :border="false">
<van-field label="额外消耗品已用">
<van-field label="物资/损耗金额">
<template #input>
<input
v-model.number="counterForm.consumableAmountYuan"
@@ -533,6 +542,9 @@ async function copyListingCode() {
</template>
</van-field>
</van-cell-group>
<!-- 实时结算试算预览(仅展示当前角色自身收支,完全屏蔽平台服务费) -->
<CheckoutTrialPreview :simulation="checkoutSimulation" variant="mobile" />
<van-field
v-model="counterForm.reason"
rows="2"
@@ -12,6 +12,8 @@ import {
OrderHandoffTimeline,
OrderResourceUsageEditor,
} from '@/features/orders'
import CheckoutTrialPreview from '@/features/orders/components/CheckoutTrialPreview.vue'
import { useCheckoutSimulator } from '@/features/orders/composables/useCheckoutSimulator'
import { useOrderActions } from '@/features/orders/composables/useOrderActions'
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
import { formatCent } from '@/shared/utils/money'
@@ -128,6 +130,13 @@ const checkingPayment = cashier.checkingPayment
const paymentPayURL = cashier.payURL
const activePayWayLabel = computedPayWayLabel(activePayment)
const handoffFocused = ref(false)
const checkoutSimulation = useCheckoutSimulator({
order,
isOwner,
isRenter,
form: counterForm,
})
const hasSidebarActions = computed(() => {
if (!order.value) return false
return (
@@ -532,7 +541,7 @@ watch([() => route.query.focus, order, loading], () => {
</p>
<h3 class="sub-title">调整消耗与扣款</h3>
<el-form class="form-grid" label-position="top">
<el-form-item label="额外消耗品已用金额">
<el-form-item label="物资/后置损耗金额(元)">
<el-input-number
v-model="counterForm.consumableAmountYuan"
class="full-control"
@@ -560,6 +569,10 @@ watch([() => route.query.focus, order, loading], () => {
/>
</el-form-item>
</el-form>
<!-- 实时结算试算预览(仅展示当前角色自身收支,完全屏蔽平台服务费) -->
<CheckoutTrialPreview :simulation="checkoutSimulation" variant="desktop" />
<el-input
v-model="counterForm.reason"
type="textarea"