拆分订单详情结账逻辑

This commit is contained in:
yml
2026-06-09 22:07:55 +08:00
parent d68020db4e
commit 3bbe356c3f
9 changed files with 1445 additions and 1402 deletions
@@ -0,0 +1,240 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Order } from '@/features/orders/api/orders'
import { amountYuan, money } from '@/features/orders/composables/useOrderSnapshot'
const props = withDefaults(
defineProps<{
variant?: 'desktop' | 'mobile'
order: Order
isOwner: boolean
isRenter: boolean
}>(),
{ variant: 'desktop' }
)
const checkout = computed(() => props.order.checkout)
</script>
<template>
<section v-if="checkout && props.variant === 'mobile'" class="card-section">
<h3 class="section-title">结账结算账单</h3>
<van-cell-group inset :border="false">
<van-cell
title="实际结算租金"
:value="`¥${money(amountYuan(checkout.display_amount_cent))}`"
/>
<van-cell
title="押金总额"
:label="
amountYuan(props.order.deposit_waived_amount_cent) > 0
? `已免押 ¥${money(amountYuan(props.order.deposit_waived_amount_cent))}`
: ''
"
:value="`¥${money(amountYuan(checkout.deposit_amount_cent))}`"
/>
<van-cell
title="额外消耗品已用"
:value="`¥${money(amountYuan(checkout.consumable_amount_cent))}`"
/>
<van-cell
title="押金赔付扣除"
:value="`¥${money(amountYuan(checkout.deposit_deduct_amount_cent))}`"
value-class="red-text"
/>
<van-cell
v-if="props.isRenter"
title="退还租客"
:value="`¥${money(amountYuan(checkout.renter_refund_amount_cent))}`"
value-class="green-text"
/>
<van-cell
v-if="props.isOwner"
title="号主最终收入"
:value="`¥${money(amountYuan(checkout.owner_income_amount_cent))}`"
value-class="green-text"
/>
<van-cell v-if="checkout.content" title="结账备注" :label="checkout.content" />
<van-cell
v-if="checkout.owner_adjustment_reason"
title="号主修正原因"
:label="checkout.owner_adjustment_reason"
/>
</van-cell-group>
</section>
<div v-else-if="checkout" class="info-section">
<div class="section-header">
<h2>结账明细</h2>
</div>
<div class="checkout-summary-card">
<div class="summary-row">
<span>实际结算租金</span>
<strong>¥{{ money(amountYuan(checkout.display_amount_cent)) }}</strong>
</div>
<div class="summary-row">
<span>预收押金</span>
<strong>
¥{{ money(amountYuan(checkout.deposit_amount_cent)) }}
<em v-if="amountYuan(props.order.deposit_waived_amount_cent) > 0">
已免押 ¥{{ money(amountYuan(props.order.deposit_waived_amount_cent)) }}
</em>
</strong>
</div>
<div class="summary-row">
<span>额外消耗品已用</span>
<strong class="warning">¥{{ money(amountYuan(checkout.consumable_amount_cent)) }}</strong>
</div>
<div class="summary-row">
<span>押金赔付扣除</span>
<strong class="warning">
¥{{ money(amountYuan(checkout.deposit_deduct_amount_cent)) }}
</strong>
</div>
<div v-if="props.isRenter" class="summary-row highlight">
<span>退还租客(未使用租金 + 剩余押金)</span>
<strong class="amount">¥{{ money(amountYuan(checkout.renter_refund_amount_cent)) }}</strong>
</div>
<div v-if="props.isOwner" class="summary-row highlight">
<span>号主最终收入(租金 + 押金赔付)</span>
<strong class="amount">¥{{ money(amountYuan(checkout.owner_income_amount_cent)) }}</strong>
</div>
<div v-if="checkout.content" class="summary-note">
<label>说明:</label>
<p>{{ checkout.content }}</p>
</div>
<div v-if="checkout.owner_adjustment_reason" class="summary-note warning">
<label>修正原因:</label>
<p>{{ checkout.owner_adjustment_reason }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.info-section {
margin-bottom: 32px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-header h2 {
font-size: 20px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.checkout-summary-card {
display: grid;
gap: 12px;
padding: 24px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
}
.summary-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.summary-row:last-child {
border-bottom: none;
}
.summary-row.highlight {
padding: 16px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
}
.summary-row span {
font-size: 14px;
color: #64748b;
}
.summary-row strong {
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 18px;
font-weight: 700;
color: #1f2937;
}
.summary-row strong em {
font-style: normal;
font-size: 12px;
font-weight: 500;
color: #2563eb;
}
.summary-row strong.amount {
color: #16a34a;
}
.summary-row strong.warning {
color: #dc2626;
}
.summary-note {
padding: 16px;
background: #f8fafc;
border-radius: 8px;
}
.summary-note.warning {
background: #fff7ed;
}
.summary-note label {
display: block;
margin-bottom: 8px;
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.summary-note p {
margin: 0;
color: #1f2937;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.card-section {
margin: 12px 12px 0;
padding: 14px;
background: #ffffff;
border-radius: 8px;
}
.section-title {
font-size: 15px;
font-weight: 800;
color: #182232;
margin: 0 0 12px;
}
:deep(.red-text) {
color: #ef4444;
font-weight: 700;
}
:deep(.green-text) {
color: #16a34a;
font-weight: 700;
}
</style>
@@ -0,0 +1,170 @@
<script setup lang="ts">
import type { HandoffRecord } from '@/features/orders/api/orders'
import { formatHandoffRecordType } from '@/features/orders/composables/useOrderSnapshot'
import { formatDateTime } from '@/shared/utils/time'
const props = withDefaults(
defineProps<{
variant?: 'desktop' | 'mobile'
records: HandoffRecord[]
listingCode: string
}>(),
{ variant: 'desktop' }
)
</script>
<template>
<section v-if="props.variant === 'mobile'" class="card-section">
<h3 class="section-title">交接日志 · 商品编号 {{ props.listingCode }}</h3>
<van-empty v-if="props.records.length === 0" image="search" description="暂无交接日志记录" />
<div v-else class="log-timeline">
<div v-for="record in props.records" :key="record.id" class="log-item">
<div class="log-dot"></div>
<div class="log-content-wrap">
<strong class="log-type">{{ formatHandoffRecordType(record.type) }}</strong>
<p class="log-desc">{{ record.content }}</p>
<span class="log-time">{{ formatDateTime(record.created_at) }}</span>
</div>
</div>
</div>
</section>
<div v-else class="timeline-section">
<div class="section-header">
<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>
</el-timeline-item>
</el-timeline>
</div>
</template>
<style scoped>
.timeline-section {
margin-bottom: 32px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-header h2 {
font-size: 20px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.timeline-section :deep(.el-timeline) {
padding: 24px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
}
.timeline-content {
display: grid;
gap: 8px;
}
.timeline-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.timeline-body {
font-size: 14px;
color: #64748b;
line-height: 1.6;
white-space: pre-wrap;
}
.card-section {
margin: 12px 12px 0;
padding: 14px;
background: #ffffff;
border-radius: 8px;
}
.section-title {
font-size: 15px;
font-weight: 800;
color: #182232;
margin: 0 0 12px;
}
.log-timeline {
position: relative;
padding-left: 18px;
}
.log-timeline::after {
content: '';
position: absolute;
left: 5px;
top: 4px;
bottom: 4px;
width: 1px;
background: #e2e8f0;
}
.log-item {
position: relative;
padding-bottom: 16px;
}
.log-item:last-child {
padding-bottom: 0;
}
.log-dot {
position: absolute;
left: -18px;
top: 4px;
z-index: 1;
width: 11px;
height: 11px;
border: 2px solid #ffffff;
border-radius: 50%;
background: #ff6a00;
}
.log-content-wrap {
display: grid;
gap: 4px;
}
.log-type {
color: #182232;
font-size: 13px;
}
.log-desc {
margin: 0;
color: #5f6f86;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
}
.log-time {
color: #a0aec0;
font-size: 11px;
}
</style>
@@ -0,0 +1,547 @@
<script setup lang="ts">
import type { CheckoutResource } from '@/features/orders/composables/useOrderSnapshot'
import { money, quantity } from '@/features/orders/composables/useOrderSnapshot'
type CheckoutFormModel = {
content: string
coin_consumed_m: number
otherAmountYuan: number
evidenceText: string
}
const props = withDefaults(
defineProps<{
variant?: 'desktop' | 'mobile'
checkoutForm: CheckoutFormModel
resources: CheckoutResource[]
resourceUsage: Record<string, number>
resourceChargeAmount: number
snapshotHafCoinM: number
remainingHafCoinM: number
returning: boolean
resourceLineAmount: (item: CheckoutResource) => number
}>(),
{ variant: 'desktop' }
)
const emit = defineEmits<{
'update:checkoutForm': [value: CheckoutFormModel]
'update:resourceUsage': [value: Record<string, number>]
submit: []
}>()
function updateCheckoutForm(patch: Partial<CheckoutFormModel>) {
emit('update:checkoutForm', { ...props.checkoutForm, ...patch })
}
function updateResourceUsage(key: string, value: number | string | null) {
emit('update:resourceUsage', {
...props.resourceUsage,
[key]: Number(value) || 0,
})
}
function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event: Event) {
updateCheckoutForm({ [field]: Number((event.target as HTMLInputElement).value) || 0 })
}
</script>
<template>
<section v-if="props.variant === 'mobile'" class="card-section action-card">
<h3 class="section-title">退号发起结账</h3>
<p class="action-hint">使用完毕,请输入在此期间消耗的物资与哈夫币进行结账申请。</p>
<van-field
:model-value="props.checkoutForm.content"
rows="3"
autosize
label="结账备注"
type="textarea"
placeholder="可在此说明物品消耗情况或归还留言"
class="action-field"
@update:model-value="updateCheckoutForm({ content: String($event) })"
/>
<div v-if="props.resources.length" class="resource-usage-panel">
<strong class="resource-panel-title">额外物资消耗登记</strong>
<div v-for="item in props.resources" :key="item.key" class="resource-row">
<div class="resource-meta">
<span class="resource-name">{{ item.label }}</span>
<span class="resource-price-text">
上限 {{ item.quantity }} · {{ item.mode }} · {{ item.price }}
</span>
</div>
<div class="stepper-wrap">
<van-stepper
:model-value="props.resourceUsage[item.key]"
integer
min="0"
:max="item.quantity"
@update:model-value="updateResourceUsage(item.key, $event)"
/>
<span class="resource-line-amount">¥{{ money(props.resourceLineAmount(item)) }}</span>
</div>
</div>
<div class="resource-panel-footer">
<span>物资金额合计:</span>
<strong>¥{{ money(props.resourceChargeAmount) }}</strong>
</div>
</div>
<van-cell-group inset :border="false" class="action-field">
<van-field label="消耗哈夫币" label-width="100px">
<template #input>
<div class="coin-input-wrap">
<input
:value="props.checkoutForm.coin_consumed_m"
type="number"
placeholder="消耗数量"
class="custom-inline-input"
@input="updateNumberField('coin_consumed_m', $event)"
/>
<span class="input-unit">M</span>
</div>
</template>
</van-field>
<div class="coin-hint">
订单快照 {{ quantity(props.snapshotHafCoinM) }}M,预计剩余
{{ quantity(props.remainingHafCoinM) }}M
</div>
<van-field label="其他押金赔付" label-width="100px">
<template #input>
<div class="coin-input-wrap">
<input
:value="props.checkoutForm.otherAmountYuan"
type="number"
placeholder="不含上方额外物资"
class="custom-inline-input"
@input="updateNumberField('otherAmountYuan', $event)"
/>
<span class="input-unit">元</span>
</div>
</template>
</van-field>
<div class="deposit-deduct-hint">仅填写封禁、违规、资产损坏等需要从押金赔付的费用。</div>
</van-cell-group>
<div class="checkout-fee-preview">
<div>
<span>额外消耗品已用</span>
<strong>¥{{ money(props.resourceChargeAmount) }}</strong>
<em>计入实际结算租金</em>
</div>
<div>
<span>其他押金赔付</span>
<strong>¥{{ money(props.checkoutForm.otherAmountYuan) }}</strong>
<em>从押金赔付扣除</em>
</div>
</div>
<van-field
:model-value="props.checkoutForm.evidenceText"
rows="2"
autosize
label="结账证据"
type="textarea"
placeholder="结账截图链接(每行一个)"
class="action-field"
@update:model-value="updateCheckoutForm({ evidenceText: String($event) })"
/>
<van-button
type="primary"
block
round
:loading="props.returning"
loading-text="正在提交结账..."
@click="emit('submit')"
>
提交结账归还
</van-button>
</section>
<div v-else class="form-section checkout-form-section">
<div class="section-header">
<h2>发起结账</h2>
</div>
<div class="form-card">
<el-input
:model-value="props.checkoutForm.content"
type="textarea"
:rows="4"
placeholder="填写结账说明、租后资产状态或注意事项"
@update:model-value="updateCheckoutForm({ content: String($event) })"
/>
<div class="checkout-resource-panel">
<div class="checkout-resource-head">
<strong>额外消耗品</strong>
<span class="amount-highlight">已用金额:¥{{ money(props.resourceChargeAmount) }}</span>
</div>
<el-empty v-if="props.resources.length === 0" description="订单快照中暂无额外消耗品" />
<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>
<span>库存 {{ item.quantity }},{{ item.mode }},{{ item.price || '未设置单价' }}</span>
</div>
<el-input-number
:model-value="props.resourceUsage[item.key]"
:min="0"
:max="item.quantity"
:precision="0"
controls-position="right"
@update:model-value="updateResourceUsage(item.key, $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-deduct-preview">
<div>
<span>额外消耗品已用</span>
<strong>¥{{ money(props.resourceChargeAmount) }}</strong>
<em>按上方物资数量自动计算,计入实际结算租金</em>
</div>
<div>
<span>其他押金赔付</span>
<strong>¥{{ money(props.checkoutForm.otherAmountYuan) }}</strong>
<em>作为押金赔付扣除,和额外消耗品分开展示</em>
</div>
</div>
<el-input
:model-value="props.checkoutForm.evidenceText"
type="textarea"
:rows="3"
placeholder="结账证据链接,一行一个。可填写截图地址或备注链接"
@update:model-value="updateCheckoutForm({ evidenceText: String($event) })"
/>
<el-button type="primary" size="large" :loading="props.returning" @click="emit('submit')">
发起结账
</el-button>
</div>
</div>
</template>
<style scoped>
.form-section {
margin-bottom: 32px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-header h2 {
font-size: 20px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.form-card {
display: grid;
gap: 16px;
padding: 24px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
}
.checkout-resource-panel {
display: grid;
gap: 12px;
padding: 16px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.checkout-resource-head {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: center;
padding-bottom: 12px;
border-bottom: 2px solid #e5e7eb;
}
.checkout-resource-head strong {
color: #1f2937;
font-size: 15px;
}
.amount-highlight {
color: #ff6a00;
font-weight: 700;
font-size: 14px;
}
.checkout-resource-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 12px;
align-items: center;
padding: 12px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 6px;
}
.checkout-resource-meta {
display: grid;
gap: 4px;
}
.checkout-resource-meta strong {
color: #1f2937;
font-size: 14px;
}
.checkout-resource-meta span {
color: #6b7785;
font-size: 12px;
}
.checkout-resource-amount {
min-width: 80px;
text-align: right;
color: #ff6a00;
font-weight: 700;
font-size: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.field-hint {
display: block;
margin-top: 6px;
color: #6b7280;
font-size: 12px;
}
.checkout-deduct-preview,
.checkout-fee-preview {
display: grid;
gap: 12px;
}
.checkout-deduct-preview {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.checkout-deduct-preview div,
.checkout-fee-preview div {
display: grid;
gap: 6px;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
}
.checkout-deduct-preview span,
.checkout-fee-preview span {
color: #4b5563;
font-size: 13px;
font-weight: 700;
}
.checkout-deduct-preview strong,
.checkout-fee-preview strong {
color: #ff6a00;
font-size: 18px;
font-weight: 800;
}
.checkout-deduct-preview em,
.checkout-fee-preview em {
color: #6b7280;
font-size: 12px;
font-style: normal;
line-height: 1.45;
}
.card-section {
margin: 12px 12px 0;
padding: 14px;
background: #ffffff;
border-radius: 8px;
}
.action-card {
background: #ffffff;
}
.section-title {
font-size: 15px;
font-weight: 800;
color: #182232;
margin: 0 0 12px;
}
.action-hint {
margin: 0 0 12px;
color: #5f6f86;
font-size: 13px;
line-height: 1.55;
}
.action-field {
margin-bottom: 12px;
}
.resource-usage-panel {
margin: 0 16px 12px;
padding: 12px;
border: 1px solid #edf2f7;
border-radius: 8px;
background: #fafafa;
}
.resource-panel-title {
display: block;
font-size: 13px;
font-weight: 700;
margin-bottom: 10px;
color: #2d3748;
}
.resource-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px dashed #e2e8f0;
}
.resource-row:last-child {
border-bottom: none;
}
.resource-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.resource-name {
font-size: 13px;
color: #2d3748;
font-weight: 600;
}
.resource-price-text {
font-size: 11px;
color: #a0aec0;
}
.stepper-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.resource-line-amount {
min-width: 50px;
text-align: right;
font-size: 13px;
font-weight: 700;
color: #ff5f00;
}
.resource-panel-footer {
display: flex;
justify-content: flex-end;
align-items: baseline;
margin-top: 10px;
font-size: 12px;
color: #4a5568;
}
.resource-panel-footer strong {
font-size: 15px;
color: #ff5f00;
}
.coin-input-wrap {
display: flex;
align-items: center;
width: 100%;
}
.custom-inline-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 14px;
color: #182232;
}
.input-unit {
color: #8a94a6;
font-size: 13px;
}
.coin-hint,
.deposit-deduct-hint {
padding: 0 16px 10px;
color: #8a94a6;
font-size: 12px;
line-height: 1.5;
}
.checkout-fee-preview {
margin: 0 16px 12px;
}
@media (max-width: 768px) {
.checkout-resource-row {
grid-template-columns: 1fr;
}
.checkout-resource-amount {
text-align: left;
}
.checkout-deduct-preview {
grid-template-columns: 1fr;
}
}
</style>