优化申诉仲裁流程和证据展示
This commit is contained in:
@@ -5,7 +5,13 @@ import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/features/disputes'
|
||||
import { fetchAdminFileBlob } from '@/shared/api/files'
|
||||
import { disputeStatusLabel } from '@/shared/utils/statusLabels'
|
||||
import AuthImage from '@/shared/components/business/AuthImage.vue'
|
||||
import {
|
||||
disputeStatusLabel,
|
||||
handoffStatusLabel,
|
||||
orderStatusLabel,
|
||||
settlementStatusLabel,
|
||||
} from '@/shared/utils/statusLabels'
|
||||
import { formatDateTime } from '@/shared/utils/time'
|
||||
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
||||
import { yuanToCent } from '@/shared/utils/money'
|
||||
@@ -22,13 +28,87 @@ const amount = ref<number | undefined>()
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const statusFilter = ref('')
|
||||
const typeFilter = ref('')
|
||||
const isPartialRefund = computed(() => result.value === 'partial_refund')
|
||||
const filteredDisputes = computed(() => {
|
||||
return disputes.value.filter(item => {
|
||||
if (statusFilter.value && item.status !== statusFilter.value) return false
|
||||
if (typeFilter.value && item.type !== typeFilter.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
const pendingCount = computed(
|
||||
() => disputes.value.filter(item => ['open', 'processing'].includes(item.status)).length
|
||||
)
|
||||
const resolvedCount = computed(() => disputes.value.filter(item => item.status === 'resolved').length)
|
||||
const cancelledCount = computed(
|
||||
() => disputes.value.filter(item => item.status === 'cancelled').length
|
||||
)
|
||||
const canSubmitArbitration = computed(() => {
|
||||
if (submitting.value || !activeDispute.value) return false
|
||||
if (!result.value || !remark.value.trim()) return false
|
||||
if (isPartialRefund.value && (!amount.value || amount.value <= 0)) return false
|
||||
return true
|
||||
})
|
||||
function canArbitrate(row: Dispute) {
|
||||
return ['open', 'processing'].includes(row.status)
|
||||
}
|
||||
|
||||
function disputeTypeLabel(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
cannot_login: '无法登录',
|
||||
false_description: '描述不符',
|
||||
account_banned: '账号封禁',
|
||||
asset_loss: '资产损失',
|
||||
haf_coin_dispute: '哈夫币争议',
|
||||
handoff_timeout: '交接超时',
|
||||
return_timeout: '归还超时',
|
||||
checkout_amount: '结账金额争议',
|
||||
checkout_dispute: '结账争议',
|
||||
}
|
||||
return map[type] || type || '-'
|
||||
}
|
||||
|
||||
function arbitrationResultLabel(value: string) {
|
||||
const map: Record<string, string> = {
|
||||
full_refund: '全额退款',
|
||||
partial_refund: '部分退款',
|
||||
deduct_deposit: '扣押金',
|
||||
release_deposit: '释放押金',
|
||||
compensate_owner: '赔付号主',
|
||||
order_close: '关闭订单',
|
||||
mark_abnormal: '标记异常',
|
||||
}
|
||||
return map[value] || value || '-'
|
||||
}
|
||||
|
||||
function disputeStatusTone(status: string) {
|
||||
const map: Record<string, 'success' | 'warning' | 'info' | 'danger'> = {
|
||||
open: 'warning',
|
||||
processing: 'warning',
|
||||
resolved: 'success',
|
||||
closed: 'info',
|
||||
cancelled: 'info',
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
function roleLabel(row: Dispute, userID: number) {
|
||||
if (userID === row.owner_id) return '号主'
|
||||
if (userID === row.renter_id) return '租客'
|
||||
return '用户'
|
||||
}
|
||||
|
||||
function userPhone(row: Dispute, userID: number) {
|
||||
if (userID === row.owner_id) return row.owner_phone || '-'
|
||||
if (userID === row.renter_id) return row.renter_phone || '-'
|
||||
return '-'
|
||||
}
|
||||
|
||||
function evidenceCount(row: Dispute) {
|
||||
return evidenceItems(row).length
|
||||
}
|
||||
|
||||
onMounted(loadDisputes)
|
||||
|
||||
@@ -48,12 +128,18 @@ async function handlePageChange() {
|
||||
}
|
||||
|
||||
function openArbitration(row: Dispute) {
|
||||
if (!canArbitrate(row)) return
|
||||
activeDispute.value = row
|
||||
result.value = row.arbitration_result || 'release_deposit'
|
||||
remark.value = row.arbitration_remark || ''
|
||||
amount.value = undefined
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
statusFilter.value = ''
|
||||
typeFilter.value = ''
|
||||
}
|
||||
|
||||
function evidenceItems(row: Dispute | null) {
|
||||
const raw = row?.evidence_urls
|
||||
if (!raw) return []
|
||||
@@ -70,20 +156,97 @@ function extractObjectKey(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function isObjectKeyLike(value: string) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed || trimmed.includes('\n')) return false
|
||||
if (/^(https?:|blob:|data:|mailto:|tel:)/i.test(trimmed) || trimmed.startsWith('/')) return false
|
||||
return /^[a-z0-9][a-z0-9/_-]*\/[^?#]+\.[a-z0-9]{2,8}$/i.test(trimmed)
|
||||
}
|
||||
|
||||
function evidenceObjectKey(value: string) {
|
||||
return extractObjectKey(value) || (isObjectKeyLike(value) ? value.trim() : '')
|
||||
}
|
||||
|
||||
function evidenceSource(value: string) {
|
||||
const key = evidenceObjectKey(value)
|
||||
if (key && isObjectKeyLike(value)) {
|
||||
return `/api/admin/files/object?key=${encodeURIComponent(key)}`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function evidenceName(value: string, index: number) {
|
||||
const key = evidenceObjectKey(value)
|
||||
const source = key || value
|
||||
try {
|
||||
const parsed = new URL(source, window.location.origin)
|
||||
const part = decodeURIComponent(parsed.pathname.split('/').filter(Boolean).pop() || '')
|
||||
if (part) return part
|
||||
} catch {
|
||||
// 非 URL 文本证据直接走下面的普通解析。
|
||||
}
|
||||
const part = decodeURIComponent(source.split('/').filter(Boolean).pop() || '')
|
||||
return part || `证据 ${index + 1}`
|
||||
}
|
||||
|
||||
function evidenceDetail(value: string) {
|
||||
const key = evidenceObjectKey(value)
|
||||
if (key) return decodeURIComponent(key)
|
||||
if (value.startsWith('/api/files/object') || value.startsWith('/api/admin/files/object')) return value
|
||||
try {
|
||||
const parsed = new URL(value, window.location.origin)
|
||||
return parsed.href
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function evidenceKind(value: string) {
|
||||
const name = evidenceName(value, 0).toLowerCase()
|
||||
if (/\.(png|jpe?g|webp|gif|bmp|avif)$/.test(name)) return 'image'
|
||||
if (name.endsWith('.pdf')) return 'pdf'
|
||||
if (value.startsWith('http') || value.startsWith('/api/')) return 'link'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
function evidenceKindLabel(value: string) {
|
||||
const map: Record<string, string> = {
|
||||
image: '图片',
|
||||
pdf: 'PDF',
|
||||
link: '链接',
|
||||
text: '文本',
|
||||
}
|
||||
return map[evidenceKind(value)] || '证据'
|
||||
}
|
||||
|
||||
async function copyEvidence(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(evidenceDetail(value))
|
||||
ElMessage.success('证据信息已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function openEvidence(url: string) {
|
||||
const key = extractObjectKey(url)
|
||||
if (!key) {
|
||||
const objectKey = key || (isObjectKeyLike(url) ? url.trim() : '')
|
||||
if (objectKey) {
|
||||
try {
|
||||
const blob = await fetchAdminFileBlob(objectKey)
|
||||
const objectURL = URL.createObjectURL(blob)
|
||||
window.open(objectURL, '_blank')
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectURL), 60_000)
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '证据文件打开失败'))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (/^https?:\/\//i.test(url) || url.startsWith('/api/')) {
|
||||
window.open(url, '_blank')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const blob = await fetchAdminFileBlob(key)
|
||||
const objectURL = URL.createObjectURL(blob)
|
||||
window.open(objectURL, '_blank')
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectURL), 60_000)
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '证据文件打开失败'))
|
||||
}
|
||||
await copyEvidence(url)
|
||||
}
|
||||
|
||||
async function handleArbitrate() {
|
||||
@@ -119,32 +282,104 @@ async function handleArbitrate() {
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Arbitration</p>
|
||||
<h1>仲裁中心</h1>
|
||||
<p>处理无法登录、资产损失、哈夫币争议和结账争议。</p>
|
||||
<div class="page-header-row">
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">申诉仲裁</p>
|
||||
<h1>仲裁中心</h1>
|
||||
<p>集中处理订单申诉、结账争议、证据查看和客服裁决。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="disputes">
|
||||
<div class="dispute-summary">
|
||||
<div class="summary-item">
|
||||
<span>当前页待处理</span>
|
||||
<strong>{{ pendingCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>当前页已处理</span>
|
||||
<strong>{{ resolvedCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>当前页已取消</span>
|
||||
<strong>{{ cancelledCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>当前页合计</span>
|
||||
<strong>{{ disputes.length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-panel">
|
||||
<el-select v-model="statusFilter" clearable placeholder="全部状态" class="filter-control">
|
||||
<el-option label="待处理" value="open" />
|
||||
<el-option label="处理中" value="processing" />
|
||||
<el-option label="已处理" value="resolved" />
|
||||
<el-option label="已关闭" value="closed" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
</el-select>
|
||||
<el-select v-model="typeFilter" clearable placeholder="全部类型" class="filter-control">
|
||||
<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-option label="结账争议" value="checkout_dispute" />
|
||||
</el-select>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel dispute-table" :data="filteredDisputes">
|
||||
<el-table-column label="商品编号" width="140">
|
||||
<template #default="{ row }">{{ formatListingNo(row.listing_no) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="order_no" label="订单号" min-width="210" />
|
||||
<el-table-column prop="title" label="账号" min-width="160" />
|
||||
<el-table-column prop="type" label="类型" width="150" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">{{ disputeStatusLabel(row.status) }}</template>
|
||||
<el-table-column label="账号信息" min-width="190" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="main-cell">
|
||||
<strong>{{ row.title }}</strong>
|
||||
<span>{{ orderStatusLabel(row.order_status) }} · {{ handoffStatusLabel(row.handoff_status) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申诉类型" width="140">
|
||||
<template #default="{ row }">{{ disputeTypeLabel(row.type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申诉状态" width="115">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="disputeStatusTone(row.status)" effect="light">
|
||||
{{ disputeStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="双方信息" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<div class="party-cell">
|
||||
<span>申诉方:{{ roleLabel(row, row.initiator_id) }} {{ userPhone(row, row.initiator_id) }}</span>
|
||||
<span>被申诉方:{{ roleLabel(row, row.target_user_id) }} {{ userPhone(row, row.target_user_id) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申诉说明" min-width="240" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.description || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="证据" width="90">
|
||||
<template #default="{ row }">{{ evidenceCount(row) }} 份</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="arbitration_result"
|
||||
label="仲裁结果"
|
||||
width="150"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="操作" width="160">
|
||||
<el-table-column label="结算状态" width="115">
|
||||
<template #default="{ row }">{{ settlementStatusLabel(row.settlement_status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="仲裁结果" width="135" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ arbitrationResultLabel(row.arbitration_result) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处理时间" min-width="180">
|
||||
<template #default="{ row }">{{ row.handled_at ? formatDateTime(row.handled_at) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
@@ -154,7 +389,7 @@ async function handleArbitrate() {
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
:disabled="row.status === 'resolved'"
|
||||
:disabled="!canArbitrate(row)"
|
||||
@click="openArbitration(row)"
|
||||
>仲裁</el-button
|
||||
>
|
||||
@@ -182,6 +417,11 @@ async function handleArbitrate() {
|
||||
<strong>{{ activeDispute.order_no }}</strong> · 商品编号
|
||||
{{ formatListingNo(activeDispute.listing_no) }} · {{ activeDispute.title }}
|
||||
</p>
|
||||
<p>
|
||||
{{ disputeTypeLabel(activeDispute.type) }} ·
|
||||
{{ disputeStatusLabel(activeDispute.status) }} ·
|
||||
{{ orderStatusLabel(activeDispute.order_status) }}
|
||||
</p>
|
||||
<p>{{ activeDispute.description }}</p>
|
||||
<el-select v-model="result" class="full-control" placeholder="选择裁决结果">
|
||||
<el-option label="全额退款" value="full_refund" />
|
||||
@@ -230,17 +470,81 @@ async function handleArbitrate() {
|
||||
<el-dialog
|
||||
:model-value="!!evidenceDispute"
|
||||
title="申诉证据"
|
||||
width="640px"
|
||||
width="760px"
|
||||
@update:model-value="evidenceDispute = null"
|
||||
>
|
||||
<div v-if="evidenceDispute" class="dialog-body">
|
||||
<p>
|
||||
<strong>{{ evidenceDispute.order_no }}</strong> · 商品编号
|
||||
{{ formatListingNo(evidenceDispute.listing_no) }} · {{ evidenceDispute.title }}
|
||||
</p>
|
||||
<div v-for="item in evidenceItems(evidenceDispute)" :key="item" class="evidence-row">
|
||||
<span>{{ item }}</span>
|
||||
<el-button size="small" @click="openEvidence(item)">打开</el-button>
|
||||
<div v-if="evidenceDispute" class="evidence-dialog">
|
||||
<div class="evidence-header">
|
||||
<div>
|
||||
<strong>{{ evidenceDispute.order_no }}</strong>
|
||||
<span>商品编号 {{ formatListingNo(evidenceDispute.listing_no) }}</span>
|
||||
</div>
|
||||
<el-tag :type="disputeStatusTone(evidenceDispute.status)" effect="light">
|
||||
{{ disputeStatusLabel(evidenceDispute.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="evidence-meta-grid">
|
||||
<div>
|
||||
<span>账号</span>
|
||||
<strong>{{ evidenceDispute.title }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>类型</span>
|
||||
<strong>{{ disputeTypeLabel(evidenceDispute.type) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>证据数量</span>
|
||||
<strong>{{ evidenceCount(evidenceDispute) }} 份</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>创建时间</span>
|
||||
<strong>{{ formatDateTime(evidenceDispute.created_at) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="evidence-description">
|
||||
<span>申诉说明</span>
|
||||
<p>{{ evidenceDispute.description || '-' }}</p>
|
||||
</div>
|
||||
|
||||
<el-empty
|
||||
v-if="evidenceItems(evidenceDispute).length === 0"
|
||||
description="暂无证据文件"
|
||||
/>
|
||||
<div v-else class="evidence-list">
|
||||
<div
|
||||
v-for="(item, index) in evidenceItems(evidenceDispute)"
|
||||
:key="`${item}-${index}`"
|
||||
class="evidence-card"
|
||||
>
|
||||
<div class="evidence-preview" :class="'evidence-preview-' + evidenceKind(item)">
|
||||
<AuthImage
|
||||
v-if="evidenceKind(item) === 'image'"
|
||||
:source="evidenceSource(item)"
|
||||
admin
|
||||
:alt="evidenceName(item, index)"
|
||||
image-class="evidence-image"
|
||||
fallback-class="evidence-image-fallback"
|
||||
/>
|
||||
<div v-else class="evidence-file-icon">
|
||||
{{ evidenceKindLabel(item) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="evidence-info">
|
||||
<div class="evidence-title-row">
|
||||
<strong>{{ evidenceName(item, index) }}</strong>
|
||||
<el-tag size="small" effect="plain">{{ evidenceKindLabel(item) }}</el-tag>
|
||||
</div>
|
||||
<p>{{ evidenceDetail(item) }}</p>
|
||||
<div class="evidence-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEvidence(item)">
|
||||
打开
|
||||
</el-button>
|
||||
<el-button size="small" @click="copyEvidence(item)">复制</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -249,3 +553,253 @@ async function handleArbitrate() {
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dispute-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.summary-item span {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.summary-item strong {
|
||||
color: #0f172a;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filter-control {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.main-cell,
|
||||
.party-cell {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.main-cell strong {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.main-cell span,
|
||||
.party-cell span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dispute-table :deep(.el-table__cell) {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.evidence-dialog {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.evidence-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.evidence-header div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.evidence-header strong {
|
||||
color: #0f172a;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.evidence-header span,
|
||||
.evidence-meta-grid span,
|
||||
.evidence-description span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.evidence-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.evidence-meta-grid > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.evidence-meta-grid strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #1f2937;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.evidence-description {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.evidence-description p {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.evidence-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-height: 420px;
|
||||
padding-right: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.evidence-card {
|
||||
display: grid;
|
||||
grid-template-columns: 132px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.evidence-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 96px;
|
||||
overflow: hidden;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.evidence-file-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 8px;
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.evidence-card :deep(.evidence-image) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.evidence-card :deep(.evidence-image-fallback) {
|
||||
min-height: 96px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.evidence-info {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.evidence-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.evidence-title-row strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.evidence-info p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.evidence-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.dispute-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.evidence-meta-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.evidence-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,14 +7,26 @@ export interface Dispute {
|
||||
id: number
|
||||
order_id: number
|
||||
order_no: string
|
||||
order_status: string
|
||||
handoff_status: string
|
||||
settlement_status: string
|
||||
listing_no: string
|
||||
title: string
|
||||
owner_id: number
|
||||
renter_id: number
|
||||
owner_phone: string
|
||||
renter_phone: string
|
||||
initiator_id: number
|
||||
target_user_id: number
|
||||
type: string
|
||||
status: DisputeStatus
|
||||
description: string
|
||||
evidence_urls?: string[]
|
||||
previous_order_status?: string
|
||||
previous_handoff_status?: string
|
||||
previous_settlement_status?: string
|
||||
checkout_id?: number
|
||||
previous_checkout_status?: string
|
||||
arbitration_result: string
|
||||
arbitration_remark: string
|
||||
handled_by?: number
|
||||
@@ -31,6 +43,16 @@ export async function createDispute(
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function cancelOrderDispute(orderId: number) {
|
||||
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/orders/${orderId}/dispute/cancel`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function cancelDispute(id: number) {
|
||||
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/disputes/${id}/cancel`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchDisputes(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Dispute>>>('/disputes', {
|
||||
params: { page, page_size: pageSize },
|
||||
|
||||
@@ -33,12 +33,20 @@ export interface Order {
|
||||
status: OrderStatus
|
||||
handoff_status: HandoffStatus
|
||||
settlement_status: SettlementStatus
|
||||
active_dispute?: ActiveDispute
|
||||
checkout?: Checkout
|
||||
payment_deadline_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ActiveDispute {
|
||||
id: number
|
||||
initiator_id: number
|
||||
type: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface Checkout {
|
||||
id: number
|
||||
order_id: number
|
||||
|
||||
@@ -237,6 +237,10 @@ export function formatHandoffRecordType(type: string) {
|
||||
renter_confirm_checkout: '买家确认结账',
|
||||
owner_accept_checkout: '卖家接受结账',
|
||||
admin_arbitration: '客服仲裁',
|
||||
dispute_opened: '发起申诉',
|
||||
checkout_dispute_opened: '发起结账争议',
|
||||
dispute_cancelled: '取消申诉',
|
||||
checkout_dispute_cancelled: '取消结账争议',
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { showToast, showDialog } from 'vant'
|
||||
|
||||
import { fetchOrderChat } from '@/features/chats/api/chats'
|
||||
import { createDispute } from '@/features/disputes/api/disputes'
|
||||
import { cancelOrderDispute, createDispute } from '@/features/disputes/api/disputes'
|
||||
import { uploadFile } from '@/shared/api/files'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
import {
|
||||
@@ -57,6 +57,7 @@ const countering = ref(false)
|
||||
const acceptingCheckout = ref(false)
|
||||
const rejectingCheckout = ref(false)
|
||||
const disputing = ref(false)
|
||||
const cancellingDispute = ref(false)
|
||||
const uploadingEvidence = ref(false)
|
||||
const openingChat = ref(false)
|
||||
const order = ref<Order | null>(null)
|
||||
@@ -127,6 +128,14 @@ const canOpenDispute = computed(() => {
|
||||
'abnormal',
|
||||
].includes(order.value.status)
|
||||
})
|
||||
const canCancelDispute = computed(() => {
|
||||
return (
|
||||
!!order.value &&
|
||||
(isOwner.value || isRenter.value) &&
|
||||
['disputing', 'checkout_disputing'].includes(order.value.status) &&
|
||||
order.value.active_dispute?.initiator_id === session.userId
|
||||
)
|
||||
})
|
||||
const isCheckoutDisputeStage = computed(() => {
|
||||
return (
|
||||
!!order.value &&
|
||||
@@ -384,6 +393,26 @@ async function handleCreateDispute() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelDispute() {
|
||||
if (!order.value) return
|
||||
showDialog({
|
||||
title: order.value.status === 'checkout_disputing' ? '取消结账争议' : '取消申诉',
|
||||
message: '取消后订单将恢复到发起申诉前的流程,确定继续吗?',
|
||||
showCancelButton: true,
|
||||
}).then(async () => {
|
||||
cancellingDispute.value = true
|
||||
try {
|
||||
await cancelOrderDispute(order.value!.id)
|
||||
showToast({ message: '申诉已取消,订单已恢复', icon: 'passed' })
|
||||
await loadOrder()
|
||||
} catch (error) {
|
||||
showToast({ message: readError(error, '取消申诉失败'), icon: 'cross' })
|
||||
} finally {
|
||||
cancellingDispute.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleEvidenceUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
@@ -731,6 +760,20 @@ async function copyListingCode() {
|
||||
</van-button>
|
||||
</section>
|
||||
|
||||
<section v-if="canCancelDispute" class="card-section action-card">
|
||||
<van-button
|
||||
type="warning"
|
||||
block
|
||||
plain
|
||||
round
|
||||
:loading="cancellingDispute"
|
||||
loading-text="取消中..."
|
||||
@click="handleCancelDispute"
|
||||
>
|
||||
{{ order.status === 'checkout_disputing' ? '取消结账争议' : '取消申诉' }}
|
||||
</van-button>
|
||||
</section>
|
||||
|
||||
<!-- Snapshot Account Details Accordion -->
|
||||
<section class="card-section">
|
||||
<van-collapse v-model="activeNames">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
import { fetchOrderChat } from '@/features/chats/api/chats'
|
||||
import { createDispute } from '@/features/disputes'
|
||||
import { cancelOrderDispute, createDispute } from '@/features/disputes'
|
||||
import { uploadFile } from '@/shared/api/files'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
import { formatCent } from '@/shared/utils/money'
|
||||
@@ -56,6 +56,7 @@ const countering = ref(false)
|
||||
const acceptingCheckout = ref(false)
|
||||
const rejectingCheckout = ref(false)
|
||||
const disputing = ref(false)
|
||||
const cancellingDispute = ref(false)
|
||||
const uploadingEvidence = ref(false)
|
||||
const order = ref<Order | null>(null)
|
||||
const handoffRecords = ref<HandoffRecord[]>([])
|
||||
@@ -116,6 +117,14 @@ const canOpenDispute = computed(() => {
|
||||
'abnormal',
|
||||
].includes(order.value.status)
|
||||
})
|
||||
const canCancelDispute = computed(() => {
|
||||
return (
|
||||
!!order.value &&
|
||||
(isOwner.value || isRenter.value) &&
|
||||
['disputing', 'checkout_disputing'].includes(order.value.status) &&
|
||||
order.value.active_dispute?.initiator_id === session.userId
|
||||
)
|
||||
})
|
||||
const isCheckoutDisputeStage = computed(() => {
|
||||
return (
|
||||
!!order.value &&
|
||||
@@ -438,6 +447,20 @@ async function handleCreateDispute() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelDispute() {
|
||||
if (!order.value) return
|
||||
cancellingDispute.value = true
|
||||
try {
|
||||
await cancelOrderDispute(order.value.id)
|
||||
ElMessage.success('申诉已取消,订单已恢复')
|
||||
await loadOrder()
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '取消申诉失败'))
|
||||
} finally {
|
||||
cancellingDispute.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEvidenceUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
@@ -870,6 +893,22 @@ async function copyListingCode() {
|
||||
</el-button>
|
||||
<p class="sidebar-hint">点击后滚动到申诉表单填写详细信息</p>
|
||||
</div>
|
||||
|
||||
<div v-if="canCancelDispute" class="sidebar-card dispute-card">
|
||||
<h3 class="sidebar-title">
|
||||
{{ order.status === 'checkout_disputing' ? '取消结账争议' : '取消申诉' }}
|
||||
</h3>
|
||||
<el-button
|
||||
type="warning"
|
||||
plain
|
||||
size="large"
|
||||
:loading="cancellingDispute"
|
||||
@click="handleCancelDispute"
|
||||
>
|
||||
{{ order.status === 'checkout_disputing' ? '取消结账争议' : '取消申诉' }}
|
||||
</el-button>
|
||||
<p class="sidebar-hint">取消后订单将恢复到发起申诉前的流程</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user