Files
hfb_sys/frontend/src/features/admin/views/AdminOrderDetailView.vue
T

2050 lines
64 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { readError } from '@/shared/utils/error'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import {
adminCloseOrder,
adminForceHandoff,
adminHoldDeposit,
adminMarkOfflineSettlement,
adminMarkOrderAbnormal,
adminPlatformCheckoutCounter,
adminPlatformCheckoutConfirm,
adminPlatformCheckoutDispute,
adminPlatformHandoff,
adminRefundOrder,
adminRefundStatus,
adminReleaseDeposit,
adminResetHandoff,
adminSealOrder,
fetchAdminHandoffRecords,
fetchAdminOrder,
type HandoffRecord,
type Order,
type RefundStatus,
} from '@/features/orders'
import { adminCreateOrderDispute } from '@/features/disputes'
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
import {
getSnapshotHafCoinM,
linesToList,
readSnapshot,
readSnapshotResources,
} from '@/features/orders/composables/useOrderSnapshot'
import { adminPath } from '@/shared/utils/adminPath'
import { centToYuan, formatCentWithSymbol } from '@/shared/utils/money'
import {
disputeStatusLabel,
orderHandoffStatusLabel,
orderStatusLabel,
refundStatusLabel,
settlementStatusLabel,
} from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
import { formatGameName, formatListingNo } from '@/shared/utils/listingDisplay'
const route = useRoute()
const loading = ref(false)
const submitting = ref(false)
const order = ref<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([])
const paymentRecords = ref<AdminPayment[]>([])
type OrderActionType =
| 'close'
| 'seal'
| 'abnormal'
| 'reset'
| 'platform_checkout_confirm'
| 'platform_checkout_dispute'
| 'deposit_hold'
| 'deposit_release'
| ''
const actionType = ref<OrderActionType>('')
const reason = ref('')
const refundStatus = ref<RefundStatus | null>(null)
const platformHandoffVisible = ref(false)
const platformHandoffContent = ref('')
const platformHandoffReason = ref('')
const forceHandoffVisible = ref(false)
const forceHandoffContent = ref('')
const forceHandoffReason = ref('')
const platformCheckoutCounterVisible = ref(false)
const platformCheckoutCounterForm = ref({
consumableAmountYuan: 0,
coin_consumed_m: 0,
depositDeductAmountYuan: 0,
reason: '',
evidenceText: '',
})
const offlineSettlementVisible = ref(false)
const offlineSettlementRemark = ref('')
const platformSubmitting = ref(false)
const adminDisputeVisible = ref(false)
const adminDisputeSubmitting = ref(false)
const adminDisputeForm = ref({
type: 'cannot_login',
targetRole: 'renter',
description: '',
evidenceText: '',
})
type FundSplitRow = {
label: string
amountCent: number | null | undefined
}
const listingCode = computed(() =>
order.value ? formatListingNo(order.value.listing_no, order.value.listing_id) : '-'
)
const returnTarget = computed(() => {
const from = firstQueryValue(route.query.from)
const chatID = firstQueryValue(route.query.chat_id)
const chatFilter = firstQueryValue(route.query.chat_filter)
const chatStage = firstQueryValue(route.query.chat_stage)
const chatKeyword = firstQueryValue(route.query.chat_keyword)
if (from === 'chat' && chatID) {
return {
path: adminPath('chats'),
query: {
chat_id: chatID,
...(chatFilter ? { chat_filter: chatFilter } : {}),
...(chatStage ? { chat_stage: chatStage } : {}),
...(chatKeyword ? { chat_keyword: chatKeyword } : {}),
},
}
}
return adminPath('orders')
})
const returnLabel = computed(() =>
firstQueryValue(route.query.from) === 'chat' && firstQueryValue(route.query.chat_id)
? '返回会话'
: '返回列表'
)
const refunding = ref(false)
const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2))
const canOperate = computed(
() => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status)
)
const resetAction = computed(() => order.value?.admin_actions?.reset_handoff)
const platformHandoffAction = computed(() => order.value?.admin_actions?.platform_handoff)
const forceHandoffAction = computed(() => order.value?.admin_actions?.force_handoff)
const platformCheckoutConfirmAction = computed(
() => order.value?.admin_actions?.platform_checkout_confirm
)
const platformCheckoutCounterAction = computed(
() => order.value?.admin_actions?.platform_checkout_counter
)
const platformCheckoutDisputeAction = computed(
() => order.value?.admin_actions?.platform_checkout_dispute
)
const offlineSettlementAction = computed(
() => order.value?.admin_actions?.platform_offline_settlement
)
const resetActionLabel = computed(() => {
return resetAction.value?.label || '重置'
})
const canResetHandoff = computed(() => resetAction.value?.enabled === true)
const canPlatformHandoff = computed(() => platformHandoffAction.value?.enabled === true)
const canForceHandoff = computed(() => forceHandoffAction.value?.enabled === true)
const forceHandoffNeedsContent = computed(
() =>
order.value?.handoff_status === 'pending_owner' ||
order.value?.handoff_status === 'owner_timeout'
)
const canPlatformCheckoutConfirm = computed(
() => platformCheckoutConfirmAction.value?.enabled === true
)
const canPlatformCheckoutCounter = computed(
() => platformCheckoutCounterAction.value?.enabled === true
)
const canPlatformCheckoutDispute = computed(
() => platformCheckoutDisputeAction.value?.enabled === true
)
const canOfflineSettlement = computed(() => offlineSettlementAction.value?.enabled === true)
const canAdminCreateDispute = computed(() => canOperate.value && !order.value?.active_dispute)
const isPlatformManaged = computed(
() =>
order.value?.handoff_mode === 'platform' || order.value?.settlement_mode === 'platform_managed'
)
const offlineSettlementStatus = computed(() => order.value?.offline_settlement_status || 'none')
// 押金暂扣:仅进行中、有实付押金、且未暂扣过的订单可暂扣。
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 === 'platform_checkout_confirm') return '客服确认结账'
if (actionType.value === 'platform_checkout_dispute') return '发起结账争议'
if (actionType.value === 'deposit_hold') return '暂扣押金'
if (actionType.value === 'deposit_release') return '归还暂扣押金'
return '标记订单异常'
})
const actionConfirmButtonType = computed(() => {
if (['deposit_release', 'platform_checkout_confirm'].includes(actionType.value)) return 'primary'
if (actionType.value === 'platform_checkout_dispute') return 'warning'
return 'danger'
})
const closeActionTip =
'关闭订单并归档商品/账号;已支付订单会原路退款,押金已暂扣时押金部分会继续挂起。'
const sealActionTip =
'封存订单:终止订单并原路退款,自动解散关联群聊,关联商品永久封存;押金已暂扣时押金部分会继续挂起。'
const depositHoldActionTip = '暂扣后订单继续流转;后续本应退给租客的押金会挂起,需客服手动归还。'
const depositReleaseActionTip = computed(() =>
depositHoldAmountCent.value > 0
? `将已暂扣的 ${moneyCent(depositHoldAmountCent.value)} 押金原路归还租客。`
: '暂扣金额为 0,需订单结算/关闭/仲裁产生暂扣金额后才能归还。'
)
const refundActionTip =
'仅发起后台人工原路退款,不关闭订单或调整商品/账号状态;押金已暂扣时仅退非暂扣部分。'
const refundButtonDisabled = computed(() => refundStatus.value?.refund_status === 'refunded')
const disputeTypeOptions = [
{ label: '无法登录', value: 'cannot_login' },
{ label: '描述不符', value: 'false_description' },
{ label: '账号封禁', value: 'account_banned' },
{ label: '资产损失', value: 'asset_loss' },
{ label: '哈夫币争议', value: 'haf_coin_dispute' },
{ label: '交接超时', value: 'handoff_timeout' },
{ label: '归还超时', value: 'return_timeout' },
{ label: '结账金额争议', value: 'checkout_amount' },
]
const orderTotalCent = computed(
() => Number(order.value?.rent_amount_cent || 0) + Number(order.value?.deposit_amount_cent || 0)
)
const latestPayment = computed(() => {
return [...paymentRecords.value].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)[0]
})
const paidOrderPayment = computed(() =>
paymentRecords.value.find(item => item.biz_type === 'order_pay' && item.status === 'paid')
)
const currentSnapshot = computed(() => readSnapshot(order.value))
const snapshotHafCoinM = computed(() => getSnapshotHafCoinM(order.value))
const checkoutRemainingHafCoinM = computed(() => {
if (!order.value?.checkout) return 0
return Number(snapshotHafCoinM.value || 0) - Number(order.value.checkout.coin_consumed_m || 0)
})
const snapshotResources = computed(() => readSnapshotResources(order.value).slice(0, 6))
const assetSummary = computed(() => {
const snapshot = order.value?.account_snapshot
if (typeof snapshot !== 'object' || snapshot === null) return null
const summary = (snapshot as Record<string, unknown>).asset_summary
if (typeof summary !== 'object' || summary === null) return null
return summary as Record<string, unknown>
})
const snapshotPriceBreakdown = computed(() => {
const breakdown = assetSummary.value?.price_breakdown
if (typeof breakdown !== 'object' || breakdown === null) return null
return breakdown as Record<string, unknown>
})
const snapshotRatio = computed(() => {
const row = snapshotPriceBreakdown.value
if (!row) return null
const sellerRatio = Number(row.seller_ratio || 0)
const buyerRatio = Number(row.buyer_ratio || 0)
const publishRatio = Number(assetSummary.value?.publish_ratio || 0)
return {
recycleRatio: sellerRatio > 0 ? sellerRatio : publishRatio > 0 ? publishRatio : 0,
sellRatio: buyerRatio > 0 ? buyerRatio : 0,
}
})
const ownerCoinBasePriceCent = computed(() => readPriceBreakdownCent('seller_coin_base_price'))
const ownerLossPriceCent = computed(() => {
const consumableCent = readPriceBreakdownCent('consumable_price')
if (consumableCent !== null) return consumableCent
if (ownerCoinBasePriceCent.value === null || !order.value?.owner_rent_amount_cent) return null
return Math.max(Number(order.value.owner_rent_amount_cent) - ownerCoinBasePriceCent.value, 0)
})
const hasOwnerRentBreakdown = computed(() => ownerCoinBasePriceCent.value !== null)
const fundSplitRows = computed(() => {
if (!order.value) return []
const rows: FundSplitRow[] = []
rows.push(
{ label: '预收纯币原价', amountCent: order.value.pure_coin_original_amount_cent },
{ label: '预收额外物品', amountCent: order.value.extra_item_original_amount_cent }
)
if (Number(order.value.rent_discount_amount_cent || 0) > 0) {
rows.push(
{ label: '原始租金', amountCent: order.value.rent_original_amount_cent },
{
label: `${order.value.renter_growth_level_name || '成长等级'}优惠`,
amountCent: -Number(order.value.rent_discount_amount_cent || 0),
}
)
}
if (order.value.status === 'completed') {
rows.push(
{ label: '最终实际纯币原价', amountCent: order.value.actual_pure_coin_amount_cent },
{
label: '最终实际纯币优惠',
amountCent: -Number(order.value.actual_pure_coin_discount_cent || 0),
},
{ label: '积分计算纯币基数', amountCent: order.value.growth_points_basis_cent }
)
}
rows.push({ label: '租客租金', amountCent: order.value.rent_amount_cent })
if (hasOwnerRentBreakdown.value) {
rows.push(
{ label: '号主纯币价格', amountCent: ownerCoinBasePriceCent.value },
{ label: '号主损耗', amountCent: ownerLossPriceCent.value ?? 0 },
{ label: '号主租金合计', amountCent: order.value.owner_rent_amount_cent }
)
} else {
rows.push({ label: '号主租金', amountCent: order.value.owner_rent_amount_cent })
}
rows.push(
{ label: '平台费用', amountCent: order.value.platform_fee_cent },
{ label: '实付押金', amountCent: order.value.deposit_amount_cent },
{ label: '原始押金', amountCent: order.value.deposit_original_amount_cent },
{ label: '免押额度', amountCent: order.value.deposit_waived_amount_cent }
)
return rows
})
const snapshotSeasonTags = computed(() => {
const tags = currentSnapshot.value?.season_tags
return Array.isArray(tags) ? tags.map(item => String(item)).filter(Boolean) : []
})
const accountSnapshotItems = computed(() => {
const snapshot = currentSnapshot.value
if (!snapshot) return []
return [
{ label: '账号ID', value: displayValue(snapshot.account_id) },
{ label: '账号标题', value: displayValue(snapshot.title) },
{ label: '游戏名称', value: formatGameName(snapshot.game_name, '-') },
{ label: '所在区服', value: displayValue(snapshot.server_region) },
{ label: '登录平台', value: displayValue(snapshot.login_platform) },
{ label: '段位等级', value: displayValue(snapshot.rank_level) },
].filter(item => item.value !== '-')
})
onMounted(loadOrder)
async function loadOrder() {
loading.value = true
try {
order.value = await fetchAdminOrder(String(route.params.id))
handoffRecords.value = await fetchAdminHandoffRecords(String(route.params.id))
paymentRecords.value = (await fetchAdminPayments({ order_id: String(route.params.id) })).items
await loadRefundStatus()
} finally {
loading.value = false
}
}
async function loadRefundStatus() {
try {
refundStatus.value = await adminRefundStatus(Number(route.params.id))
} catch {
refundStatus.value = null
}
}
function openAction(type: Exclude<OrderActionType, ''>) {
actionType.value = type
reason.value = ''
}
async function confirmCloseAction() {
if (!order.value || !canOperate.value) return
try {
await ElMessageBox.confirm(
'客服关闭会终止订单,归档关联商品和账号;若订单已支付,将发起原路退款。若押金已暂扣,押金部分会继续挂起。确认继续?',
'确认客服关闭',
{
confirmButtonText: '继续关闭',
cancelButtonText: '取消',
type: 'warning',
}
)
} catch {
return
}
openAction('close')
}
async function confirmSealAction() {
if (!order.value || !canOperate.value) return
try {
await ElMessageBox.confirm(
'封存会终止订单并发起原路退款,自动解散关联群聊,关联商品将被永久封存。若押金已暂扣,押金部分会继续挂起。确认继续?',
'确认封存订单',
{
confirmButtonText: '继续封存',
cancelButtonText: '取消',
type: 'warning',
}
)
} catch {
return
}
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
try {
if (actionType.value === 'close') {
await adminCloseOrder(order.value.id, reason.value)
ElMessage.success('订单已关闭')
} else if (actionType.value === 'seal') {
await adminSealOrder(order.value.id, reason.value)
ElMessage.success('订单已封存,群聊已解散')
} else if (actionType.value === 'reset') {
await adminResetHandoff(order.value.id, reason.value)
ElMessage.success(`${resetActionLabel.value}成功`)
} else if (actionType.value === 'platform_checkout_confirm') {
await adminPlatformCheckoutConfirm(order.value.id, reason.value)
ElMessage.success('结账已确认')
} else if (actionType.value === 'platform_checkout_dispute') {
await adminPlatformCheckoutDispute(order.value.id, reason.value)
ElMessage.success('已发起结账争议')
} 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('订单已标记异常')
}
actionType.value = ''
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '操作失败'))
} finally {
submitting.value = false
}
}
function openPlatformHandoff() {
platformHandoffContent.value = ''
platformHandoffReason.value = ''
platformHandoffVisible.value = true
}
async function submitPlatformHandoff() {
if (!order.value) return
if (!platformHandoffContent.value.trim() || !platformHandoffReason.value.trim()) {
ElMessage.warning('请填写交接说明和操作原因')
return
}
platformSubmitting.value = true
try {
await adminPlatformHandoff(
order.value.id,
platformHandoffContent.value.trim(),
platformHandoffReason.value.trim()
)
ElMessage.success('客服代交接已提交')
platformHandoffVisible.value = false
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '代交接失败'))
} finally {
platformSubmitting.value = false
}
}
function openForceHandoff() {
forceHandoffContent.value = ''
forceHandoffReason.value = ''
forceHandoffVisible.value = true
}
async function submitForceHandoff() {
if (!order.value) return
if (!forceHandoffReason.value.trim()) {
ElMessage.warning('请填写客服操作原因')
return
}
if (forceHandoffNeedsContent.value && !forceHandoffContent.value.trim()) {
ElMessage.warning('当前尚无交接说明,请填写交接内容')
return
}
platformSubmitting.value = true
try {
await adminForceHandoff(
order.value.id,
forceHandoffContent.value.trim(),
forceHandoffReason.value.trim()
)
ElMessage.success('客服已确认交接,订单进入使用中')
forceHandoffVisible.value = false
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '客服一键交接失败'))
} finally {
platformSubmitting.value = false
}
}
function openPlatformCheckoutCounter() {
const checkout = order.value?.checkout
platformCheckoutCounterForm.value = {
consumableAmountYuan: centToYuan(checkout?.consumable_amount_cent || 0),
coin_consumed_m: Number(checkout?.coin_consumed_m || snapshotHafCoinM.value || 0),
depositDeductAmountYuan: centToYuan(
checkout?.deposit_deduct_amount_cent || checkout?.other_amount_cent || 0
),
reason: '',
evidenceText: Array.isArray(checkout?.evidence_urls) ? checkout.evidence_urls.join('\n') : '',
}
platformCheckoutCounterVisible.value = true
}
async function submitPlatformCheckoutCounter() {
if (!order.value) return
const form = platformCheckoutCounterForm.value
if (!form.reason.trim()) {
ElMessage.warning('请填写修改原因')
return
}
platformSubmitting.value = true
try {
await adminPlatformCheckoutCounter(order.value.id, {
content: form.reason.trim(),
consumableAmountYuan: form.consumableAmountYuan,
coin_consumed_m: form.coin_consumed_m,
otherAmountYuan: form.depositDeductAmountYuan,
depositDeductAmountYuan: form.depositDeductAmountYuan,
reason: form.reason.trim(),
evidence_urls: linesToList(form.evidenceText),
})
ElMessage.success('结账方案已修改,等待租客确认')
platformCheckoutCounterVisible.value = false
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '修改结账方案失败'))
} finally {
platformSubmitting.value = false
}
}
function openAdminDispute() {
adminDisputeForm.value = {
type: ['pending_checkout_confirm', 'pending_checkout_accept'].includes(
order.value?.status || ''
)
? 'checkout_amount'
: 'cannot_login',
targetRole: 'renter',
description: '',
evidenceText: '',
}
adminDisputeVisible.value = true
}
async function submitAdminDispute() {
if (!order.value) return
const form = adminDisputeForm.value
if (!form.description.trim()) {
ElMessage.warning('请填写申诉说明')
return
}
adminDisputeSubmitting.value = true
try {
await adminCreateOrderDispute(order.value.id, {
type: form.type,
target_role: form.targetRole,
description: form.description.trim(),
evidence_urls: linesToList(form.evidenceText),
})
ElMessage.success('申诉已发起,订单进入仲裁流程')
adminDisputeVisible.value = false
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '发起申诉失败'))
} finally {
adminDisputeSubmitting.value = false
}
}
function openOfflineSettlement() {
offlineSettlementRemark.value = ''
offlineSettlementVisible.value = true
}
async function submitOfflineSettlement() {
if (!order.value) return
platformSubmitting.value = true
try {
await adminMarkOfflineSettlement(order.value.id, offlineSettlementRemark.value.trim())
ElMessage.success('线下结算已确认')
offlineSettlementVisible.value = false
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '确认线下结算失败'))
} finally {
platformSubmitting.value = false
}
}
function orderRentedAt() {
return order.value?.rented_at
}
function orderEstimatedEndAt() {
if (!order.value) return undefined
if (order.value.estimated_end_at) return order.value.estimated_end_at
const rentedAt = orderRentedAt()
const durationHours = Number(order.value.estimated_duration_hours || 0)
if (!rentedAt || durationHours <= 0) return undefined
return new Date(new Date(rentedAt).getTime() + durationHours * 60 * 60 * 1000).toISOString()
}
async function handleRefund() {
if (!order.value) return
refunding.value = true
try {
refundStatus.value = await adminRefundOrder(order.value.id)
ElMessage.success('退款已发起')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '退款失败'))
} finally {
refunding.value = false
}
}
async function confirmRefund() {
if (!order.value || refundButtonDisabled.value) return
const hasRefundInProgress = ['pending', 'refunding'].includes(
refundStatus.value?.refund_status || ''
)
const message = hasRefundInProgress
? '当前订单已有退款处理中,继续人工退款可能导致重复全量退款。确认仍要发起?'
: isDepositHeld.value
? '人工退款不会关闭订单或调整商品状态;当前押金已暂扣,本次仅退非暂扣部分。确认继续?'
: '人工退款只会发起原路退款,不会关闭订单或调整商品状态。请确认该订单确实需要补发退款。'
try {
await ElMessageBox.confirm(message, '确认人工退款', {
confirmButtonText: '确认退款',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
await handleRefund()
}
function paymentStatusLabel(status: string) {
const map: Record<string, string> = {
created: '已创建',
paying: '支付中',
paid: '已支付',
refunding: '退款中',
refunded: '已退款',
failed: '失败',
}
return map[status] || status
}
function paymentStatusType(status: string) {
if (['paid', 'refunded'].includes(status)) return 'success'
if (status === 'failed') return 'danger'
if (['paying', 'refunding'].includes(status)) return 'warning'
return 'info'
}
function paymentBizTypeLabel(type: string) {
const map: Record<string, string> = {
order_pay: '订单支付',
checkout_refund: '结账退款',
arbitration_refund: '仲裁退款',
admin_refund: '人工退款',
cancel_refund: '取消退款',
admin_close_refund: '客服关闭退款',
admin_seal_refund: '封存退款',
deposit_refund: '暂扣押金归还',
}
return map[type] || type
}
function moneyCent(value: number | undefined | null) {
return formatCentWithSymbol(Number(value || 0))
}
function readPriceBreakdownCent(key: string) {
const raw = snapshotPriceBreakdown.value?.[key]
if (raw === undefined || raw === null || raw === '') return null
const value = Number(raw)
if (!Number.isFinite(value)) return null
return Math.round(value * 100)
}
function firstQueryValue(value: unknown) {
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : ''
return typeof value === 'string' ? value : ''
}
function formatHandoffRecordType(type: string) {
const typeMap: Record<string, string> = {
owner_handoff: '卖家交接',
platform_handoff: '客服代交接',
renter_checkout: '买家结账',
owner_counter_checkout: '卖家反驳结账',
platform_checkout_counter: '客服修改结账',
renter_confirm_checkout: '买家确认结账',
owner_accept_checkout: '卖家接受结账',
admin_arbitration: '客服仲裁',
platform_checkout_dispute_opened: '客服发起结账争议',
}
return typeMap[type] || type
}
function handoffModeLabel(mode?: string) {
const map: Record<string, string> = {
owner: '号主交接',
platform: '平台代管',
}
return map[mode || 'owner'] || mode || '-'
}
function settlementModeLabel(mode?: string) {
const map: Record<string, string> = {
owner_wallet: '号主钱包',
platform_managed: '线下结算',
}
return map[mode || 'owner_wallet'] || mode || '-'
}
function offlineSettlementStatusLabel(status?: string) {
const map: Record<string, string> = {
none: '无需线下结算',
pending: '待线下结算',
settled: '已线下结算',
}
return map[status || 'none'] || status || '-'
}
function offlineSettlementStatusType(status?: string) {
if (status === 'settled') return 'success'
if (status === 'pending') return 'warning'
return 'info'
}
function orderStatusType(status: string) {
if (['completed', 'renting'].includes(status)) return 'success'
if (['overdue', 'abnormal', 'checkout_disputing'].includes(status)) return 'danger'
if (['pending_payment', 'pending_handoff', 'pending_checkout_confirm'].includes(status)) {
return 'warning'
}
if (['cancelled', 'closed'].includes(status)) return 'info'
return 'primary'
}
function settlementStatusType(status: string) {
if (['settled', 'refunded', 'arbitrated'].includes(status)) return 'success'
if (['disputed', 'frozen'].includes(status)) return 'danger'
if (['pending', 'unsettled'].includes(status)) return 'warning'
return 'info'
}
function refundStatusType(status: string) {
if (status === 'refunded') return 'success'
if (status === 'failed') return 'danger'
if (['pending', 'refunding'].includes(status)) return 'warning'
return 'info'
}
function checkoutStatusLabel(status: string) {
const map: Record<string, string> = {
submitted: '待确认',
countered: '号主已修正',
accepted: '已接受',
disputed: '争议中',
}
return map[status] || settlementStatusLabel(status)
}
function priceRoleLabel(role?: string) {
const map: Record<string, string> = {
renter: '租客视角',
owner: '号主视角',
admin: '客服处理',
}
return map[role || ''] || role || '-'
}
function disputeTypeLabel(type: string) {
const map: Record<string, string> = {
handoff: '交接申诉',
checkout: '结账争议',
order: '订单争议',
cannot_login: '无法登录',
false_description: '描述不符',
account_banned: '账号封禁',
asset_loss: '资产损失',
haf_coin_dispute: '哈夫币争议',
handoff_timeout: '交接超时',
return_timeout: '归还超时',
checkout_amount: '结账金额争议',
checkout_dispute: '结账争议',
}
return map[type] || type || '-'
}
function disputeInitiatorLabel() {
const active = order.value?.active_dispute
if (!active) return '-'
if (active.initiator_type === 'admin') {
return active.initiator_admin_id ? `客服 ID ${active.initiator_admin_id}` : '客服'
}
return `用户 ID ${active.initiator_id}`
}
function userDisplay(phone: string | undefined, id: number) {
return phone ? `${phone} / ID ${id}` : `ID ${id}`
}
function sellerDisplay(row: Order) {
if (row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed') {
return row.managed_admin_id ? `平台代管 / 客服ID ${row.managed_admin_id}` : '平台代管'
}
return userDisplay(row.owner_phone, row.owner_id)
}
function displayValue(value: unknown) {
if (value === undefined || value === null || value === '') return '-'
if (Array.isArray(value))
return (
value
.map(item => String(item))
.filter(Boolean)
.join('、') || '-'
)
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
function quantity(value: unknown) {
return Number(value || 0).toLocaleString('zh-CN', {
maximumFractionDigits: 2,
})
}
function formatRatioNumber(value: number) {
const rounded = Math.round(value * 100) / 100
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(2)
}
function paymentPaidAt(record: AdminPayment) {
return record.paid_at || record.updated_at || record.created_at
}
</script>
<template>
<div class="finance-page-container" v-loading="loading">
<!-- 头部卡片 -->
<header v-if="order" class="page-header-card">
<div class="header-main">
<div class="title-group">
<div class="eyebrow-tag">
<span>ORDER: {{ order.order_no }}</span>
</div>
<h1 class="page-title">订单详情</h1>
<p class="page-subtitle">
商品编号 {{ listingCode }} · {{ order.title }} · {{ order.server_region }} /
{{ order.login_platform }}
</p>
</div>
<div class="toolbar-actions">
<RouterLink :to="returnTarget">
<el-button>{{ returnLabel }}</el-button>
</RouterLink>
<el-button v-if="canResetHandoff" type="success" @click="openAction('reset')">
{{ resetActionLabel }}
</el-button>
<el-button
v-if="canPlatformHandoff"
class="platform-handoff-button"
type="primary"
@click="openPlatformHandoff"
>
{{ platformHandoffAction?.label || '客服代交接' }}
</el-button>
<el-button v-if="canForceHandoff" type="warning" @click="openForceHandoff">
{{ forceHandoffAction?.label || '客服一键交接' }}
</el-button>
<el-button
v-if="canPlatformCheckoutConfirm"
type="primary"
@click="openAction('platform_checkout_confirm')"
>
{{ platformCheckoutConfirmAction?.label || '客服确认结账' }}
</el-button>
<el-button
v-if="canPlatformCheckoutCounter"
type="warning"
plain
@click="openPlatformCheckoutCounter"
>
{{ platformCheckoutCounterAction?.label || '客服修改结账方案' }}
</el-button>
<el-button
v-if="canPlatformCheckoutDispute"
type="warning"
@click="openAction('platform_checkout_dispute')"
>
{{ platformCheckoutDisputeAction?.label || '发起结账争议' }}
</el-button>
<el-button v-if="canOfflineSettlement" type="success" @click="openOfflineSettlement">
{{ offlineSettlementAction?.label || '确认线下结算' }}
</el-button>
<el-button v-if="canAdminCreateDispute" type="warning" plain @click="openAdminDispute">
发起申诉
</el-button>
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
>标记异常</el-button
>
<el-tooltip :content="closeActionTip" placement="top">
<span>
<el-button type="danger" :disabled="!canOperate" @click="confirmCloseAction"
>客服关闭</el-button
>
</span>
</el-tooltip>
<el-tooltip :content="sealActionTip" placement="top">
<span>
<el-button type="danger" plain :disabled="!canOperate" @click="confirmSealAction"
>封存订单</el-button
>
</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
type="primary"
:loading="refunding"
:disabled="refundButtonDisabled"
@click="confirmRefund"
>
{{ refundButtonDisabled ? '已退款' : '人工退款' }}
</el-button>
</span>
</el-tooltip>
</div>
</div>
</header>
<div v-if="order" class="metric-grid dashboard-metrics order-metrics">
<div class="metric-card">
<span>订单状态</span>
<strong>{{ orderStatusLabel(order.status) }}</strong>
<small>{{ orderHandoffStatusLabel(order) }}</small>
</div>
<div class="metric-card">
<span>结算状态</span>
<strong>{{ settlementStatusLabel(order.settlement_status) }}</strong>
<small>更新 {{ formatDateTime(order.updated_at) }}</small>
</div>
<div v-if="isPlatformManaged" class="metric-card">
<span>线下结算</span>
<strong>{{ offlineSettlementStatusLabel(offlineSettlementStatus) }}</strong>
<small>{{ moneyCent(order.offline_settlement_amount_cent) }}</small>
</div>
<div class="metric-card">
<span>订单总额</span>
<strong>{{ moneyCent(orderTotalCent) }}</strong>
<small
>租金 {{ moneyCent(order.rent_amount_cent) }} / 押金
{{ moneyCent(order.deposit_amount_cent) }}</small
>
</div>
<div class="metric-card">
<span>支付状态</span>
<strong>{{ latestPayment ? paymentStatusLabel(latestPayment.status) : '暂无流水' }}</strong>
<small v-if="paidOrderPayment">支付 {{ moneyCent(paidOrderPayment.amount_cent) }}</small>
</div>
<div class="metric-card">
<span>退款状态</span>
<strong>{{ refundStatusLabel(refundStatus?.refund_status || '') }}</strong>
<small v-if="refundStatus?.refund_amount_cent">
{{ moneyCent(refundStatus.refund_amount_cent) }}
</small>
</div>
<div class="metric-card">
<span>预计截止</span>
<strong>{{ formatDateTime(orderEstimatedEndAt(), '未设置') }}</strong>
<small>{{ order.estimated_duration_hours }} 小时</small>
</div>
</div>
<div v-if="order" class="order-detail-grid">
<section class="dashboard-panel detail-panel">
<div class="panel-heading">
<h2>订单概览</h2>
<div class="status-tags">
<el-tag :type="orderStatusType(order.status)" effect="light">
{{ orderStatusLabel(order.status) }}
</el-tag>
<el-tag :type="settlementStatusType(order.settlement_status)" effect="light">
{{ settlementStatusLabel(order.settlement_status) }}
</el-tag>
</div>
</div>
<dl class="detail-list">
<div>
<dt>订单编号</dt>
<dd>{{ order.order_no }}</dd>
</div>
<div>
<dt>订单ID</dt>
<dd>{{ order.id }}</dd>
</div>
<div>
<dt>商品编号</dt>
<dd>{{ listingCode }}</dd>
</div>
<div>
<dt>账号ID</dt>
<dd>{{ order.account_id }}</dd>
</div>
<div>
<dt>游戏区服</dt>
<dd>{{ order.server_region }}</dd>
</div>
<div>
<dt>登录平台</dt>
<dd>{{ order.login_platform }}</dd>
</div>
<div class="wide">
<dt>商品标题</dt>
<dd>{{ order.title }}</dd>
</div>
</dl>
</section>
<section class="dashboard-panel detail-panel">
<div class="panel-heading">
<h2>用户与时间</h2>
</div>
<dl class="detail-list">
<div>
<dt>租客</dt>
<dd>{{ userDisplay(order.renter_phone, order.renter_id) }}</dd>
</div>
<div>
<dt>号主</dt>
<dd>{{ sellerDisplay(order) }}</dd>
</div>
<div>
<dt>创建时间</dt>
<dd>{{ formatDateTime(order.created_at) }}</dd>
</div>
<div>
<dt>支付截止</dt>
<dd>{{ formatDateTime(order.payment_deadline_at, '未设置') }}</dd>
</div>
<div>
<dt>开始租用</dt>
<dd>{{ formatDateTime(orderRentedAt(), '未开始') }}</dd>
</div>
<div>
<dt>预计结束</dt>
<dd>{{ formatDateTime(orderEstimatedEndAt(), '未设置') }}</dd>
</div>
</dl>
</section>
<section v-if="isPlatformManaged" class="dashboard-panel detail-panel">
<div class="panel-heading">
<h2>平台代管</h2>
<el-tag :type="offlineSettlementStatusType(offlineSettlementStatus)" effect="light">
{{ offlineSettlementStatusLabel(offlineSettlementStatus) }}
</el-tag>
</div>
<dl class="detail-list">
<div>
<dt>交接模式</dt>
<dd>{{ handoffModeLabel(order.handoff_mode) }}</dd>
</div>
<div>
<dt>结算模式</dt>
<dd>{{ settlementModeLabel(order.settlement_mode) }}</dd>
</div>
<div>
<dt>负责客服</dt>
<dd>{{ order.managed_admin_id ? `ID ${order.managed_admin_id}` : '-' }}</dd>
</div>
<div>
<dt>待打款金额</dt>
<dd>{{ moneyCent(order.offline_settlement_amount_cent) }}</dd>
</div>
<div v-if="order.offline_settled_by">
<dt>确认客服</dt>
<dd>ID {{ order.offline_settled_by }}</dd>
</div>
<div v-if="order.offline_settled_at">
<dt>确认时间</dt>
<dd>{{ formatDateTime(order.offline_settled_at) }}</dd>
</div>
<div v-if="order.offline_settlement_remark" class="wide">
<dt>线下备注</dt>
<dd>{{ order.offline_settlement_remark }}</dd>
</div>
</dl>
</section>
<section class="dashboard-panel detail-panel">
<div class="panel-heading">
<h2>资金拆分</h2>
<span class="panel-subtitle">{{ priceRoleLabel(order.price_role) }}</span>
</div>
<div class="money-list">
<div v-for="row in fundSplitRows" :key="row.label" class="money-row">
<span>{{ row.label }}</span>
<strong>{{ moneyCent(row.amountCent) }}</strong>
</div>
<div class="money-row total">
<span>订单实付合计</span>
<strong>{{ moneyCent(orderTotalCent) }}</strong>
</div>
</div>
</section>
<section class="dashboard-panel detail-panel">
<div class="panel-heading">
<h2>退款概况</h2>
<el-tag :type="refundStatusType(refundStatus?.refund_status || '')" effect="light">
{{ refundStatusLabel(refundStatus?.refund_status || '') }}
</el-tag>
</div>
<dl class="detail-list">
<div>
<dt>退款金额</dt>
<dd>{{ moneyCent(refundStatus?.refund_amount_cent) }}</dd>
</div>
<div>
<dt>可退上限</dt>
<dd>{{ moneyCent(orderTotalCent) }}</dd>
</div>
<div>
<dt>退款完成</dt>
<dd>{{ formatDateTime(refundStatus?.refunded_at, '未完成') }}</dd>
</div>
<div>
<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>
<section v-if="order.checkout" class="dashboard-panel detail-panel">
<div class="panel-heading">
<h2>结账明细</h2>
<el-tag effect="light">{{ checkoutStatusLabel(order.checkout.status) }}</el-tag>
</div>
<dl class="detail-list">
<div>
<dt>结算租金</dt>
<dd>{{ moneyCent(order.checkout.display_amount_cent) }}</dd>
</div>
<div>
<dt>实际纯币原价</dt>
<dd>{{ moneyCent(order.checkout.pure_coin_amount_cent) }}</dd>
</div>
<div>
<dt>实际纯币优惠</dt>
<dd>{{ moneyCent(order.checkout.pure_coin_discount_cent) }}</dd>
</div>
<div>
<dt>折后纯币金额</dt>
<dd>{{ moneyCent(order.checkout.pure_coin_payable_cent) }}</dd>
</div>
<div>
<dt>退还租客</dt>
<dd>{{ moneyCent(order.checkout.renter_refund_amount_cent) }}</dd>
</div>
<div>
<dt>号主收入</dt>
<dd>{{ moneyCent(order.checkout.owner_income_amount_cent) }}</dd>
</div>
<div>
<dt>押金赔付扣除</dt>
<dd>{{ moneyCent(order.checkout.deposit_deduct_amount_cent) }}</dd>
</div>
<div>
<dt>消耗品金额</dt>
<dd>{{ moneyCent(order.checkout.consumable_amount_cent) }}</dd>
</div>
<div>
<dt>哈夫币消耗</dt>
<dd>{{ quantity(order.checkout.coin_consumed_m) }}M</dd>
</div>
<div>
<dt>哈夫币预计剩余</dt>
<dd v-if="checkoutRemainingHafCoinM >= 0">
{{ quantity(checkoutRemainingHafCoinM) }}M
</dd>
<dd v-else class="danger-text">
打超 {{ quantity(-checkoutRemainingHafCoinM) }}M按比例从押金扣
</dd>
</div>
<div v-if="order.status === 'completed'">
<dt>积分计算基数</dt>
<dd>{{ moneyCent(order.growth_points_basis_cent) }}</dd>
</div>
<div v-if="order.status === 'completed'">
<dt>本单成长积分</dt>
<dd>{{ order.growth_points_awarded || 0 }}</dd>
</div>
<div>
<dt>发起人</dt>
<dd>ID {{ order.checkout.initiated_by }}</dd>
</div>
<div>
<dt>凭证数量</dt>
<dd>{{ order.checkout.evidence_urls?.length || 0 }} </dd>
</div>
<div v-if="order.checkout.content" class="wide">
<dt>结账说明</dt>
<dd>{{ order.checkout.content }}</dd>
</div>
<div v-if="order.checkout.owner_adjustment_reason" class="wide">
<dt>号主修正</dt>
<dd>{{ order.checkout.owner_adjustment_reason }}</dd>
</div>
</dl>
</section>
<section v-if="order.active_dispute" class="dashboard-panel detail-panel">
<div class="panel-heading">
<h2>争议信息</h2>
<el-tag type="warning" effect="light">
{{ disputeStatusLabel(order.active_dispute.status) }}
</el-tag>
</div>
<dl class="detail-list">
<div>
<dt>争议ID</dt>
<dd>{{ order.active_dispute.id }}</dd>
</div>
<div>
<dt>争议类型</dt>
<dd>{{ disputeTypeLabel(order.active_dispute.type) }}</dd>
</div>
<div>
<dt>发起人</dt>
<dd>{{ disputeInitiatorLabel() }}</dd>
</div>
<div>
<dt>当前状态</dt>
<dd>{{ disputeStatusLabel(order.active_dispute.status) }}</dd>
</div>
</dl>
</section>
<section class="dashboard-panel detail-panel order-wide-panel">
<div class="panel-heading">
<h2>支付与退款流水</h2>
<span class="panel-subtitle"> {{ paymentRecords.length }} </span>
</div>
<el-empty v-if="paymentRecords.length === 0" description="暂无支付流水" />
<div v-else class="record-list">
<article v-for="record in paymentRecords" :key="record.id" class="record-item">
<div class="record-main">
<strong>
{{ paymentBizTypeLabel(record.biz_type) }} · {{ moneyCent(record.amount_cent) }}
</strong>
<el-tag :type="paymentStatusType(record.status)" size="small" effect="light">
{{ paymentStatusLabel(record.status) }}
</el-tag>
</div>
<div class="record-meta">
<span>流水号{{ record.payment_no }}</span>
<span>用户{{ record.user_phone || record.user_id }}</span>
<span>渠道{{ record.provider || '-' }}</span>
<span>商户{{ record.merchant_id || '-' }}</span>
<span>三方单号{{ record.third_order_id || '-' }}</span>
<span>渠道单号{{ record.provider_order_id || '-' }}</span>
<span>创建{{ formatDateTime(record.created_at) }}</span>
<span>完成{{ formatDateTime(paymentPaidAt(record), '-') }}</span>
</div>
<p v-if="record.error_message" class="danger-text">
{{ record.error_code }} {{ record.error_message }}
</p>
</article>
</div>
</section>
<section class="dashboard-panel detail-panel order-wide-panel">
<div class="panel-heading">
<h2>交接记录</h2>
<span class="panel-subtitle"> {{ handoffRecords.length }} </span>
</div>
<el-empty v-if="handoffRecords.length === 0" description="暂无交接记录" />
<div v-else class="record-list">
<article v-for="record in handoffRecords" :key="record.id" class="record-item">
<div class="record-main">
<strong>{{ formatHandoffRecordType(record.type) }}</strong>
<span>{{ formatDateTime(record.created_at) }}</span>
</div>
<p>{{ record.content }}</p>
<div class="record-meta">
<span>发送ID {{ record.from_user_id }}</span>
<span>接收ID {{ record.to_user_id }}</span>
<span>租客确认{{ formatDateTime(record.confirmed_by_renter_at, '-') }}</span>
<span>号主确认{{ formatDateTime(record.confirmed_by_owner_at, '-') }}</span>
</div>
</article>
</div>
</section>
<section class="dashboard-panel detail-panel order-wide-panel">
<div class="panel-heading">
<h2>账号快照</h2>
<span class="panel-subtitle">哈夫币 {{ quantity(snapshotHafCoinM) }}M</span>
</div>
<dl v-if="accountSnapshotItems.length" class="detail-list snapshot-detail-list">
<div v-for="item in accountSnapshotItems" :key="item.label">
<dt>{{ item.label }}</dt>
<dd>{{ item.value }}</dd>
</div>
</dl>
<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
>
</div>
<div v-if="snapshotRatio.sellRatio > 0" class="ratio-item">
<span class="ratio-label">售卖比例</span>
<strong class="ratio-value">1:{{ formatRatioNumber(snapshotRatio.sellRatio) }}</strong>
</div>
</div>
<div v-if="snapshotSeasonTags.length" class="snapshot-tags">
<el-tag v-for="tag in snapshotSeasonTags" :key="tag" size="small" effect="plain">
{{ tag }}
</el-tag>
</div>
<div v-if="snapshotResources.length" class="snapshot-resources">
<div v-for="resource in snapshotResources" :key="resource.key" class="resource-item">
<strong>{{ resource.label }}</strong>
<span>{{ quantity(resource.quantity) }} · {{ resource.price || resource.mode }}</span>
</div>
</div>
<pre class="snapshot-json">{{ snapshotText }}</pre>
</section>
</div>
<el-dialog
:model-value="!!actionType"
:title="actionTitle"
width="560px"
@update:model-value="actionType = ''"
>
<div v-if="order" class="dialog-body">
<p>
<strong>{{ order.order_no }}</strong> · 商品编号 {{ listingCode }} · {{ order.title }}
</p>
<el-input
v-model="reason"
type="textarea"
:rows="4"
placeholder="填写客服操作原因,会写入审计日志并通知双方"
/>
</div>
<template #footer>
<el-button @click="actionType = ''">取消</el-button>
<el-button :type="actionConfirmButtonType" :loading="submitting" @click="submitAction">
确认操作
</el-button>
</template>
</el-dialog>
<el-dialog v-model="platformHandoffVisible" title="客服代交接" width="560px" destroy-on-close>
<div v-if="order" class="dialog-body">
<p>
<strong>{{ order.order_no }}</strong> · 商品编号 {{ listingCode }} · {{ order.title }}
</p>
<el-input
v-model="platformHandoffContent"
type="textarea"
:rows="5"
placeholder="填写发给租客的交接说明"
/>
<el-input
v-model="platformHandoffReason"
type="textarea"
:rows="3"
placeholder="填写客服操作原因"
/>
</div>
<template #footer>
<el-button @click="platformHandoffVisible = false">取消</el-button>
<el-button type="primary" :loading="platformSubmitting" @click="submitPlatformHandoff">
提交交接
</el-button>
</template>
</el-dialog>
<el-dialog v-model="forceHandoffVisible" title="客服一键交接" width="560px" destroy-on-close>
<div v-if="order" class="dialog-body">
<p>
<strong>{{ order.order_no }}</strong> · 商品编号 {{ listingCode }} · {{ order.title }}
</p>
<el-alert
title="确认后将跳过号主交接与租客确认步骤,订单会立即进入使用中并开始计算租期。"
type="warning"
:closable="false"
show-icon
/>
<el-input
v-if="forceHandoffNeedsContent"
v-model="forceHandoffContent"
type="textarea"
:rows="5"
placeholder="填写可供租客查看的交接说明"
/>
<el-input
v-model="forceHandoffReason"
type="textarea"
:rows="3"
placeholder="填写客服确认双方已完成交接的原因"
/>
</div>
<template #footer>
<el-button @click="forceHandoffVisible = false">取消</el-button>
<el-button type="warning" :loading="platformSubmitting" @click="submitForceHandoff">
确认一键交接
</el-button>
</template>
</el-dialog>
<el-dialog v-model="adminDisputeVisible" title="发起订单申诉" width="620px" destroy-on-close>
<div v-if="order" class="dialog-body">
<p>
<strong>{{ order.order_no }}</strong> · 商品编号 {{ listingCode }} · {{ order.title }}
</p>
<div class="form-grid">
<label>
<span>申诉类型</span>
<el-select v-model="adminDisputeForm.type" placeholder="选择申诉类型">
<el-option
v-for="item in disputeTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</label>
<label>
<span>被申诉方</span>
<el-select v-model="adminDisputeForm.targetRole" placeholder="选择被申诉方">
<el-option label="租客" value="renter" />
<el-option label="号主" value="owner" />
</el-select>
</label>
</div>
<el-input
v-model="adminDisputeForm.description"
type="textarea"
:rows="4"
placeholder="填写申诉说明,会通知订单双方并进入仲裁中心"
/>
<el-input
v-model="adminDisputeForm.evidenceText"
type="textarea"
:rows="3"
placeholder="证据链接,一行一个,可留空"
/>
</div>
<template #footer>
<el-button @click="adminDisputeVisible = false">取消</el-button>
<el-button type="primary" :loading="adminDisputeSubmitting" @click="submitAdminDispute">
发起申诉
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="platformCheckoutCounterVisible"
title="客服修改结账方案"
width="620px"
destroy-on-close
>
<div v-if="order" class="dialog-body">
<p>
<strong>{{ order.order_no }}</strong> · 当前号主收入
{{ moneyCent(order.checkout?.owner_income_amount_cent) }}
</p>
<div class="form-grid">
<label>
<span>消耗品金额</span>
<el-input-number
v-model="platformCheckoutCounterForm.consumableAmountYuan"
:min="0"
:precision="2"
:step="1"
controls-position="right"
/>
</label>
<label>
<span>哈夫币消耗M</span>
<el-input-number
v-model="platformCheckoutCounterForm.coin_consumed_m"
:min="0"
:precision="2"
:step="1"
controls-position="right"
/>
</label>
<label>
<span>押金赔付扣除</span>
<el-input-number
v-model="platformCheckoutCounterForm.depositDeductAmountYuan"
:min="0"
:precision="2"
:step="1"
controls-position="right"
/>
</label>
</div>
<el-input
v-model="platformCheckoutCounterForm.reason"
type="textarea"
:rows="4"
placeholder="填写修改原因,将同步给租客并写入审计日志"
/>
<el-input
v-model="platformCheckoutCounterForm.evidenceText"
type="textarea"
:rows="3"
placeholder="证据链接,一行一个,可留空"
/>
</div>
<template #footer>
<el-button @click="platformCheckoutCounterVisible = false">取消</el-button>
<el-button
type="primary"
:loading="platformSubmitting"
@click="submitPlatformCheckoutCounter"
>
提交修改
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="offlineSettlementVisible"
title="确认线下结算"
width="520px"
destroy-on-close
>
<div v-if="order" class="dialog-body">
<p>
待打款金额
<strong>{{ moneyCent(order.offline_settlement_amount_cent) }}</strong>
</p>
<el-input
v-model="offlineSettlementRemark"
type="textarea"
:rows="4"
placeholder="填写线下转账备注,可留空"
/>
</div>
<template #footer>
<el-button @click="offlineSettlementVisible = false">取消</el-button>
<el-button type="primary" :loading="platformSubmitting" @click="submitOfflineSettlement">
确认已结算
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.finance-page-container {
display: flex;
flex-direction: column;
gap: 20px;
padding: 0 4px 32px;
}
.page-header-card {
background: #ffffff;
border-radius: 16px;
padding: 24px 28px;
box-shadow:
0 1px 3px rgba(15, 23, 42, 0.04),
0 4px 12px rgba(15, 23, 42, 0.02);
border: 1px solid rgba(226, 232, 240, 0.8);
}
.header-main {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.title-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.eyebrow-tag {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
color: #3b82f6;
background: #eff6ff;
padding: 3px 10px;
border-radius: 20px;
width: fit-content;
}
.page-title {
margin: 0;
font-size: 26px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.5px;
}
.page-subtitle {
margin: 0;
font-size: 13.5px;
color: #64748b;
line-height: 1.5;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.order-metrics {
margin-bottom: 20px;
}
.order-metrics .metric-card strong {
font-size: 20px;
line-height: 1.25;
overflow-wrap: anywhere;
}
.metric-card small {
display: block;
margin-top: 8px;
color: #8f9bba;
font-size: 12px;
line-height: 1.4;
word-break: break-word;
}
.platform-handoff-button {
min-width: 96px;
border-color: #2563eb;
background: #2563eb;
color: #ffffff;
font-weight: 700;
box-shadow: 0 6px 14px rgba(37, 99, 235, 0.18);
}
.platform-handoff-button:hover,
.platform-handoff-button:focus {
border-color: #1d4ed8;
background: #1d4ed8;
color: #ffffff;
}
.platform-handoff-button:active {
border-color: #1e40af;
background: #1e40af;
}
.order-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(360px, 0.9fr);
gap: 20px;
margin-bottom: 28px;
}
.detail-panel {
min-width: 0;
}
.order-wide-panel {
grid-column: 1 / -1;
}
.panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.panel-heading h2 {
margin: 0;
}
.panel-subtitle {
color: #8f9bba;
font-size: 13px;
line-height: 1.6;
text-align: right;
}
.status-tags,
.snapshot-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.detail-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 18px;
margin: 0;
}
.detail-list div {
min-width: 0;
border-top: 1px solid #f0f2f5;
padding-top: 10px;
}
.detail-list .wide {
grid-column: 1 / -1;
}
.detail-list dt {
margin-bottom: 5px;
color: #8f9bba;
font-size: 12px;
font-weight: 600;
}
.detail-list dd {
margin: 0;
color: #1b2559;
font-size: 14px;
font-weight: 700;
line-height: 1.5;
overflow-wrap: anywhere;
}
.money-list {
display: grid;
gap: 8px;
}
.money-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-top: 1px solid #f0f2f5;
padding-top: 9px;
color: #52616f;
font-size: 13px;
}
.money-row strong {
color: #1b2559;
font-size: 15px;
}
.money-row.total {
margin-top: 4px;
border-top-color: #d8e2f0;
color: #1b2559;
font-weight: 700;
}
.money-row.total strong {
font-size: 18px;
}
.record-list {
display: grid;
gap: 12px;
}
.record-item {
display: grid;
gap: 8px;
border: 1px solid #eef2f7;
border-radius: 8px;
background: #fbfcff;
padding: 14px;
}
.record-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.record-main strong {
color: #1b2559;
font-size: 15px;
}
.record-main span {
color: #8f9bba;
font-size: 13px;
}
.record-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 6px 12px;
color: #52616f;
font-size: 12px;
line-height: 1.5;
}
.record-item p {
margin: 0;
color: #52616f;
line-height: 1.6;
white-space: pre-wrap;
}
.snapshot-detail-list {
margin-bottom: 14px;
}
.ratio-row {
display: flex;
gap: 24px;
margin-bottom: 14px;
padding: 12px 14px;
border: 1px solid #eef2f7;
border-radius: 8px;
background: #fbfcff;
}
.ratio-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.ratio-label {
font-size: 12px;
color: #8f9bba;
font-weight: 600;
}
.ratio-value {
font-size: 20px;
font-weight: 700;
color: #1b2559;
}
.snapshot-tags {
justify-content: flex-start;
margin-bottom: 14px;
}
.snapshot-resources {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.resource-item {
display: grid;
gap: 4px;
border: 1px solid #eef2f7;
border-radius: 8px;
background: #fbfcff;
padding: 10px 12px;
}
.resource-item strong {
color: #1b2559;
font-size: 13px;
}
.resource-item span {
color: #52616f;
font-size: 12px;
}
.snapshot-json {
overflow-x: auto;
max-height: 360px;
margin: 0;
border-radius: 8px;
background: #111827;
color: #dbe4ee;
padding: 14px;
line-height: 1.6;
}
.dialog-body {
display: grid;
gap: 12px;
}
.dialog-body p {
margin: 0;
color: #52616f;
line-height: 1.6;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
}
.form-grid label {
display: grid;
gap: 6px;
color: #52616f;
font-size: 13px;
font-weight: 600;
}
.form-grid :deep(.el-input-number) {
width: 100%;
}
@media (max-width: 960px) {
.order-detail-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.panel-heading,
.record-main {
align-items: flex-start;
flex-direction: column;
}
.detail-list {
grid-template-columns: 1fr;
}
.status-tags {
justify-content: flex-start;
}
}
</style>