添加订单押金暂扣与归还

This commit is contained in:
yml2213
2026-07-05 19:35:57 +08:00
parent 9ef0843eb0
commit 090a90a268
17 changed files with 587 additions and 36 deletions
@@ -6,9 +6,11 @@ import { useRoute } from 'vue-router'
import {
adminCloseOrder,
adminHoldDeposit,
adminMarkOrderAbnormal,
adminRefundOrder,
adminRefundStatus,
adminReleaseDeposit,
adminResetHandoff,
adminSealOrder,
fetchAdminHandoffRecords,
@@ -41,7 +43,15 @@ const submitting = ref(false)
const order = ref<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([])
const paymentRecords = ref<AdminPayment[]>([])
const actionType = ref<'close' | 'seal' | 'abnormal' | 'reset' | ''>('')
type OrderActionType =
| 'close'
| 'seal'
| 'abnormal'
| 'reset'
| 'deposit_hold'
| 'deposit_release'
| ''
const actionType = ref<OrderActionType>('')
const reason = ref('')
const refundStatus = ref<RefundStatus | null>(null)
@@ -88,16 +98,50 @@ const resetActionLabel = computed(() => {
return resetAction.value?.label || '重置'
})
const canResetHandoff = computed(() => resetAction.value?.enabled === true)
// 押金暂扣:仅进行中、有实付押金、且未暂扣过的订单可暂扣。
const depositHoldStatus = computed(() => order.value?.deposit_hold_status || 'none')
const depositHoldAmountCent = computed(() => Number(order.value?.deposit_hold_amount_cent || 0))
const isDepositHeld = computed(() => depositHoldStatus.value === 'held')
const canHoldDeposit = computed(
() =>
canOperate.value &&
depositHoldStatus.value === 'none' &&
Number(order.value?.deposit_amount_cent || 0) > 0
)
const canReleaseDeposit = computed(() => isDepositHeld.value && depositHoldAmountCent.value > 0)
const depositHoldStatusLabel = computed(() => {
switch (depositHoldStatus.value) {
case 'held':
return '押金已暂扣'
case 'released':
return '押金已归还'
default:
return ''
}
})
const actionTitle = computed(() => {
if (actionType.value === 'close') return '客服关闭订单'
if (actionType.value === 'seal') return '封存订单'
if (actionType.value === 'reset') return `${resetActionLabel.value}(恢复到对应待办)`
if (actionType.value === 'deposit_hold') return '暂扣押金'
if (actionType.value === 'deposit_release') return '归还暂扣押金'
return '标记订单异常'
})
const closeActionTip = '关闭订单并归档商品/账号;已支付订单会按租金+实付押金全量原路退款。'
const actionConfirmButtonType = computed(() =>
actionType.value === 'deposit_release' ? 'primary' : 'danger'
)
const closeActionTip =
'关闭订单并归档商品/账号;已支付订单会原路退款,押金已暂扣时押金部分会继续挂起。'
const sealActionTip =
'封存订单:终止订单并全量原路退款,自动解散关联群聊,关联商品永久封存,号主无法再次编辑或上架。适用于号主失联、无法联系的场景。'
const refundActionTip = '仅发起后台人工全量原路退款,不关闭订单或调整商品/账号状态。'
'封存订单:终止订单并原路退款,自动解散关联群聊,关联商品永久封存;押金已暂扣时押金部分会继续挂起。'
const depositHoldActionTip = '暂扣后订单继续流转;后续本应退给租客的押金会挂起,需客服手动归还。'
const depositReleaseActionTip = computed(() =>
depositHoldAmountCent.value > 0
? `将已暂扣的 ${moneyCent(depositHoldAmountCent.value)} 押金原路归还租客。`
: '暂扣金额为 0,需订单结算/关闭/仲裁产生暂扣金额后才能归还。'
)
const refundActionTip =
'仅发起后台人工原路退款,不关闭订单或调整商品/账号状态;押金已暂扣时仅退非暂扣部分。'
const refundButtonDisabled = computed(() => refundStatus.value?.refund_status === 'refunded')
const orderTotalCent = computed(
() => Number(order.value?.rent_amount_cent || 0) + Number(order.value?.deposit_amount_cent || 0)
@@ -146,9 +190,7 @@ const ownerLossPriceCent = computed(() => {
const hasOwnerRentBreakdown = computed(() => ownerCoinBasePriceCent.value !== null)
const fundSplitRows = computed(() => {
if (!order.value) return []
const rows: FundSplitRow[] = [
{ label: '租客租金', amountCent: order.value.rent_amount_cent },
]
const rows: FundSplitRow[] = [{ label: '租客租金', amountCent: order.value.rent_amount_cent }]
if (hasOwnerRentBreakdown.value) {
rows.push(
{ label: '号主纯币价格', amountCent: ownerCoinBasePriceCent.value },
@@ -205,7 +247,7 @@ async function loadRefundStatus() {
}
}
function openAction(type: 'close' | 'seal' | 'abnormal' | 'reset') {
function openAction(type: Exclude<OrderActionType, ''>) {
actionType.value = type
reason.value = ''
}
@@ -214,7 +256,7 @@ async function confirmCloseAction() {
if (!order.value || !canOperate.value) return
try {
await ElMessageBox.confirm(
'客服关闭会终止订单,归档关联商品和账号;若订单已支付,将按租金加实付押金发起全量原路退款。确认继续?',
'客服关闭会终止订单,归档关联商品和账号;若订单已支付,将发起原路退款。若押金已暂扣,押金部分会继续挂起。确认继续?',
'确认客服关闭',
{
confirmButtonText: '继续关闭',
@@ -232,7 +274,7 @@ async function confirmSealAction() {
if (!order.value || !canOperate.value) return
try {
await ElMessageBox.confirm(
'封存会终止订单并全量原路退款,自动解散关联群聊,关联商品将被永久封存,号主无法再次编辑或上架。适用于号主失联场景。确认继续?',
'封存会终止订单并发起原路退款,自动解散关联群聊,关联商品将被永久封存。若押金已暂扣,押金部分会继续挂起。确认继续?',
'确认封存订单',
{
confirmButtonText: '继续封存',
@@ -246,6 +288,46 @@ async function confirmSealAction() {
openAction('seal')
}
async function confirmHoldDeposit() {
if (!order.value || !canHoldDeposit.value) return
try {
await ElMessageBox.confirm(
'暂扣后订单继续流转;后续结算、关闭或仲裁中本应退给租客的押金会被挂起,需客服手动归还。确认继续?',
'确认暂扣押金',
{
confirmButtonText: '继续暂扣',
cancelButtonText: '取消',
type: 'warning',
}
)
} catch {
return
}
openAction('deposit_hold')
}
async function confirmReleaseDeposit() {
if (!order.value || !isDepositHeld.value) return
if (!canReleaseDeposit.value) {
ElMessage.warning('暂扣押金尚未形成可归还金额')
return
}
try {
await ElMessageBox.confirm(
`将已暂扣的 ${moneyCent(depositHoldAmountCent.value)} 押金原路归还租客。确认继续?`,
'确认归还押金',
{
confirmButtonText: '继续归还',
cancelButtonText: '取消',
type: 'warning',
}
)
} catch {
return
}
openAction('deposit_release')
}
async function submitAction() {
if (!order.value || !actionType.value) return
submitting.value = true
@@ -259,6 +341,12 @@ async function submitAction() {
} else if (actionType.value === 'reset') {
await adminResetHandoff(order.value.id, reason.value)
ElMessage.success(`${resetActionLabel.value}成功`)
} else if (actionType.value === 'deposit_hold') {
await adminHoldDeposit(order.value.id, reason.value)
ElMessage.success('押金已暂扣')
} else if (actionType.value === 'deposit_release') {
await adminReleaseDeposit(order.value.id, reason.value)
ElMessage.success('暂扣押金已发起归还')
} else {
await adminMarkOrderAbnormal(order.value.id, reason.value)
ElMessage.success('订单已标记异常')
@@ -306,7 +394,9 @@ async function confirmRefund() {
)
const message = hasRefundInProgress
? '当前订单已有退款处理中,继续人工退款可能导致重复全量退款。确认仍要发起?'
: '人工退款只会发起全量原路退款,不会关闭订单或调整商品状态。请确认该订单确实需要补发全额退款。'
: isDepositHeld.value
? '人工退款不会关闭订单或调整商品状态;当前押金已暂扣,本次仅退非暂扣部分。确认继续?'
: '人工退款只会发起原路退款,不会关闭订单或调整商品状态。请确认该订单确实需要补发退款。'
try {
await ElMessageBox.confirm(message, '确认人工退款', {
confirmButtonText: '确认退款',
@@ -346,6 +436,8 @@ function paymentBizTypeLabel(type: string) {
admin_refund: '人工退款',
cancel_refund: '取消退款',
admin_close_refund: '客服关闭退款',
admin_seal_refund: '封存退款',
deposit_refund: '暂扣押金归还',
}
return map[type] || type
}
@@ -499,6 +591,23 @@ function paymentPaidAt(record: AdminPayment) {
>
</span>
</el-tooltip>
<el-tooltip v-if="canHoldDeposit" :content="depositHoldActionTip" placement="top">
<span>
<el-button type="warning" plain @click="confirmHoldDeposit"> 暂扣押金 </el-button>
</span>
</el-tooltip>
<el-tooltip v-if="isDepositHeld" :content="depositReleaseActionTip" placement="top">
<span>
<el-button
type="success"
plain
:disabled="!canReleaseDeposit"
@click="confirmReleaseDeposit"
>
归还押金
</el-button>
</span>
</el-tooltip>
<el-tooltip :content="refundActionTip" placement="top">
<span>
<el-button
@@ -670,6 +779,26 @@ function paymentPaidAt(record: AdminPayment) {
<dt>支付流水</dt>
<dd>{{ paymentRecords.length }} </dd>
</div>
<div>
<dt>押金暂扣</dt>
<dd>{{ depositHoldStatusLabel || '未暂扣' }}</dd>
</div>
<div v-if="depositHoldStatus !== 'none'">
<dt>已暂扣金额</dt>
<dd>{{ moneyCent(depositHoldAmountCent) }}</dd>
</div>
<div v-if="order.deposit_hold_reason" class="wide">
<dt>暂扣原因</dt>
<dd>{{ order.deposit_hold_reason }}</dd>
</div>
<div v-if="order.deposit_held_at">
<dt>暂扣时间</dt>
<dd>{{ formatDateTime(order.deposit_held_at) }}</dd>
</div>
<div v-if="order.deposit_hold_released_at">
<dt>归还时间</dt>
<dd>{{ formatDateTime(order.deposit_hold_released_at) }}</dd>
</div>
</dl>
</section>
@@ -816,10 +945,15 @@ function paymentPaidAt(record: AdminPayment) {
<dd>{{ item.value }}</dd>
</div>
</dl>
<div v-if="snapshotRatio && (snapshotRatio.recycleRatio > 0 || snapshotRatio.sellRatio > 0)" class="ratio-row">
<div
v-if="snapshotRatio && (snapshotRatio.recycleRatio > 0 || snapshotRatio.sellRatio > 0)"
class="ratio-row"
>
<div v-if="snapshotRatio.recycleRatio > 0" class="ratio-item">
<span class="ratio-label">回收比例</span>
<strong class="ratio-value">1:{{ formatRatioNumber(snapshotRatio.recycleRatio) }}</strong>
<strong class="ratio-value"
>1:{{ formatRatioNumber(snapshotRatio.recycleRatio) }}</strong
>
</div>
<div v-if="snapshotRatio.sellRatio > 0" class="ratio-item">
<span class="ratio-label">售卖比例</span>
@@ -860,7 +994,9 @@ function paymentPaidAt(record: AdminPayment) {
</div>
<template #footer>
<el-button @click="actionType = ''">取消</el-button>
<el-button type="danger" :loading="submitting" @click="submitAction">确认操作</el-button>
<el-button :type="actionConfirmButtonType" :loading="submitting" @click="submitAction">
确认操作
</el-button>
</template>
</el-dialog>
</section>
@@ -36,6 +36,11 @@ export interface Order {
settlement_status: SettlementStatus
refund_status?: string
refund_amount_cent?: number
deposit_hold_status?: string
deposit_hold_amount_cent?: number
deposit_hold_reason?: string
deposit_held_at?: string
deposit_hold_released_at?: string
active_dispute?: ActiveDispute
checkout?: Checkout
admin_actions?: AdminActions
@@ -387,6 +392,22 @@ export async function adminRejectRefund(id: number, action: 'restore' | 'close')
return data.data
}
export async function adminHoldDeposit(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<{ held: boolean }>>(
`/admin/orders/${id}/deposit-hold`,
{ reason }
)
return data.data
}
export async function adminReleaseDeposit(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<{ released: boolean }>>(
`/admin/orders/${id}/deposit-release`,
{ reason }
)
return data.data
}
export async function fetchPendingRefundOrders() {
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>(
'/admin/orders/refund-pending'