Files
hfb_sys/frontend/src/views/account/OrderDetailView.vue
T
ymlandClaude Opus 4.7 6fb5de204c 用户消息系统:消息列表、订单群聊、联系对方入口
- 新增消息列表页和聊天详情页,支持 SSE 实时推送
- 订单详情页新增"联系对方"按钮,一键进入订单群聊
- 导航栏新增"消息"入口
- 修复 mobileHost 路径匹配 bug,避免 /m 前缀误匹配
- 优化 dev.sh:提取 mysql 辅助函数、修复空 PID 处理、条件检查依赖

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:46:34 +08:00

726 lines
25 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 { ElMessage } from 'element-plus'
import { ChatDotRound } from '@element-plus/icons-vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchOrderChat } from '@/api/chats'
import { createDispute } from '@/api/disputes'
import { uploadFile } from '@/api/files'
import {
acceptCheckout,
cancelOrder,
confirmCheckout,
confirmReceive,
counterCheckout,
fetchHandoffRecords,
fetchOrder,
payOrder,
submitCheckout,
submitHandoff,
type HandoffRecord,
type Order,
} from '@/api/orders'
import { useSessionStore } from '@/stores/session'
import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const loading = ref(false)
const cancelling = ref(false)
const paying = ref(false)
const handoffing = ref(false)
const confirming = ref(false)
const returning = ref(false)
const completing = ref(false)
const countering = ref(false)
const acceptingCheckout = ref(false)
const rejectingCheckout = ref(false)
const disputing = ref(false)
const uploadingEvidence = ref(false)
const order = ref<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([])
const handoffContent = ref('')
const checkoutForm = ref({
content: '',
consumable_amount: 0,
coin_consumed_m: 0,
other_amount: 0,
evidenceText: '',
})
const resourceUsage = ref<Record<string, number>>({})
const counterForm = ref({
consumable_amount: 0,
coin_consumed_m: 0,
other_amount: 0,
deposit_deduct_amount: 0,
reason: '',
evidenceText: '',
})
const rejectReason = ref('')
const disputeType = ref('cannot_login')
const disputeDescription = ref('')
const disputeEvidenceText = ref('')
const openingChat = ref(false)
const isOwner = computed(() => order.value?.owner_id === session.userId)
const isRenter = computed(() => order.value?.renter_id === session.userId)
const orderAmountLabel = computed(() => (isOwner.value ? '我的租金' : '订单金额'))
const canOpenDispute = computed(() => {
if (!order.value || (!isOwner.value && !isRenter.value)) return false
return !['completed', 'cancelled', 'closed', 'disputing', 'checkout_disputing', 'abnormal'].includes(order.value.status)
})
const isCheckoutDisputeStage = computed(() => {
return !!order.value && ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value.status)
})
const checkoutResources = computed(() => {
const resources = readSnapshotResources()
return resources.filter((item) => item.quantity > 0)
})
const resourceChargeAmount = computed(() => {
return roundMoney(
checkoutResources.value.reduce((sum, item) => {
if (!isChargedResource(item)) return sum
const used = readResourceUsage(item.key)
return sum + used * item.unitPrice
}, 0),
)
})
const snapshotHafCoinM = computed(() => {
const snapshot = readSnapshot()
return roundQuantity(readNumber(snapshot?.haf_coin_amount) / 1000000)
})
const remainingHafCoinM = computed(() => {
return roundQuantity(Math.max(snapshotHafCoinM.value - Number(checkoutForm.value.coin_consumed_m || 0), 0))
})
onMounted(loadOrder)
async function loadOrder() {
loading.value = true
try {
order.value = await fetchOrder(String(route.params.id))
handoffRecords.value = await fetchHandoffRecords(String(route.params.id))
hydrateResourceUsage()
hydrateCounterForm()
} finally {
loading.value = false
}
}
async function handleCancel() {
if (!order.value) return
cancelling.value = true
try {
await cancelOrder(order.value.id)
ElMessage.success('订单已取消,账号已释放')
await router.push('/orders')
} catch (error) {
ElMessage.error(readError(error, '取消失败'))
} finally {
cancelling.value = false
}
}
async function handlePay() {
if (!order.value) return
paying.value = true
try {
await payOrder(order.value.id)
ElMessage.success('支付成功,等待号主交接')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '支付失败'))
} finally {
paying.value = false
}
}
async function handleSubmitHandoff() {
if (!order.value) return
handoffing.value = true
try {
await submitHandoff(order.value.id, handoffContent.value)
handoffContent.value = ''
ElMessage.success('交接说明已提交')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '提交交接失败'))
} finally {
handoffing.value = false
}
}
async function handleConfirmReceive() {
if (!order.value) return
confirming.value = true
try {
await confirmReceive(order.value.id)
ElMessage.success('已确认收号,订单进入使用中')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '确认收号失败'))
} finally {
confirming.value = false
}
}
async function handleSubmitCheckout() {
if (!order.value) return
returning.value = true
try {
const consumableAmount = resourceChargeAmount.value
checkoutForm.value.consumable_amount = consumableAmount
await submitCheckout(order.value.id, {
content: checkoutContentWithSummary(),
consumable_amount: consumableAmount,
coin_consumed_m: checkoutForm.value.coin_consumed_m,
other_amount: checkoutForm.value.other_amount,
evidence_urls: linesToList(checkoutForm.value.evidenceText),
})
checkoutForm.value = {
content: '',
consumable_amount: 0,
coin_consumed_m: 0,
other_amount: 0,
evidenceText: '',
}
resourceUsage.value = {}
ElMessage.success('结账已发起,等待号主确认')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '发起结账失败'))
} finally {
returning.value = false
}
}
async function handleConfirmCheckout() {
if (!order.value) return
completing.value = true
try {
await confirmCheckout(order.value.id)
ElMessage.success('结账已确认,订单完成')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '确认结账失败'))
} finally {
completing.value = false
}
}
async function handleCounterCheckout() {
if (!order.value) return
countering.value = true
try {
await counterCheckout(order.value.id, {
consumable_amount: counterForm.value.consumable_amount,
coin_consumed_m: counterForm.value.coin_consumed_m,
other_amount: counterForm.value.other_amount,
deposit_deduct_amount: counterForm.value.deposit_deduct_amount,
reason: counterForm.value.reason,
evidence_urls: linesToList(counterForm.value.evidenceText),
})
ElMessage.success('结账修正已提交,等待租客确认')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '修改结账失败'))
} finally {
countering.value = false
}
}
async function handleAcceptCheckout() {
if (!order.value) return
acceptingCheckout.value = true
try {
await acceptCheckout(order.value.id)
ElMessage.success('已确认修正结账,订单完成')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '确认修正失败'))
} finally {
acceptingCheckout.value = false
}
}
async function handleRejectCheckout() {
if (!order.value) return
rejectingCheckout.value = true
try {
await createDispute(order.value.id, {
type: 'checkout_dispute',
description: rejectReason.value,
evidence_urls: [],
})
rejectReason.value = ''
ElMessage.success('已拒绝修正结账,订单进入争议处理')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '拒绝修正失败'))
} finally {
rejectingCheckout.value = false
}
}
async function handleCreateDispute() {
if (!order.value) return
disputing.value = true
try {
const evidence_urls = linesToList(disputeEvidenceText.value)
await createDispute(order.value.id, {
type: isCheckoutDisputeStage.value ? 'checkout_dispute' : disputeType.value,
description: disputeDescription.value,
evidence_urls,
})
disputeDescription.value = ''
disputeEvidenceText.value = ''
ElMessage.success('申诉已提交,订单进入仲裁处理')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '提交申诉失败'))
} finally {
disputing.value = false
}
}
async function handleEvidenceUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
uploadingEvidence.value = true
try {
const uploaded = await uploadFile(file, 'dispute')
disputeEvidenceText.value = [disputeEvidenceText.value, uploaded.url].filter(Boolean).join('\n')
ElMessage.success('证据文件已上传')
} catch (error) {
ElMessage.error(readError(error, '上传失败'))
} finally {
uploadingEvidence.value = false
}
}
async function openOrderChat() {
if (!order.value || openingChat.value) return
openingChat.value = true
try {
const chat = await fetchOrderChat(order.value.id)
await router.push(`/messages/${chat.id}`)
} catch {
ElMessage.error('订单群聊暂不可用')
} finally {
openingChat.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
function orderRentedAt() {
return order.value?.rented_at
}
function orderEstimatedEndAt() {
if (!order.value) return undefined
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()
}
interface CheckoutResource {
key: string
label: string
price: string
mode: string
quantity: number
unitPrice: number
}
function readSnapshot() {
const snapshot = order.value?.account_snapshot
if (isRecord(snapshot)) return snapshot
return null
}
function readAssetSummary() {
const summary = readSnapshot()?.asset_summary
if (isRecord(summary)) return summary
return null
}
function readSnapshotResources(): CheckoutResource[] {
const resources = readAssetSummary()?.resources
if (!Array.isArray(resources)) return []
return resources
.filter(isRecord)
.map((item) => {
const key = String(item.key || item.label || '')
const label = String(item.label || key || '额外消耗品')
const price = String(item.price || '')
return {
key,
label,
price,
mode: String(item.mode || '收费'),
quantity: readNumber(item.quantity),
unitPrice: readUnitPrice(price),
}
})
.filter((item) => item.key && item.quantity > 0)
}
function hydrateResourceUsage() {
const next: Record<string, number> = {}
for (const item of checkoutResources.value) {
next[item.key] = Math.min(Math.max(Number(resourceUsage.value[item.key] || 0), 0), item.quantity)
}
resourceUsage.value = next
}
function readResourceUsage(key: string) {
return Math.max(Number(resourceUsage.value[key] || 0), 0)
}
function isChargedResource(item: CheckoutResource) {
return item.mode !== '赠送'
}
function resourceLineAmount(item: CheckoutResource) {
return roundMoney(readResourceUsage(item.key) * item.unitPrice)
}
function checkoutContentWithSummary() {
const lines = [checkoutForm.value.content.trim()].filter(Boolean)
const usedResources = checkoutResources.value.filter((item) => readResourceUsage(item.key) > 0)
if (usedResources.length) {
lines.push(
`额外消耗品:${usedResources
.map((item) => `${item.label} ${readResourceUsage(item.key)}/${item.quantity}${isChargedResource(item) ? `,金额¥${money(resourceLineAmount(item))}` : ',赠送不扣款'}`)
.join('')}`,
)
}
if (Number(checkoutForm.value.coin_consumed_m || 0) > 0) {
lines.push(`哈夫币消耗:${quantity(Number(checkoutForm.value.coin_consumed_m))}M,预计剩余${quantity(remainingHafCoinM.value)}M`)
}
if (lines.length === 0) {
lines.push('租客发起结账。')
}
return lines.join('\n')
}
function readUnitPrice(priceText: string) {
const normalized = priceText.replace(//g, ',').trim()
const fractionMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元\s*\/\s*(\d+(?:\.\d+)?)/)
if (fractionMatch) {
const amount = Number(fractionMatch[1])
const count = Number(fractionMatch[2])
return count > 0 ? roundMoney(amount / count) : 0
}
const singleMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元/)
if (singleMatch) return Number(singleMatch[1])
const fallback = normalized.match(/(\d+(?:\.\d+)?)/)
return fallback ? Number(fallback[1]) : 0
}
function roundMoney(value: number) {
return Math.round(value)
}
function roundQuantity(value: number) {
return Math.round(value * 100) / 100
}
function money(value: unknown) {
return `${roundMoney(readNumber(value))}`
}
function quantity(value: unknown) {
const rounded = roundQuantity(readNumber(value))
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(2)
}
function readNumber(value: unknown) {
const number = Number(value || 0)
return Number.isFinite(number) ? number : 0
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function hydrateCounterForm() {
if (!order.value?.checkout) return
const checkout = order.value.checkout
counterForm.value.consumable_amount = checkout.consumable_amount
counterForm.value.coin_consumed_m = checkout.coin_consumed_m
counterForm.value.other_amount = checkout.other_amount
counterForm.value.deposit_deduct_amount = checkout.deposit_deduct_amount
}
function linesToList(value: string) {
return value
.split('\n')
.map((item) => item.trim())
.filter(Boolean)
}
</script>
<template>
<section class="page" v-loading="loading">
<div v-if="order" class="page-header">
<p class="eyebrow">{{ order.order_no }}</p>
<h1>订单详情</h1>
<div class="header-actions">
<p>{{ order.title }} · {{ order.server_region }} / {{ order.login_platform }}</p>
<el-button type="primary" :loading="openingChat" @click="openOrderChat">
<el-icon style="margin-right: 4px;"><ChatDotRound /></el-icon>
联系对方
</el-button>
</div>
</div>
<div v-if="order" class="detail-grid">
<div class="metric-card">
<span>状态</span>
<strong>{{ orderStatusLabel(order.status) }}</strong>
</div>
<div class="metric-card">
<span>交接</span>
<strong>{{ handoffStatusLabel(order.handoff_status) }}</strong>
</div>
<div class="metric-card">
<span>{{ orderAmountLabel }}</span>
<strong>¥{{ money(order.display_amount) }}</strong>
</div>
<div class="metric-card">
<span>押金</span>
<strong>¥{{ money(order.deposit_amount) }}</strong>
</div>
</div>
<div v-if="order" class="order-panel">
<p>开始{{ formatDateTime(orderRentedAt(), '未开始') }}</p>
<p>预计截止{{ formatDateTime(orderEstimatedEndAt(), '未设置') }}</p>
<p v-if="order.status === 'pending_payment'">订单待支付支付后账号进入交接流程</p>
<el-button v-if="order.status === 'pending_payment' && isRenter" type="primary" :loading="paying" @click="handlePay">
支付订单
</el-button>
<el-button v-if="['pending_payment', 'pending_handoff'].includes(order.status)" type="danger" :loading="cancelling" @click="handleCancel">
取消订单并释放账号
</el-button>
</div>
<div v-if="order" class="order-panel">
<h2>交接记录</h2>
<el-empty v-if="handoffRecords.length === 0" description="暂无交接记录" />
<div v-for="record in handoffRecords" :key="record.id" class="timeline-item">
<strong>{{ record.type }}</strong>
<p>{{ record.content }}</p>
<span>{{ formatDateTime(record.created_at) }}</span>
</div>
</div>
<div v-if="order && isOwner && order.status === 'pending_handoff' && order.handoff_status === 'pending_owner'" class="order-panel">
<h2>提交交接说明</h2>
<el-input v-model="handoffContent" type="textarea" :rows="4" placeholder="填写登录方式、注意事项和交接说明" />
<el-button class="panel-action" type="primary" :loading="handoffing" @click="handleSubmitHandoff">提交交接</el-button>
</div>
<div
v-if="order && isRenter && order.status === 'pending_handoff' && order.handoff_status === 'pending_renter_confirm'"
class="order-panel"
>
<h2>确认收号</h2>
<p>确认账号可以正常登录后订单会进入使用中并重新计算预计截止时间</p>
<el-button type="primary" :loading="confirming" @click="handleConfirmReceive">确认已收到账号</el-button>
</div>
<div v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)" class="order-panel">
<h2>发起结账</h2>
<el-input v-model="checkoutForm.content" type="textarea" :rows="4" placeholder="填写结账说明、租后资产状态或注意事项" />
<div class="checkout-resource-panel panel-action">
<div class="checkout-resource-head">
<strong>额外消耗品</strong>
<span>金额合计:¥{{ money(resourceChargeAmount) }}</span>
</div>
<el-empty v-if="checkoutResources.length === 0" description="订单快照中暂无额外消耗品" />
<div v-for="item in checkoutResources" 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
v-model="resourceUsage[item.key]"
:min="0"
:max="item.quantity"
:precision="0"
controls-position="right"
/>
<span class="checkout-resource-amount">¥{{ money(resourceLineAmount(item)) }}</span>
</div>
</div>
<el-form class="form-grid panel-action" label-position="top">
<el-form-item label="消耗哈夫币(M">
<el-input-number v-model="checkoutForm.coin_consumed_m" class="full-control" :min="0" :max="snapshotHafCoinM" :precision="2" controls-position="right" />
<span class="field-hint">订单快照 {{ quantity(snapshotHafCoinM) }}M预计剩余 {{ quantity(remainingHafCoinM) }}M</span>
</el-form-item>
<el-form-item label="其他扣款(元)">
<el-input-number v-model="checkoutForm.other_amount" class="full-control" :min="0" :precision="0" controls-position="right" />
</el-form-item>
</el-form>
<el-input
v-model="checkoutForm.evidenceText"
class="panel-action"
type="textarea"
:rows="3"
placeholder="结账证据链接,一行一个。可填写截图地址或备注链接"
/>
<el-button class="panel-action" type="primary" :loading="returning" @click="handleSubmitCheckout">发起结账</el-button>
</div>
<div v-if="order && order.checkout && ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.status)" class="order-panel">
<h2>结账明细</h2>
<p>{{ orderAmountLabel }}:¥{{ money(order.checkout.display_amount) }}押金:¥{{ money(order.checkout.deposit_amount) }}</p>
<p>额外消耗品金额:¥{{ money(order.checkout.consumable_amount) }}押金扣除:¥{{ money(order.checkout.deposit_deduct_amount) }}</p>
<p v-if="isRenter">退还租客:¥{{ money(order.checkout.renter_refund_amount) }}</p>
<p v-if="isOwner">号主收入:¥{{ money(order.checkout.owner_income_amount) }}</p>
<p v-if="order.checkout.content">说明{{ order.checkout.content }}</p>
<p v-if="order.checkout.owner_adjustment_reason">修正原因{{ order.checkout.owner_adjustment_reason }}</p>
</div>
<div v-if="order && isOwner && order.status === 'pending_checkout_confirm'" class="order-panel">
<h2>确认结账</h2>
<p>确认账号状态和扣款金额无误后订单会完成账号重新上架</p>
<el-button type="primary" :loading="completing" @click="handleConfirmCheckout">确认结账并完成订单</el-button>
<h2 class="panel-action">修改结账</h2>
<el-form class="form-grid" label-position="top">
<el-form-item label="额外消耗品金额(已含租金)">
<el-input-number v-model="counterForm.consumable_amount" class="full-control" :min="0" :precision="0" controls-position="right" />
</el-form-item>
<el-form-item label="消耗哈夫币(M">
<el-input-number v-model="counterForm.coin_consumed_m" class="full-control" :min="0" :precision="2" controls-position="right" />
</el-form-item>
<el-form-item label="其他扣款(元)">
<el-input-number v-model="counterForm.other_amount" class="full-control" :min="0" :precision="0" controls-position="right" />
</el-form-item>
<el-form-item label="押金扣除(元)">
<el-input-number v-model="counterForm.deposit_deduct_amount" class="full-control" :min="0" :max="order.deposit_amount" :precision="0" controls-position="right" />
</el-form-item>
</el-form>
<el-input v-model="counterForm.reason" class="panel-action" type="textarea" :rows="3" placeholder="填写修改原因" />
<el-input v-model="counterForm.evidenceText" class="panel-action" type="textarea" :rows="3" placeholder="修正证据链接,一行一个" />
<el-button class="panel-action" type="warning" :loading="countering" @click="handleCounterCheckout">提交修正给租客确认</el-button>
</div>
<div v-if="order && isRenter && order.status === 'pending_checkout_accept'" class="order-panel">
<h2>确认修正结账</h2>
<p>同意后订单会完成结算不同意会进入争议由客服仲裁</p>
<el-button type="primary" :loading="acceptingCheckout" @click="handleAcceptCheckout">同意修正并完成订单</el-button>
<el-input v-model="rejectReason" class="panel-action" type="textarea" :rows="3" placeholder="不同意时填写原因,会进入争议处理" />
<el-button class="panel-action" type="danger" :loading="rejectingCheckout" @click="handleRejectCheckout">拒绝修正并发起争议</el-button>
</div>
<div v-if="order && canOpenDispute" class="order-panel">
<h2>{{ isCheckoutDisputeStage ? '发起结账争议' : '发起申诉' }}</h2>
<el-select v-if="!isCheckoutDisputeStage" v-model="disputeType" class="full-control" placeholder="选择申诉类型">
<el-option label="无法登录" value="cannot_login" />
<el-option label="虚假描述" value="false_description" />
<el-option label="账号被封" value="account_banned" />
<el-option label="资产损失" value="asset_loss" />
<el-option label="哈夫币争议" value="haf_coin_dispute" />
<el-option label="超时未交接" value="handoff_timeout" />
<el-option label="超时未归还" value="return_timeout" />
</el-select>
<el-input
v-model="disputeDescription"
class="panel-action"
type="textarea"
:rows="4"
placeholder="说明争议经过、时间点和希望客服核查的证据"
/>
<el-input
v-model="disputeEvidenceText"
class="panel-action"
type="textarea"
:rows="3"
placeholder="证据链接,一行一个。开发阶段可先填截图地址或备注链接"
/>
<div class="panel-action upload-line">
<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf" :disabled="uploadingEvidence" @change="handleEvidenceUpload" />
</div>
<el-button class="panel-action" type="warning" :loading="disputing" @click="handleCreateDispute">
{{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }}
</el-button>
</div>
</section>
</template>
<style scoped>
.header-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.header-actions p {
margin: 0;
}
.checkout-resource-panel {
display: grid;
gap: 10px;
}
.checkout-resource-head,
.checkout-resource-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 12px;
align-items: center;
}
.checkout-resource-head {
color: #1f2d3d;
}
.checkout-resource-head span,
.checkout-resource-meta span,
.field-hint {
color: #6b7785;
font-size: 13px;
}
.checkout-resource-row {
padding: 10px 0;
border-top: 1px solid #eef1f5;
}
.checkout-resource-meta {
display: grid;
gap: 4px;
}
.checkout-resource-amount {
min-width: 72px;
text-align: right;
color: #ff6a00;
font-weight: 700;
}
.field-hint {
display: block;
margin-top: 6px;
}
</style>