Files
points-mall/supplied-h5/src/view/joyful-selection/merchant/after-sale-detail.vue
T
2026-07-31 19:08:15 +08:00

768 lines
22 KiB
Vue

<template>
<main class="after-sale-detail-page">
<van-nav-bar title="售后详情" left-arrow @click-left="$router.replace('/joyful-selection/merchant')" />
<section v-if="loading" class="skeleton-card">
<van-skeleton title :row="3" />
<van-skeleton title :row="5" />
<van-skeleton title :row="3" />
</section>
<section v-show="!loading" class="status-card" :class="statusCardClass">
<div class="status-icon">
<van-icon :name="statusIcon" />
</div>
<div>
<h2>{{ statusTitle }}</h2>
<p>{{ statusDesc }}</p>
<button v-if="afterSale.auditRemark" type="button" @click="showAuditRemark">查看审核意见</button>
</div>
</section>
<section v-show="!loading" class="detail-card progress-card">
<h3>售后进度</h3>
<div class="timeline">
<div v-for="step in progressSteps" :key="step.key" class="timeline-item" :class="step.state">
<span class="timeline-dot">
<van-icon v-if="step.state === 'done'" name="success" />
</span>
<div class="timeline-main">
<b>{{ step.title }}</b>
<em>{{ step.time || step.subTitle }}</em>
</div>
<p>{{ step.desc }}</p>
</div>
</div>
</section>
<section v-show="!loading" class="detail-card product-card">
<h3>售后商品</h3>
<article v-for="item in products" :key="item.id" class="product-row">
<van-image class="product-thumb" :src="item.image" fit="cover" />
<div class="product-info">
<h4>{{ item.name || '-' }}</h4>
<p>{{ skuText(item) }}</p>
<b>¥{{ item.price || afterSale.amount || '0.00' }}</b>
</div>
<span>x{{ item.number || 1 }}</span>
</article>
</section>
<section v-if="!loading && showReturnInfo" class="detail-card address-card">
<h3>{{ returnInfoTitle }}</h3>
<p><span>联系人</span><b>{{ afterSale.pickupName || '-' }}</b></p>
<p><span>联系电话</span><b>{{ maskedMobile(afterSale.pickupMobile) }}</b></p>
<p><span>地址</span><b>{{ afterSale.pickupAddress || '-' }}</b></p>
<p v-if="afterSale.userLogisticsNo" class="logistics-row">
<span>寄回单号</span><b>{{ afterSale.userLogisticsCompany || '快递' }} {{ afterSale.userLogisticsNo }}</b>
</p>
<p v-if="afterSale.merchantLogisticsNo" class="logistics-row">
<span>换货单号</span><b>{{ afterSale.merchantLogisticsCompany || '快递' }} {{ afterSale.merchantLogisticsNo }}</b>
</p>
</section>
<section v-show="!loading" class="detail-card info-card">
<h3>售后信息</h3>
<p class="service-no-row"><span>售后单号</span><b class="scrollable-no">{{ afterSale.serviceOrderId || '-' }}</b><button type="button" @click="copyServiceNo">复制</button></p>
<p><span>子订单号</span><b>{{ afterSale.orderNumberId || afterSale.subOrderId || '-' }}</b></p>
<p><span>售后类型</span><b>{{ afterSale.serviceTypeText || '-' }}</b></p>
<p><span>售后原因</span><b>{{ afterSale.reason || '-' }}</b></p>
<p><span>问题描述</span><b>{{ afterSale.problemDescription || '-' }}</b></p>
<p><span>申请时间</span><b>{{ afterSale.applyTime || '-' }}</b></p>
<p v-if="!isUnsignedAfterSale"><span>取件方式</span><b>{{ pickupMethodText }}</b></p>
<p v-if="!isUnsignedAfterSale"><span>取件地址</span><b>{{ pickupAddressText }}</b></p>
<div v-if="images.length" class="voucher-block">
<h4>上传凭证</h4>
<div>
<van-image v-for="image in images" :key="image.url" :src="image.url" fit="cover" />
</div>
</div>
</section>
<footer v-show="!loading && merchantActions.length" class="bottom-actions">
<button
v-for="action in merchantActions"
:key="action.key"
type="button"
:class="{ primary: action.primary }"
@click="handleAction(action.key)"
>
{{ action.text }}
</button>
</footer>
<van-popup v-model="showExchangeShipPopup" round position="bottom">
<section class="action-popup">
<h3>填写换货发货信息</h3>
<van-field v-model="exchangeShipForm.logisticsCompany" label="快递公司" placeholder="请输入快递公司" />
<van-field v-model="exchangeShipForm.logisticsNo" label="快递单号" placeholder="请输入快递单号" />
<button type="button" @click="submitExchangeShip">确认发货</button>
</section>
</van-popup>
</main>
</template>
<script>
import { mapActions, mapState } from 'pinia'
import { Dialog, Toast } from 'vant'
import { postFunction } from '@/api/supplied-service'
import { useUserStore } from '@/store/user'
const SERVICE_TYPE = {
RETURN: 4,
EXCHANGE: 5
}
const SERVICE_STATE = {
PENDING_AUDIT: 0,
APPROVED: 1,
REJECTED: 2,
USER_SHIPPED: 3,
RECEIVED: 4,
FINISHED: 5,
CANCELLED: 6
}
const LOGISTICS_STATE = {
NONE: 0,
WAIT_USER_SHIP: 1,
USER_SHIPPED: 2,
MERCHANT_RECEIVED: 3,
MERCHANT_SHIPPED: 4,
USER_RECEIVED: 5
}
const SKU_COLOR_MAP = {
cream: '奶油白',
blue: '雾霾蓝',
orange: '暖橙色'
}
const SKU_SIZE_MAP = {
standard: '标准款',
plus: '升级款'
}
export default {
name: 'JoyfulSelectionMerchantAfterSaleDetail',
data: () => ({
afterSale: {},
loading: false,
showExchangeShipPopup: false,
exchangeShipForm: {
logisticsCompany: '',
logisticsNo: ''
}
}),
computed: {
...mapState(useUserStore, ['supplierId']),
products() {
return this.afterSale.products || []
},
images() {
return Array.isArray(this.afterSale.images)
? this.afterSale.images.map(item => ({
url: item.url || item.previewUrl || item
})).filter(item => item.url)
: []
},
isExchange() {
return Number(this.afterSale.serviceType) === SERVICE_TYPE.EXCHANGE
},
isUnsignedAfterSale() {
if (this.afterSale.isSigned === false || this.afterSale.isSigned === 0 || this.afterSale.isSigned === 'false') {
return true
}
return this.afterSale.isSigned === undefined &&
Number(this.afterSale.logisticsState) === LOGISTICS_STATE.NONE &&
!this.afterSale.pickupMethod &&
!this.afterSale.pickupName &&
!this.afterSale.pickupMobile &&
!this.afterSale.pickupAddress
},
statusCardClass() {
if (Number(this.afterSale.serviceState) === SERVICE_STATE.REJECTED) return 'rejected'
if (Number(this.afterSale.serviceState) === SERVICE_STATE.APPROVED) return 'approved'
if (Number(this.afterSale.serviceState) === SERVICE_STATE.FINISHED) return 'finished'
return 'pending'
},
statusIcon() {
if (this.statusCardClass === 'approved' || this.statusCardClass === 'finished') return 'success'
if (this.statusCardClass === 'rejected') return 'cross'
return 'clock-o'
},
statusTitle() {
const serviceState = Number(this.afterSale.serviceState)
const logisticsState = Number(this.afterSale.logisticsState)
if (serviceState === SERVICE_STATE.REJECTED) return '审核未通过'
if (serviceState === SERVICE_STATE.FINISHED) return '售后完成'
if (logisticsState === LOGISTICS_STATE.MERCHANT_SHIPPED) return '商家已发货'
if (logisticsState === LOGISTICS_STATE.MERCHANT_RECEIVED && this.isExchange) return '待商家发货'
if (logisticsState === LOGISTICS_STATE.USER_SHIPPED) return '用户已寄回'
if (serviceState === SERVICE_STATE.APPROVED) return '审核通过'
return '待审核'
},
statusDesc() {
const serviceState = Number(this.afterSale.serviceState)
const logisticsState = Number(this.afterSale.logisticsState)
if (serviceState === SERVICE_STATE.REJECTED) return '商家已拒绝该售后申请'
if (serviceState === SERVICE_STATE.FINISHED) return '售后流程已完成'
if (logisticsState === LOGISTICS_STATE.MERCHANT_SHIPPED) return '换货商品已发出,等待用户收货'
if (logisticsState === LOGISTICS_STATE.MERCHANT_RECEIVED && this.isExchange) return '已收到寄回商品,请发出换货商品'
if (logisticsState === LOGISTICS_STATE.USER_SHIPPED) return '用户已寄回商品,请确认收货'
if (this.isExchange && this.isUnsignedAfterSale && serviceState === SERVICE_STATE.APPROVED) return '未签收换货申请已通过,请发出换货商品'
if (serviceState === SERVICE_STATE.APPROVED) return '已通过售后申请,等待用户寄回商品'
return '请审核用户提交的售后申请'
},
progressSteps() {
const serviceState = Number(this.afterSale.serviceState)
const logisticsState = Number(this.afterSale.logisticsState)
const steps = [
{
key: 'apply',
title: '提交申请',
time: this.afterSale.applyTime,
desc: '用户已提交售后申请',
state: 'done'
},
{
key: 'audit',
title: '商家审核',
time: this.afterSale.auditTime,
desc: serviceState === SERVICE_STATE.REJECTED ? '商家审核未通过' : serviceState >= SERVICE_STATE.APPROVED ? '商家已审核通过' : '待商家审核',
state: serviceState >= SERVICE_STATE.APPROVED || serviceState === SERVICE_STATE.REJECTED ? 'done' : 'current'
},
{
key: 'userShip',
title: '寄回商品',
time: this.afterSale.userShipTime,
desc: logisticsState >= LOGISTICS_STATE.USER_SHIPPED ? '用户已寄回商品' : '等待用户寄回商品',
state: logisticsState >= LOGISTICS_STATE.USER_SHIPPED ? 'done' : logisticsState === LOGISTICS_STATE.WAIT_USER_SHIP ? 'current' : 'todo'
},
{
key: 'merchantReceive',
title: '商家收货',
time: this.afterSale.merchantReceiveTime,
desc: logisticsState >= LOGISTICS_STATE.MERCHANT_RECEIVED ? '商家已收到商品' : '商家尚未收到商品',
state: logisticsState >= LOGISTICS_STATE.MERCHANT_RECEIVED ? 'done' : logisticsState === LOGISTICS_STATE.USER_SHIPPED ? 'current' : 'todo'
}
]
if (this.isUnsignedAfterSale && !this.isExchange) {
steps.splice(2)
steps.push({
key: 'finish',
title: '售后完成',
time: this.afterSale.finishTime,
desc: serviceState === SERVICE_STATE.FINISHED ? '售后已完成' : '审核通过后售后完成',
state: serviceState === SERVICE_STATE.FINISHED ? 'done' : serviceState >= SERVICE_STATE.APPROVED ? 'current' : 'todo'
})
return this.normalizeSteps(steps)
}
if (this.isExchange) {
if (this.isUnsignedAfterSale) {
steps.splice(2)
}
steps.push(
{
key: 'merchantShip',
title: '商家发货',
time: this.afterSale.merchantShipTime,
desc: logisticsState >= LOGISTICS_STATE.MERCHANT_SHIPPED ? '商家已发出换货商品' : '等待商家发出换货商品',
state: logisticsState >= LOGISTICS_STATE.MERCHANT_SHIPPED ? 'done' : (this.isUnsignedAfterSale ? serviceState >= SERVICE_STATE.APPROVED : logisticsState === LOGISTICS_STATE.MERCHANT_RECEIVED) ? 'current' : 'todo'
},
{
key: 'userReceive',
title: '用户收货',
time: this.afterSale.finishTime,
desc: logisticsState >= LOGISTICS_STATE.USER_RECEIVED ? '用户已收到换货商品' : '等待用户收货',
state: logisticsState >= LOGISTICS_STATE.USER_RECEIVED ? 'done' : logisticsState === LOGISTICS_STATE.MERCHANT_SHIPPED ? 'current' : 'todo'
}
)
}
steps.push({
key: 'finish',
title: '售后完成',
time: this.afterSale.finishTime,
desc: serviceState === SERVICE_STATE.FINISHED ? '售后流程已完成' : '售后流程未完成',
state: serviceState === SERVICE_STATE.FINISHED ? 'done' : !this.isExchange && logisticsState === LOGISTICS_STATE.MERCHANT_RECEIVED ? 'current' : 'todo'
})
return this.normalizeSteps(steps)
},
showReturnInfo() {
return Number(this.afterSale.serviceState) >= SERVICE_STATE.APPROVED && !this.isUnsignedAfterSale
},
returnInfoTitle() {
return Number(this.afterSale.logisticsState) >= LOGISTICS_STATE.WAIT_USER_SHIP ? '退货地址' : '取件地址'
},
pickupMethodText() {
return this.afterSale.pickupMethod === 'mail' ? '自行寄回' : '上门取件'
},
pickupAddressText() {
return this.afterSale.pickupAddress || '-'
},
merchantActions() {
const serviceState = Number(this.afterSale.serviceState)
if (serviceState === SERVICE_STATE.PENDING_AUDIT) {
return [
{ key: 'reject', text: '拒绝' },
{ key: 'approve', text: '通过', primary: true }
]
}
if (Number(this.afterSale.serviceState) === SERVICE_STATE.USER_SHIPPED) {
return [{ key: 'receive', text: '确认收货', primary: true }]
}
if (this.canShipExchange) {
return [{ key: 'shipExchange', text: '换货发货', primary: true }]
}
return []
},
canShipExchange() {
return this.isExchange &&
(Number(this.afterSale.serviceState) === SERVICE_STATE.RECEIVED ||
(this.afterSale.isSigned === false && Number(this.afterSale.serviceState) === SERVICE_STATE.APPROVED))
}
},
created() {
this.loadDetail()
},
methods: {
...mapActions(useUserStore, ['fetchBridgeToken']),
normalizeSteps(steps) {
const serviceState = Number(this.afterSale.serviceState)
return steps.map((step, index) => ({
...step,
state: serviceState === SERVICE_STATE.REJECTED && index > 1 ? 'todo' : step.state,
subTitle: step.state === 'done' ? '' : '-'
}))
},
skuText(item = {}) {
const sku = String(item.sku || item.skuText || '').trim()
const parts = sku.split('-').slice(-2).filter(Boolean)
const color = SKU_COLOR_MAP[parts[0]] || parts[0]
const size = SKU_SIZE_MAP[parts[1]] || parts[1]
return color || size ? [color, size].filter(Boolean).join(' | ') : '-'
},
maskedMobile(mobile = '') {
return String(mobile).replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2') || '-'
},
async loadDetail() {
try {
this.loading = true
await this.fetchBridgeToken()
const data = await postFunction('merchant-after-sale-detail', {
supplierId: this.supplierId,
serviceOrderId: String(this.$route.query.serviceOrderId || '')
})
this.afterSale = data?.afterSale || {}
} catch (error) {
Toast(error.message || '查询售后详情失败,请稍后再试')
} finally {
this.loading = false
}
},
copyServiceNo() {
if (navigator.clipboard && this.afterSale.serviceOrderId) {
navigator.clipboard.writeText(this.afterSale.serviceOrderId)
}
Toast('已复制')
},
showAuditRemark() {
Dialog.alert({
title: '审核意见',
message: this.afterSale.auditRemark
})
},
handleAction(key) {
if (key === 'approve') {
this.auditAfterSale(1)
return
}
if (key === 'reject') {
this.auditAfterSale(2)
return
}
if (key === 'receive') {
this.receiveAfterSale()
return
}
if (key === 'shipExchange') {
this.openExchangeShipPopup()
}
},
async auditAfterSale(auditStatus) {
await Dialog.confirm({
title: auditStatus === 1 ? '确认通过售后申请?' : '确认拒绝售后申请?',
message: auditStatus === 1 ? '通过后用户可寄回商品。' : '拒绝后该售后申请将结束。'
})
await postFunction('merchant-after-sale-audit', {
supplierId: this.supplierId,
serviceOrderId: this.afterSale.serviceOrderId,
auditStatus,
auditRemark: auditStatus === 1 ? '审核通过' : '商家拒绝'
})
Toast('处理成功')
await this.loadDetail()
},
async receiveAfterSale() {
await Dialog.confirm({
title: '确认已收到寄回商品?',
message: '确认后退货售后会进入完成,换货售后会进入待商家寄货。'
})
await postFunction('merchant-after-sale-receive', {
supplierId: this.supplierId,
serviceOrderId: this.afterSale.serviceOrderId
})
Toast('已确认收货')
await this.loadDetail()
},
openExchangeShipPopup() {
this.exchangeShipForm = {
logisticsCompany: '',
logisticsNo: ''
}
this.showExchangeShipPopup = true
},
async submitExchangeShip() {
if (!this.exchangeShipForm.logisticsCompany || !this.exchangeShipForm.logisticsNo) {
Toast('请填写完整发货信息')
return
}
await postFunction('merchant-after-sale-ship', {
supplierId: this.supplierId,
serviceOrderId: this.afterSale.serviceOrderId,
logisticsCompany: this.exchangeShipForm.logisticsCompany,
logisticsNo: this.exchangeShipForm.logisticsNo
})
Toast('换货发货成功')
this.showExchangeShipPopup = false
await this.loadDetail()
}
}
}
</script>
<style scoped lang="less">
.after-sale-detail-page {
min-height: 100vh;
padding: 56px 10px 78px;
background: #f7f8fa;
}
.after-sale-detail-page ::v-deep .van-nav-bar {
position: fixed;
z-index: 20;
top: 0;
right: 0;
left: 0;
}
.status-card,
.skeleton-card,
.detail-card {
margin-bottom: 10px;
border-radius: 8px;
background: #fff;
}
.skeleton-card {
padding: 18px 14px;
}
.status-card {
display: flex;
gap: 12px;
padding: 18px 14px;
}
.status-icon {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 42px;
height: 42px;
border-radius: 50%;
color: #fff;
font-size: 24px;
background: #ff9f1a;
}
.status-card.approved .status-icon,
.status-card.finished .status-icon {
background: #20b879;
}
.status-card.rejected .status-icon {
background: #ee3f4d;
}
.status-card h2 {
margin: 0 0 6px;
color: #222;
font-size: 18px;
}
.status-card p {
margin: 0;
color: #666;
font-size: 13px;
line-height: 20px;
}
.status-card button {
margin-top: 8px;
border: 0;
color: #ff5b20;
font-size: 12px;
background: transparent;
}
.info-card button {
flex: none;
align-self: center;
height: 24px;
margin-left: 8px;
padding: 0;
border: 0;
color: #ff5b20;
font-size: 12px;
background: transparent;
}
.detail-card {
padding: 14px 12px;
}
.detail-card h3 {
margin: 0 0 12px;
color: #222;
font-size: 15px;
}
.timeline-item {
position: relative;
padding-left: 24px;
padding-bottom: 16px;
}
.timeline-item::before {
content: '';
position: absolute;
top: 12px;
bottom: -2px;
left: 6px;
width: 1px;
background: #e5e5e5;
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-item:last-child::before {
display: none;
}
.timeline-dot {
position: absolute;
top: 2px;
left: 0;
display: flex;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
border-radius: 50%;
color: #fff;
font-size: 10px;
background: #d8d8d8;
}
.timeline-item.done .timeline-dot {
background: #20b879;
}
.timeline-item.current .timeline-dot {
background: #ff5b20;
}
.timeline-main {
display: flex;
justify-content: space-between;
gap: 10px;
}
.timeline-main b {
color: #222;
font-size: 14px;
}
.timeline-main em,
.timeline-item p {
color: #999;
font-size: 12px;
font-style: normal;
}
.timeline-item p {
margin: 6px 0 0;
}
.product-row {
display: flex;
gap: 10px;
padding: 10px 0;
}
.product-thumb {
flex: none;
width: 72px;
height: 72px;
border-radius: 6px;
overflow: hidden;
}
.product-info {
flex: 1;
min-width: 0;
}
.product-info h4 {
margin: 0 0 6px;
color: #222;
font-size: 14px;
}
.product-info p {
margin: 0 0 8px;
color: #999;
font-size: 12px;
}
.product-info b {
color: #ff5b20;
font-size: 14px;
}
.product-row > span {
color: #999;
font-size: 12px;
}
.address-card p,
.info-card p {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 10px 0 0;
color: #999;
font-size: 13px;
}
.address-card b,
.info-card b {
flex: 1;
color: #333;
font-weight: 500;
text-align: right;
word-break: break-all;
}
.info-card .service-no-row {
align-items: center;
}
.info-card .scrollable-no {
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
white-space: nowrap;
word-break: normal;
-webkit-overflow-scrolling: touch;
}
.info-card .scrollable-no::-webkit-scrollbar {
display: none;
}
.voucher-block h4 {
margin: 14px 0 8px;
color: #333;
font-size: 13px;
}
.voucher-block div {
display: flex;
gap: 8px;
}
.voucher-block .van-image {
width: 72px;
height: 72px;
border-radius: 6px;
overflow: hidden;
}
.bottom-actions {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 10;
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -4px 14px rgba(0, 0, 0, 0.06);
}
.bottom-actions button,
.action-popup button {
min-width: 96px;
height: 36px;
border: 1px solid #ddd;
border-radius: 18px;
color: #333;
font-size: 13px;
background: #fff;
}
.bottom-actions button.primary,
.action-popup button {
border-color: #ff5b20;
color: #fff;
background: #ff5b20;
}
.action-popup {
padding: 18px 14px 22px;
}
.action-popup h3 {
margin: 0 0 12px;
color: #222;
font-size: 16px;
text-align: center;
}
.action-popup button {
width: 100%;
margin-top: 12px;
}
</style>