Files
hfb_sys/frontend/src/features/admin/views/AdminDisputesView.vue
T
2026-06-21 13:17:10 +08:00

844 lines
23 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 { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/features/disputes'
import { fetchAdminFileBlob } from '@/shared/api/files'
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'
import AdminTablePagination from '../components/AdminTablePagination.vue'
const loading = ref(false)
const submitting = ref(false)
const disputes = ref<Dispute[]>([])
const activeDispute = ref<Dispute | null>(null)
const evidenceDispute = ref<Dispute | null>(null)
const result = ref('release_deposit')
const remark = ref('')
const amount = ref<number | undefined>()
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const keywordFilter = ref('')
const statusFilter = ref('')
const typeFilter = ref('')
const isPartialRefund = computed(() => result.value === 'partial_refund')
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)
async function loadDisputes() {
loading.value = true
try {
const res = await fetchAdminDisputes(currentPage.value, currentPageSize.value, {
keyword: keywordFilter.value.trim() || undefined,
status: statusFilter.value || undefined,
type: typeFilter.value || undefined,
})
disputes.value = res.items
total.value = res.total
} finally {
loading.value = false
}
}
async function handlePageChange() {
await loadDisputes()
}
async function queryDisputes() {
currentPage.value = 1
await loadDisputes()
}
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() {
keywordFilter.value = ''
statusFilter.value = ''
typeFilter.value = ''
void queryDisputes()
}
function evidenceItems(row: Dispute | null) {
const raw = row?.evidence_urls
if (!raw) return []
if (Array.isArray(raw)) return raw
return []
}
function extractObjectKey(url: string) {
try {
const parsed = new URL(url, window.location.origin)
return parsed.searchParams.get('key') || ''
} catch {
return ''
}
}
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)
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
}
await copyEvidence(url)
}
async function handleArbitrate() {
if (!activeDispute.value) return
if (submitting.value) return
if (!remark.value.trim()) {
ElMessage.warning('请填写客服裁决说明')
return
}
if (isPartialRefund.value && (!amount.value || amount.value <= 0)) {
ElMessage.warning('请填写部分退款金额')
return
}
submitting.value = true
try {
await arbitrateDispute(activeDispute.value.id, {
result: result.value,
remark: remark.value.trim(),
amount_cent: amount.value ? yuanToCent(amount.value) : undefined,
})
ElMessage.success('仲裁结果已保存,双方已收到通知')
activeDispute.value = null
remark.value = ''
await loadDisputes()
} catch (error) {
ElMessage.error(readError(error, '仲裁失败'))
} finally {
submitting.value = false
}
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">申诉仲裁</p>
<h1>仲裁中心</h1>
<p>集中处理订单申诉结账争议证据查看和客服裁决</p>
</div>
</div>
<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-input
v-model="keywordFilter"
clearable
placeholder="订单号 / 商品编号 / 手机号"
class="filter-control keyword-filter"
@keyup.enter="queryDisputes"
@clear="queryDisputes"
/>
<el-select
v-model="statusFilter"
clearable
placeholder="全部状态"
class="filter-control"
@change="queryDisputes"
>
<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"
@change="queryDisputes"
>
<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 type="primary" :icon="Search" @click="queryDisputes">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
<el-table v-loading="loading" class="table-panel dispute-table" :data="disputes">
<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 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 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"
:disabled="evidenceItems(row).length === 0"
@click="evidenceDispute = row"
>证据</el-button
>
<el-button size="small" :disabled="!canArbitrate(row)" @click="openArbitration(row)"
>仲裁</el-button
>
</template>
</el-table-column>
</el-table>
<AdminTablePagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:loading="loading"
@page-change="handlePageChange"
/>
<el-dialog
:model-value="!!activeDispute"
title="申诉仲裁"
width="560px"
@update:model-value="activeDispute = null"
>
<div v-if="activeDispute" class="dialog-body">
<p>
<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" />
<el-option label="部分退款" value="partial_refund" />
<el-option label="扣押金" value="deduct_deposit" />
<el-option label="释放押金" value="release_deposit" />
<el-option label="赔付号主" value="compensate_owner" />
<el-option label="关闭订单" value="order_close" />
<el-option label="标记异常" value="mark_abnormal" />
</el-select>
<el-input-number
v-if="['partial_refund', 'deduct_deposit', 'compensate_owner'].includes(result)"
v-model="amount"
class="full-control panel-action"
:min="0"
:precision="0"
:step="10"
placeholder="裁决金额"
/>
<p v-if="result === 'partial_refund'">
部分退款金额表示退给租客的金额剩余冻结金额结算给号主
</p>
<p v-if="['deduct_deposit', 'compensate_owner'].includes(result)">
金额表示从押金中赔付给号主的部分不填则默认处理全额押金
</p>
<el-input
v-model="remark"
class="panel-action"
type="textarea"
:rows="4"
placeholder="填写客服裁决说明"
/>
</div>
<template #footer>
<el-button :disabled="submitting" @click="activeDispute = null">取消</el-button>
<el-button
type="primary"
:loading="submitting"
:disabled="!canSubmitArbitration"
@click="handleArbitrate"
>保存裁决</el-button
>
</template>
</el-dialog>
<el-dialog
:model-value="!!evidenceDispute"
title="申诉证据"
width="760px"
@update:model-value="evidenceDispute = null"
>
<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>
<el-button type="primary" @click="evidenceDispute = null">关闭</el-button>
</template>
</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;
}
.keyword-filter {
width: 280px;
}
.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>