Files
points-mall/supplied-admin/src/components/MerchantAfterSaleTable.vue
T

112 lines
20 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.
<template>
<section class="page-card"><div class="page-title"><div><h2>{{ title }}</h2><p>{{ description }}</p></div></div><el-form :inline="true" class="query-form" @submit.prevent="loadRecords"><el-form-item label="售后单号"><el-input v-model="serviceOrderId" placeholder="请输入售后单号" clearable /></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="loadRecords">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form><div v-if="scene !== 'process'" v-loading="loading" class="audit-list">
<article v-for="row in records" :key="row.serviceOrderId" class="audit-card">
<header class="audit-card-header"><strong>{{ row.serviceOrderId }}</strong><el-tag type="warning">{{ row.serviceTypeText || '-' }}</el-tag></header>
<div v-for="product in row.products || []" :key="product.id || product.sku" class="audit-product">
<el-image :src="resolveImageUrl(product.image || '')" :preview-src-list="product.image ? [resolveImageUrl(product.image)] : []" preview-teleported fit="cover"><template #error><span class="image-empty">暂无图片</span></template></el-image>
<div class="audit-product-info"><strong>{{ product.name }}</strong><span>{{ specText(product.sku) }}</span><b>¥{{ money(product.price) }}</b></div><span class="audit-quantity">×{{ product.number }}</span>
</div>
<div class="audit-summary"><dl><dt>原因</dt><dd>{{ row.reason || '-' }}</dd><dt>描述</dt><dd>{{ row.problemDescription || '-' }}</dd></dl><el-tag :type="row.isSigned === true ? 'success' : 'info'">{{ row.isSigned === true ? '已签收' : row.isSigned === false ? '未签收' : '签收状态未知' }}</el-tag></div>
<div v-if="scene === 'audit'" class="after-sale-status"><el-tag :type="row.serviceState === 0 ? 'warning' : 'primary'">{{ currentStatusText(row) }}</el-tag></div>
<div v-if="scene === 'history'" class="history-result"><span class="result-stamp" :class="{ approved: row.serviceState === 5, rejected: row.serviceState === 2 }">{{ historyResultText(row) }}</span></div>
<footer class="audit-card-footer"><span>{{ row.applyTime || '-' }}</span><div><el-button @click="openDetail(row)">查看详情</el-button><el-button v-if="scene === 'audit' && row.serviceState === 0" type="danger" plain :disabled="auditing" @click="audit(row, 2)">拒绝</el-button><el-button v-if="scene === 'audit' && row.serviceState === 0" type="primary" :disabled="auditing" @click="audit(row, 1)">通过</el-button><el-button v-if="canReceive(row)" type="primary" :disabled="auditing" @click="receive(row)">确认收货</el-button><el-button v-if="canShip(row)" type="primary" :disabled="auditing" @click="openShipDialog(row)">换货发货</el-button></div></footer>
</article>
<el-empty v-if="!loading && !records.length" :description="scene === 'audit' ? '暂无待处理售后' : '暂无售后记录'" />
</div><el-table v-else v-loading="loading" :data="records" border><el-table-column prop="serviceOrderId" label="售后单号" min-width="170" /><el-table-column prop="serviceTypeText" label="售后类型" width="110" /><el-table-column prop="serviceStateText" label="状态" width="130" /><el-table-column prop="reason" label="原因" min-width="160" /><el-table-column prop="applyTime" label="申请时间" min-width="170" /><el-table-column label="操作" min-width="160" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button v-if="scene === 'audit'" link type="success" @click="audit(row, 1)">通过</el-button><el-button v-if="scene === 'audit'" link type="danger" @click="audit(row, 2)">拒绝</el-button><el-button v-if="canReceive(row)" link type="success" @click="receive(row)">确认收货</el-button><el-button v-if="canShip(row)" link type="primary" @click="openShipDialog(row)">换货发货</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无售后记录" /></template></el-table><el-dialog v-model="detailVisible" title="售后详情" :width="scene !== 'process' ? '1080px' : '760px'">
<div v-if="scene !== 'process' && detail.afterSale" class="audit-detail">
<section class="audit-detail-section audit-progress"><span v-if="scene === 'history'" class="result-stamp detail-result-stamp" :class="{ approved: detail.afterSale.serviceState === 5, rejected: detail.afterSale.serviceState === 2 }" aria-label="处理结果">{{ historyResultText(detail.afterSale) }}</span><h3>售后进度</h3><el-steps align-center>
<el-step title="提交申请" status="success"><template #description><p>用户提交售后申请</p><p>{{ detail.afterSale.applyTime || '-' }}</p></template></el-step>
<el-step title="商家审核" :status="detail.afterSale.auditTime ? (detail.afterSale.auditStatus === 2 ? 'error' : 'success') : detail.afterSale.serviceState === 6 ? 'wait' : 'process'"><template #description><p>{{ detail.afterSale.auditRemark || (detail.afterSale.serviceState === 6 ? '申请已撤销' : '待商家审核') }}</p><p>{{ detail.afterSale.auditTime || '-' }}</p></template></el-step>
<el-step v-if="detail.afterSale.isSigned && detail.afterSale.auditStatus === 1" title="商品寄回" :status="detail.afterSale.userShipTime ? 'success' : 'process'"><template #description><p>{{ detail.afterSale.userShipTime ? '用户已寄回商品' : '等待用户填写寄回物流' }}</p><p>{{ detail.afterSale.userShipTime || '-' }}</p></template></el-step>
<el-step v-if="detail.afterSale.isSigned && detail.afterSale.auditStatus === 1" title="商家收货" :status="detail.afterSale.merchantReceiveTime ? 'success' : detail.afterSale.userShipTime ? 'process' : 'wait'"><template #description><p>{{ detail.afterSale.merchantReceiveTime ? '商家已确认收货' : '等待商家收到商品' }}</p><p>{{ detail.afterSale.merchantReceiveTime || '-' }}</p></template></el-step>
<el-step :title="detail.afterSale.serviceState === 6 ? '已撤销' : detail.afterSale.serviceState === 2 ? '审核未通过' : '售后完成'" :status="detail.afterSale.serviceState === 5 ? 'success' : 'wait'"><template #description><p>{{ detail.afterSale.serviceState === 5 ? '售后已完成' : detail.afterSale.serviceState === 2 ? '审核未通过,售后已结束' : detail.afterSale.serviceState === 6 ? '用户已撤销售后申请' : '等待售后处理完成' }}</p><p>{{ detail.afterSale.finishTime || '-' }}</p></template></el-step>
</el-steps></section>
<section class="audit-detail-section"><h3>售后商品</h3><div v-for="product in detail.afterSale.products || []" :key="product.id || product.sku" class="audit-product"><el-image :src="resolveImageUrl(product.image || '')" :preview-src-list="product.image ? [resolveImageUrl(product.image)] : []" preview-teleported fit="cover"><template #error><span class="image-empty">暂无图片</span></template></el-image><div class="audit-product-info"><strong>{{ product.name }}</strong><span>{{ specText(product.sku) }}</span><b>¥{{ money(product.price) }}</b></div><span class="audit-quantity">×{{ product.number }}</span></div></section>
<section class="audit-detail-section"><h3>售后信息</h3><el-descriptions :column="1" border>
<el-descriptions-item label="售后单号">{{ detail.afterSale.serviceOrderId }}</el-descriptions-item><el-descriptions-item label="子订单号">{{ detail.afterSale.orderNumberId || detail.afterSale.subOrderId || '-' }}</el-descriptions-item><el-descriptions-item label="售后类型"><el-tag type="warning">{{ detail.afterSale.serviceTypeText || '-' }}</el-tag></el-descriptions-item><el-descriptions-item label="签收状态"><el-tag :type="detail.afterSale.isSigned === true ? 'success' : 'info'">{{ detail.afterSale.isSigned === true ? '已签收' : detail.afterSale.isSigned === false ? '未签收' : '-' }}</el-tag></el-descriptions-item><el-descriptions-item label="当前状态"><el-tag>{{ currentStatusText(detail.afterSale) }}</el-tag></el-descriptions-item><el-descriptions-item v-if="detail.afterSale.userLogisticsNo" label="寄回物流">{{ detail.afterSale.userLogisticsCompany || '-' }} / {{ detail.afterSale.userLogisticsNo }}</el-descriptions-item><el-descriptions-item v-if="detail.afterSale.merchantReceiveTime" label="商家收货时间">{{ detail.afterSale.merchantReceiveTime }}</el-descriptions-item><el-descriptions-item v-if="detail.afterSale.merchantLogisticsNo" label="换货物流">{{ detail.afterSale.merchantLogisticsCompany || '-' }} / {{ detail.afterSale.merchantLogisticsNo }}</el-descriptions-item><el-descriptions-item label="售后原因">{{ detail.afterSale.reason || '-' }}</el-descriptions-item><el-descriptions-item label="问题描述">{{ detail.afterSale.problemDescription || '-' }}</el-descriptions-item><el-descriptions-item label="申请时间">{{ detail.afterSale.applyTime || '-' }}</el-descriptions-item>
</el-descriptions><div v-if="detailImages.length" class="audit-evidence"><el-image v-for="url in detailImages" :key="url" :src="url" :preview-src-list="detailImages" preview-teleported fit="cover" /></div></section>
</div><pre v-else class="detail-json">{{ JSON.stringify(detail, null, 2) }}</pre>
<template #footer><el-button @click="detailVisible = false">关闭</el-button><template v-if="scene === 'audit' && detail.afterSale?.serviceState === 0"><el-button type="danger" plain :disabled="auditing" @click="audit(detail.afterSale, 2)">拒绝</el-button><el-button type="primary" :disabled="auditing" @click="audit(detail.afterSale, 1)">通过</el-button></template><el-button v-if="detail.afterSale && canReceive(detail.afterSale)" type="primary" :disabled="auditing" @click="receive(detail.afterSale)">确认收货</el-button><el-button v-if="detail.afterSale && canShip(detail.afterSale)" type="primary" :disabled="auditing" @click="openShipDialog(detail.afterSale)">换货发货</el-button></template>
</el-dialog><el-dialog v-model="shipVisible" title="换货发货" width="460px" append-to-body><el-form label-width="90px"><el-form-item label="快递公司"><el-input v-model="shipForm.logisticsCompany" /></el-form-item><el-form-item label="快递单号"><el-input v-model="shipForm.logisticsNo" /></el-form-item></el-form><template #footer><el-button @click="shipVisible = false">取消</el-button><el-button type="primary" :loading="auditing" @click="shipExchange">确认发货</el-button></template></el-dialog></section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { postAdmin, resolveImageUrl } from '@/api/admin'
const props = defineProps<{ scene: 'audit' | 'process' | 'history'; title: string; description: string }>()
type AfterSaleProduct = { id?: number; sku: string; name: string; number: number; price: string; image?: string }
type AfterSale = { serviceOrderId: string; supplierId: string; serviceState?: number; logisticsState?: number; serviceType?: number; isSigned?: boolean; serviceTypeText?: string; serviceStateText?: string; reason?: string; applyTime?: string; problemDescription?: string; products?: AfterSaleProduct[]; subOrderId?: string; orderNumberId?: string; auditStatus?: number; auditTime?: string; auditRemark?: string; finishTime?: string; userLogisticsNo?: string; userLogisticsCompany?: string; merchantLogisticsNo?: string; merchantLogisticsCompany?: string; userShipTime?: string; merchantReceiveTime?: string; merchantShipTime?: string; images?: Array<string | { url?: string; previewUrl?: string; fileUrl?: string }> }
const serviceOrderId = ref('')
const auditing = ref(false)
const currentStatusText = (row: AfterSale) => {
if (row.serviceState === 1 && row.logisticsState === 1) return '审核通过,待用户寄回'
if (row.serviceState === 3) return '用户已寄回,待商家收货'
if (row.serviceState === 4 && row.serviceType === 5) return '商家已收货,待换货发货'
if (row.serviceState === 1 && row.serviceType === 5 && row.isSigned === false) return '审核通过,待换货发货'
return row.serviceStateText || '-'
}
const historyResultText = (row: AfterSale) => row.serviceState === 5 ? '审核通过' : row.serviceStateText || '-'
const money = (value: string) => Number(value || 0).toFixed(2)
const specText = (sku: string) => {
const labels: Record<string, string> = { cream: '奶油白', blue: '雾霾蓝', orange: '暖橙色', standard: '标准款', plus: '升级款' }
const parts = String(sku || '').split('-').slice(-2)
return parts.some(part => labels[part]) ? parts.map(part => labels[part] || part).join(' | ') : '-'
}
const detailImages = computed(() => (detail.value.afterSale?.images || []).map(item => resolveImageUrl(typeof item === 'string' ? item : item.fileUrl || item.url || item.previewUrl || '')).filter(Boolean))
const records = ref<AfterSale[]>([]); const loading = ref(false); const queried = ref(false); const detailVisible = ref(false); const detail = ref<{ afterSale?: AfterSale }>({}); const shipVisible = ref(false); const current = ref<AfterSale>(); const shipForm = ref({ logisticsCompany: '', logisticsNo: '' })
const loadRecords = async () => { loading.value = true; try { const data = await postAdmin<{ afterSaleItems?: AfterSale[] }>('merchant-after-sale-list', { scene: props.scene, serviceOrderId: serviceOrderId.value.trim() }); records.value = data.afterSaleItems || []; queried.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '售后查询失败') } finally { loading.value = false } }
const reset = () => { serviceOrderId.value = ''; return loadRecords() }
const openDetail = async (row: AfterSale) => { try { detail.value = await postAdmin<{ afterSale: AfterSale }>('merchant-after-sale-detail', { supplierId: row.supplierId, serviceOrderId: row.serviceOrderId }); detailVisible.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '售后详情查询失败') } }
const audit = async (row: AfterSale, auditStatus: number) => { if (auditing.value) return; auditing.value = true; try { await ElMessageBox.confirm(auditStatus === 1 ? '确认通过该售后申请?' : '确认拒绝该售后申请?', '售后审核'); await postAdmin('merchant-after-sale-audit', { supplierId: row.supplierId, serviceOrderId: row.serviceOrderId, auditStatus, auditRemark: auditStatus === 1 ? '审核通过' : '商家拒绝' }); ElMessage.success('处理成功'); detailVisible.value = false; await loadRecords() } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '处理失败') } finally { auditing.value = false } }
const canReceive = (row: AfterSale) => props.scene !== 'history' && Number(row.serviceState) === 3
const canShip = (row: AfterSale) => props.scene !== 'history' && Number(row.serviceType) === 5 && (Number(row.serviceState) === 4 || (row.isSigned === false && Number(row.serviceState) === 1))
const receive = async (row: AfterSale) => { if (auditing.value) return; auditing.value = true; try { await ElMessageBox.confirm('确认已收到寄回商品?', '售后处理'); await postAdmin('merchant-after-sale-receive', { supplierId: row.supplierId, serviceOrderId: row.serviceOrderId }); ElMessage.success('已确认收货'); await loadRecords(); if (detailVisible.value) await openDetail(row) } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '操作失败') } finally { auditing.value = false } }
const openShipDialog = (row: AfterSale) => { current.value = row; shipForm.value = { logisticsCompany: '', logisticsNo: '' }; shipVisible.value = true }
const shipExchange = async () => { if (auditing.value) return; if (!current.value || !shipForm.value.logisticsCompany || !shipForm.value.logisticsNo) return ElMessage.warning('请填写完整发货信息'); auditing.value = true; try { await postAdmin('merchant-after-sale-ship', { supplierId: current.value.supplierId, serviceOrderId: current.value.serviceOrderId, ...shipForm.value }); ElMessage.success('换货已发货'); shipVisible.value = false; await loadRecords(); if (detailVisible.value) await openDetail(current.value) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '发货失败') } finally { auditing.value = false } }
onMounted(loadRecords)
</script>
<style scoped>
.after-sale-status { margin-top: 16px; }
.audit-card-footer > div { display: flex; flex-wrap: wrap; gap: 8px; }
.audit-card-footer > div .el-button { margin-left: 0; }
.history-result { display: flex; justify-content: flex-end; margin-top: 16px; padding: 8px 6px; }
.result-stamp { position: relative; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 112px; height: 56px; margin: 6px 0; border: 2px solid currentColor; border-radius: 50%; color: #909399; font-size: 16px; font-weight: 700; letter-spacing: 2px; line-height: 1; transform: rotate(-14deg); }
.result-stamp::after { position: absolute; inset: 4px; border: 1px dashed currentColor; border-radius: 50%; content: ""; }
.result-stamp.approved { color: #389e63; }
.result-stamp.rejected { color: #d9534f; }
.audit-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 390px), 1fr)); gap: 20px; }
.audit-list > .el-empty { grid-column: 1 / -1; }
.audit-card { padding: 20px; border: 1px solid #e9eef5; border-radius: 12px; background: #fff; }
.audit-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
.audit-card-header strong { min-width: 0; overflow-wrap: anywhere; line-height: 22px; }
.audit-card-header .el-tag { flex-shrink: 0; }
.audit-product { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }
.audit-product > .el-image { width: 72px; height: 72px; flex-shrink: 0; border-radius: 8px; }
.audit-product-info { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 6px; }
.audit-product-info strong { overflow-wrap: anywhere; }
.audit-product-info span, .audit-card-footer > span { color: #8793a5; font-size: 12px; }
.audit-product-info b { color: #ff6b35; }
.audit-quantity { flex-shrink: 0; font-weight: 600; }
.image-empty { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; background: #f5f7fa; color: #8793a5; font-size: 12px; }
.audit-summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.audit-summary dl { display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 10px 12px; flex: 1; min-width: 0; margin: 0; font-size: 13px; }
.audit-summary dt { color: #8793a5; }
.audit-summary dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
.audit-card-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; margin-top: 20px; padding-top: 16px; border-top: 1px solid #edf0f5; }
.audit-detail { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); align-items: start; gap: 20px; }
.audit-progress { position: relative; grid-column: 1 / -1; }
.detail-result-stamp { position: absolute; z-index: 10; top: -18px; right: 8px; pointer-events: none; width: 128px; height: 128px; margin: 0; border-width: 3px; font-size: 20px; }
.detail-result-stamp::after { inset: 7px; border-width: 2px; }
.audit-detail-section { min-width: 0; padding: 20px; border: 1px solid #e9eef5; border-radius: 10px; }
@media (max-width: 767px) { .audit-detail { grid-template-columns: minmax(0, 1fr); } }
.audit-detail-section h3 { margin: 0 0 18px; font-size: 15px; }
.audit-progress :deep(.el-step__description) { overflow-wrap: anywhere; }
.audit-detail-section p { margin: 6px 0 0; color: #8793a5; font-size: 12px; }
.audit-detail :deep(.el-descriptions__label) { width: 100px; white-space: nowrap; }
.audit-detail :deep(.el-descriptions__content) { overflow-wrap: anywhere; white-space: pre-wrap; }
.audit-evidence { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
.audit-evidence .el-image { width: 80px; height: 80px; border-radius: 6px; }
.detail-json { max-height: 420px; overflow: auto; padding: 12px; border-radius: 4px; background: #f5f7fa; white-space: pre-wrap; word-break: break-all; }</style>