feat: 积分商城-完成

This commit is contained in:
2026-07-31 19:08:15 +08:00
parent 898e239463
commit 20d059443c
21 changed files with 2122 additions and 181 deletions
+8 -1
View File
@@ -1,3 +1,10 @@
# points-mall
积分商城-供应商模块-订单操作模块
积分商城-供应商模块-订单操作模块
## 页面与接口
- 商家处理台:`http://localhost:3001/#/joyful-selection/merchant`
- 供应商日志页面:`http://localhost:3001/#/joyful-selection/supplier-logs`
- 供应商日志接口:`GET http://localhost:8888/api/supplier-logs`
- 日志内容包含:当前后端接收请求、向zhgh发送请求、接收zhgh请求。
+4
View File
@@ -20,6 +20,8 @@ import JoyfulSelectionAfterSaleSelect from '@/view/joyful-selection/after-sale/s
import JoyfulSelectionAfterSaleReturn from '@/view/joyful-selection/after-sale/return/index.vue'
import JoyfulSelectionAfterSaleExchange from '@/view/joyful-selection/after-sale/exchange/index.vue'
import JoyfulSelectionMerchant from '@/view/joyful-selection/merchant/index.vue'
import JoyfulSelectionMerchantAfterSaleDetail from '@/view/joyful-selection/merchant/after-sale-detail.vue'
import JoyfulSelectionSupplierLogs from '@/view/joyful-selection/supplier-logs/index.vue'
import JoyfulSelectionProfile from '@/view/joyful-selection/profile/index.vue'
import JoyfulSelectionPoints from '@/view/joyful-selection/profile/points/index.vue'
import HomeLiving from '@/view/home-living/index.vue'
@@ -55,6 +57,8 @@ export default new Router({
{ path: 'after-sale/return', component: JoyfulSelectionAfterSaleReturn },
{ path: 'after-sale/exchange', component: JoyfulSelectionAfterSaleExchange },
{ path: 'merchant', component: JoyfulSelectionMerchant },
{ path: 'merchant/after-sale-detail', component: JoyfulSelectionMerchantAfterSaleDetail },
{ path: 'supplier-logs', component: JoyfulSelectionSupplierLogs },
{ path: 'profile', component: JoyfulSelectionProfile },
{ path: 'points', component: JoyfulSelectionPoints }
]
+53
View File
@@ -0,0 +1,53 @@
import { defineStore } from 'pinia'
const UI_KEY = 'joyful-selection-ui'
const readUi = () => {
try {
return JSON.parse(localStorage.getItem(UI_KEY) || '{}')
} catch (error) {
return {}
}
}
const saveUi = data => {
localStorage.setItem(UI_KEY, JSON.stringify(data))
}
export const useUiStore = defineStore('joyfulSelectionUi', {
state: () => ({
ordersTab: 0,
merchantTab: 'ship',
afterSaleTab: 'processing',
loaded: false
}),
actions: {
loadUi() {
const data = readUi()
this.ordersTab = Number(data.ordersTab || 0)
this.merchantTab = data.merchantTab || 'ship'
this.afterSaleTab = data.afterSaleTab || 'processing'
this.loaded = true
},
persistUi() {
saveUi({
ordersTab: this.ordersTab,
merchantTab: this.merchantTab,
afterSaleTab: this.afterSaleTab
})
this.loaded = true
},
setOrdersTab(value) {
this.ordersTab = Number(value || 0)
this.persistUi()
},
setMerchantTab(value) {
this.merchantTab = value || 'ship'
this.persistUi()
},
setAfterSaleTab(value) {
this.afterSaleTab = value || 'processing'
this.persistUi()
}
}
})
@@ -1,6 +1,6 @@
<template>
<main class="after-sale-detail-page">
<van-nav-bar title="售后详情" left-arrow @click-left="$router.back()" />
<van-nav-bar title="售后详情" left-arrow @click-left="$router.replace('/joyful-selection/after-sale')" />
<section v-if="loading" class="skeleton-card">
<van-skeleton title :row="3" />
@@ -64,13 +64,14 @@
<section v-show="!loading" class="detail-card info-card">
<h3>售后信息</h3>
<p><span>售后单号</span><b>{{ afterSale.serviceOrderId || '-' }}</b><button type="button" @click="copyServiceNo">复制</button></p>
<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><span>取件方式</span><b>{{ pickupMethodText }}</b></p>
<p><span>取件地址</span><b>{{ pickupAddressText }}</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>
@@ -82,6 +83,7 @@
<footer v-show="!loading" class="bottom-actions">
<button v-if="canCancel" type="button">撤销申请</button>
<button v-if="needUserShipNo" type="button" class="primary" @click="fillLogisticsNo">填写物流单号</button>
<button v-else-if="canConfirmExchangeReceive" type="button" class="primary" @click="confirmExchangeReceive">确认收货</button>
<button v-else type="button" class="primary" @click="contactService">联系客服</button>
</footer>
@@ -162,6 +164,18 @@ export default {
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'
@@ -178,8 +192,8 @@ export default {
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.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 '审核中'
@@ -189,9 +203,10 @@ export default {
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.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 '商家正在审核您的售后申请,请耐心等待'
},
@@ -229,14 +244,35 @@ export default {
}
]
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 steps.map((step, index) => ({
...step,
state: serviceState === SERVICE_STATE.REJECTED && index > 1 ? 'todo' : step.state,
subTitle: step.state === 'done' ? '' : '-'
}))
}
if (this.isExchange) {
if (this.isUnsignedAfterSale) {
steps.splice(2)
}
steps.push(
{
key: 'merchantShip',
title: '商家寄货',
title: '商家发货',
time: this.afterSale.merchantShipTime,
desc: logisticsState >= LOGISTICS_STATE.MERCHANT_SHIPPED ? '商家已寄出换货商品' : '等待商家寄出换货商品',
state: logisticsState >= LOGISTICS_STATE.MERCHANT_SHIPPED ? 'done' : logisticsState === LOGISTICS_STATE.MERCHANT_RECEIVED ? 'current' : 'todo'
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',
@@ -263,7 +299,7 @@ export default {
}))
},
showReturnInfo() {
return Number(this.afterSale.serviceState) >= SERVICE_STATE.APPROVED
return Number(this.afterSale.serviceState) >= SERVICE_STATE.APPROVED && !this.isUnsignedAfterSale
},
returnInfoTitle() {
return Number(this.afterSale.logisticsState) >= LOGISTICS_STATE.WAIT_USER_SHIP ? '退货地址' : '取件地址'
@@ -274,6 +310,9 @@ export default {
canCancel() {
return Number(this.afterSale.serviceState) === SERVICE_STATE.PENDING_AUDIT
},
canConfirmExchangeReceive() {
return this.isExchange && Number(this.afterSale.logisticsState) === LOGISTICS_STATE.MERCHANT_SHIPPED
},
pickupMethodText() {
return this.afterSale.pickupMethod === 'mail' ? '自行寄回' : '上门取件'
},
@@ -367,6 +406,26 @@ export default {
this.submittingLogistics = false
}
},
async confirmExchangeReceive() {
await Dialog.confirm({
title: '确认收到换货商品?',
message: '确认后该换货售后将完成。'
})
try {
await this.fetchBridgeToken()
await postFunction('after-sale-logistics', {
supplierId: this.supplierId,
userId: this.userId,
serviceOrderId: this.afterSale.serviceOrderId,
logisticsState: LOGISTICS_STATE.USER_RECEIVED
})
Toast('已确认收货')
await this.loadDetail()
} catch (error) {
Toast(error.message || '确认收货失败,请稍后再试')
}
},
contactService() {
Toast('客服功能开发中')
}
@@ -643,6 +702,10 @@ export default {
color: #555;
}
.info-card .service-no-row {
align-items: center;
}
.address-card b,
.info-card b {
min-width: 0;
@@ -653,15 +716,29 @@ export default {
overflow-wrap: anywhere;
}
.info-card .scrollable-no {
overflow-x: auto;
scrollbar-width: none;
white-space: nowrap;
word-break: normal;
overflow-wrap: normal;
-webkit-overflow-scrolling: touch;
}
.info-card .scrollable-no::-webkit-scrollbar {
display: none;
}
.info-card button {
flex: none;
align-self: center;
height: 24px;
margin-left: 8px;
padding: 0 8px;
padding: 0;
border: 0;
border-radius: 4px;
color: #ff5b20;
font-size: 12px;
background: #fff0e9;
background: transparent;
}
.address-card em {
@@ -21,36 +21,15 @@
</article>
</section>
<section v-show="!loading" class="exchange-card">
<div class="exchange-head">
<h3>换货商品</h3>
<button type="button" @click="openExchangeGoodsPopup">{{ selectedExchangeGoods.sku ? '重新选择' : '选择商品' }}</button>
<section v-show="!loading" class="form-card">
<div class="method-row">
<span>是否签收</span>
<button type="button" :class="{ active: isSigned === false }" @click="isSigned = false">未签收</button>
<button type="button" :class="{ active: isSigned === true }" @click="isSigned = true">已签收</button>
</div>
<article v-if="selectedExchangeGoods.sku" class="product-row">
<van-image class="product-thumb" :src="selectedExchangeGoods.image" fit="cover" />
<div class="product-info">
<h4>{{ selectedExchangeGoods.name || '-' }}</h4>
<p>{{ skuText(selectedExchangeGoods) }}</p>
<b>¥{{ selectedExchangeGoods.price || '0.00' }}</b>
</div>
<span>x1</span>
</article>
<p v-else>请选择与原商品价格一致的换货商品</p>
</section>
<section v-show="!loading" class="form-card">
<div class="form-row reason-row" @click="showReasonPopup = true">
<span>售后原因</span>
<div class="reason-value">
<b>{{ selectedReasonText }}</b>
<van-icon name="arrow" />
</div>
</div>
<div class="desc-block">
<h3>问题描述</h3>
<textarea v-model="description" maxlength="200" placeholder="请详细描述遇到的问题(选填)"></textarea>
<em>{{ description.length }}/200</em>
</div>
<section v-show="!loading && isSigned" class="form-card">
<div class="method-row">
<span>取件方式</span>
<button type="button" :class="{ active: pickupMethod === 'door' }" @click="pickupMethod = 'door'">上门取件</button>
@@ -64,6 +43,44 @@
</div>
<van-icon name="arrow" />
</div>
</section>
<section v-show="!loading" class="exchange-card">
<div class="exchange-head method-row">
<h3>换货商品</h3>
</div>
<div class="exchange-body">
<article v-if="selectedExchangeGoods.sku" class="product-row">
<van-image class="product-thumb" :src="selectedExchangeGoods.image" fit="cover" />
<div class="product-info">
<h4>{{ selectedExchangeGoods.name || '-' }}</h4>
<p>{{ skuText(selectedExchangeGoods) }}</p>
<b>¥{{ selectedExchangeGoods.price || '0.00' }}</b>
</div>
<span>x1</span>
</article>
<p v-else>请选择与原商品价格一致的换货商品</p>
<button type="button" @click="openExchangeGoodsPopup">{{ selectedExchangeGoods.sku ? '重新选择' : '选择商品' }}</button>
</div>
</section>
<section v-show="!loading" class="form-card">
<div class="form-row reason-row" @click="showReasonPopup = true">
<span>售后原因</span>
<div class="reason-value">
<b>{{ selectedReasonText }}</b>
<van-icon name="arrow" />
</div>
</div>
<div class="desc-block">
<h3>问题描述</h3>
<textarea v-model="description" maxlength="200" placeholder="请详细描述遇到的问题(选填)"></textarea>
<em>{{ description.length }}/200</em>
</div>
<div class="upload-block">
<h3>上传图片(最多3张)</h3>
<van-uploader
@@ -99,7 +116,7 @@
</van-radio-group>
</section>
</van-popup>
<van-popup v-model="showExchangeGoodsPopup" round position="bottom">
<van-popup v-model="showExchangeGoodsPopup" round position="bottom" :style="{ height: '80vh' }">
<section class="exchange-popup">
<h3>选择换货商品</h3>
<p>仅可选择价格为 ¥{{ originalProductPrice }} 的商品</p>
@@ -130,6 +147,11 @@ const ORDER_EVENT_TYPE = {
EXCHANGE_ORDER: 5
}
const ORDER_STATE = {
RECEIVED: 4,
COMPLETED: 5
}
const SKU_COLOR_MAP = {
cream: '奶油白',
blue: '雾霾蓝',
@@ -153,21 +175,22 @@ const REASON_OPTIONS = [
export default {
name: 'JoyfulSelectionAfterSaleExchange',
data: () => ({
loading: false,
currentOrder: {},
currentSubOrder: {},
pickupAddress: {},
description: '',
pickupMethod: 'door',
showReasonPopup: false,
showExchangeGoodsPopup: false,
selectedReason: '',
exchangeGoods: [],
selectedExchangeGoods: {},
fileList: [],
uploadedImages: [],
submitting: false
}),
loading: false,
currentOrder: {},
currentSubOrder: {},
pickupAddress: {},
description: '',
pickupMethod: 'door',
showReasonPopup: false,
showExchangeGoodsPopup: false,
selectedReason: '',
exchangeGoods: [],
selectedExchangeGoods: {},
fileList: [],
uploadedImages: [],
submitting: false,
isSigned: false
}),
computed: {
...mapState(useUserStore, ['supplierId', 'userId']),
products() {
@@ -177,7 +200,9 @@ export default {
return Number(this.products[0]?.price || this.currentSubOrder.amount || 0).toFixed(2)
},
samePriceGoods() {
return this.exchangeGoods.filter(item => Number(item.price || 0).toFixed(2) === this.originalProductPrice)
return this.exchangeGoods
.map(item => this.normalizeExchangeGoods(item))
.filter(item => Number(item.price || 0).toFixed(2) === this.originalProductPrice)
},
receiverNameText() {
return this.pickupAddress.name || this.currentSubOrder.receiverName || this.currentOrder.receiverName || '-'
@@ -197,6 +222,12 @@ export default {
},
selectedReasonText() {
return this.reasonOptions.find(item => item.value === this.selectedReason)?.label || '请选择售后原因'
},
hasProductShipped() {
// Assuming currentSubOrder.logisticsState indicates if the product has been shipped to the user.
// If the product is not in 'NONE' (0) or 'WAIT_USER_SHIP' (1) state, it's considered shipped.
const logisticsState = Number(this.currentSubOrder.logisticsState);
return logisticsState !== 0 && logisticsState !== 1;
}
},
created() {
@@ -204,18 +235,35 @@ export default {
this.loadPickupAddress()
},
activated() {
this.loadOrderDetail()
this.loadPickupAddress()
},
methods: {
...mapActions(useUserStore, ['fetchBridgeToken']),
skuText(item = {}) {
const sku = String(item.sku || item.skuText || '').trim()
if (item.skuText) {
return item.skuText
}
const sku = String(item.sku || '').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(' | ') : '-'
},
normalizeExchangeGoods(item = {}) {
const color = 'cream'
const size = 'standard'
const sku = item.sku || `${item.id}-${color}-${size}`
const skuText = [SKU_COLOR_MAP[color] || color, SKU_SIZE_MAP[size] || size].filter(Boolean).join(' | ')
return {
...item,
sku,
skuText
}
},
selectReason(item) {
this.selectedReason = item.value
this.showReasonPopup = false
@@ -242,6 +290,20 @@ export default {
this.selectedExchangeGoods = item
this.showExchangeGoodsPopup = false
},
inferIsSigned(order = {}, subOrder = {}) {
const state = Number(subOrder.orderState ?? order.orderState)
return [ORDER_STATE.RECEIVED, ORDER_STATE.COMPLETED].includes(state)
},
checkSignedMatch() {
const realSigned = this.inferIsSigned(this.currentOrder, this.currentSubOrder)
if (this.isSigned !== realSigned) {
Toast(realSigned ? '该订单已签收,请选择已签收' : '该订单未签收,请选择未签收')
return false
}
return true
},
async afterRead(fileItems) {
const items = Array.isArray(fileItems) ? fileItems : [fileItems]
await Promise.all(items.map(async item => {
@@ -300,6 +362,7 @@ export default {
this.currentOrder = data?.order || {}
this.currentSubOrder = data?.subOrder || {}
this.isSigned = this.inferIsSigned(this.currentOrder, this.currentSubOrder)
} catch (error) {
Toast(error.message || '查询订单详情失败,请稍后再试')
} finally {
@@ -321,11 +384,30 @@ export default {
Toast('订单信息不完整,请稍后再试')
return
}
if (!this.checkSignedMatch()) {
return
}
if (!this.selectedExchangeGoods.sku) {
Toast('请选择换货商品')
return
}
// 售后原因必填
if (!this.selectedReason || this.selectedReason === '') {
Toast('请选择售后原因');
return;
}
// 如果已签收,且取件方式是上门取件,则取件信息必填
if (this.isSigned && this.pickupMethod === 'door') {
if (!this.receiverNameText || this.receiverNameText === '-' ||
!this.receiverMobileText || this.receiverMobileText === '-' ||
!this.receiverAddressText || this.receiverAddressText === '-') {
Toast('请填写完整的取件信息');
return;
}
}
const applied = await postFunction('after-sale-apply', {
supplierId: this.supplierId,
userId: this.userId,
@@ -337,17 +419,17 @@ export default {
reason: this.selectedReasonText === '请选择售后原因' ? '换货' : this.selectedReasonText,
reasonType: this.selectedReason || 'OTHER',
problemDescription: this.description,
pickupMethod: this.pickupMethod,
pickupName: this.receiverNameText === '-' ? '' : this.receiverNameText,
pickupMobile: this.receiverMobileText === '-' ? '' : this.receiverMobileText,
pickupAddress: this.receiverAddressText === '-' ? '' : this.receiverAddressText,
pickupMethod: this.isSigned ? this.pickupMethod : undefined, // Conditionally set
pickupName: this.isSigned && this.receiverNameText !== '-' ? this.receiverNameText : undefined, // Conditionally set
pickupMobile: this.isSigned && this.receiverMobileText !== '-' ? this.receiverMobileText : undefined, // Conditionally set
pickupAddress: this.isSigned && this.receiverAddressText !== '-' ? this.receiverAddressText : undefined, // Conditionally set
exchangeProductSku: this.selectedExchangeGoods.sku,
exchangeProductName: this.selectedExchangeGoods.name,
exchangeProductPrice: this.selectedExchangeGoods.price,
images: this.uploadedImages
})
Toast('换货申请已提交,等待商家审核')
Toast('敬请期待')
const serviceOrderId = applied?.serviceOrderId || applied?.afterSale?.serviceOrderId
if (serviceOrderId) {
this.$router.replace({
@@ -387,7 +469,7 @@ export default {
.exchange-card,
.form-card {
margin-top: 10px;
padding: 14px 12px;
padding: 0 12px 5px;
border-radius: 8px;
background: #fff;
}
@@ -405,32 +487,55 @@ export default {
.exchange-head {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 8px;
min-height: 42px;
padding: 4px 0;
}
.exchange-head h3 {
margin-bottom: 0;
line-height: 20px;
}
.exchange-head button {
height: 30px;
padding: 0 14px;
.exchange-body {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-bottom: 8px;
}
.exchange-body button {
align-self: center;
flex: none;
min-width: 92px;
height: 34px;
padding: 0 16px;
border: 1px solid #ff5b20;
border-radius: 15px;
border-radius: 6px;
color: #ff5b20;
font-size: 12px;
line-height: 1;
background: #fff7f2;
}
.exchange-card > p {
margin: 12px 0 0;
.exchange-body > p {
flex: 1;
margin: 0;
color: #999;
font-size: 13px;
line-height: 20px;
}
.exchange-card .product-row {
margin-top: 14px;
flex: 1;
margin-top: 0;
}
.exchange-card .product-info {
padding-left: 8px;
padding-right: 6px;
}
.product-row {
@@ -483,7 +588,6 @@ export default {
display: flex;
align-items: center;
min-height: 46px;
border-bottom: 1px solid #f2f2f2;
color: #222;
font-size: 13px;
}
@@ -740,9 +844,12 @@ export default {
}
.exchange-popup {
max-height: 72vh;
display: flex;
height: 100%;
flex-direction: column;
overflow: hidden;
padding: 16px 12px 12px;
background: #f7f8fa;
}
.exchange-popup h3 {
@@ -754,6 +861,7 @@ export default {
}
.exchange-popup > p {
flex: none;
margin: 0 0 12px;
color: #999;
font-size: 12px;
@@ -761,59 +869,96 @@ export default {
}
.exchange-goods-list {
max-height: calc(72vh - 68px);
flex: 1;
min-height: 0;
overflow-y: auto;
padding-bottom: 4px;
}
.exchange-goods-item {
display: flex;
min-height: 116px;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f3f3f3;
gap: 12px;
padding: 10px 12px 10px 10px;
border-radius: 14px;
background: #fff;
box-shadow: 0 4px 18px rgba(0, 0, 0, .045);
}
.exchange-goods-item + .exchange-goods-item {
margin-top: 10px;
}
.exchange-goods-thumb {
width: 58px;
height: 58px;
width: 88px;
height: 88px;
min-width: 88px;
max-width: 88px;
min-height: 88px;
max-height: 88px;
aspect-ratio: 1 / 1;
flex: none;
overflow: hidden;
border-radius: 5px;
background: #f5f5f5;
border-radius: 10px;
background: #f6f6f6;
}
.exchange-goods-thumb ::v-deep .van-image__img,
.exchange-goods-thumb ::v-deep img {
display: block;
width: 88px;
height: 88px;
border-radius: 10px;
object-fit: cover;
}
.exchange-goods-item > div {
display: flex;
min-width: 0;
flex: 1;
padding: 0 10px;
align-self: stretch;
flex-direction: column;
justify-content: space-between;
padding: 4px 0;
}
.exchange-goods-item h4 {
display: -webkit-box;
overflow: hidden;
margin: 0 0 5px;
color: #222;
font-size: 14px;
line-height: 18px;
margin: 0;
color: #111;
font-size: 15px;
font-weight: 700;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.exchange-goods-item p {
overflow: hidden;
margin: 0 0 5px;
color: #999;
width: fit-content;
max-width: 100%;
margin: 6px 0 0;
padding: 2px 6px;
border-radius: 4px;
color: #8f8f8f;
font-size: 12px;
line-height: 16px;
background: #f5f6f8;
text-overflow: ellipsis;
white-space: nowrap;
}
.exchange-goods-item b {
color: #ff5b20;
font-size: 14px;
font-size: 18px;
}
.exchange-goods-item > .van-icon {
flex: none;
color: #ff5b20;
font-size: 18px;
font-size: 20px;
}
</style>
@@ -1,6 +1,6 @@
<template>
<main class="after-sale-page">
<van-nav-bar title="退款/售后" left-arrow @click-left="$router.back()" />
<van-nav-bar title="退款/售后" left-arrow @click-left="$router.replace('/joyful-selection/profile')" />
<van-pull-refresh v-model="refreshing" class="after-sale-list" @refresh="onRefresh">
<van-tabs v-model="activeTab" color="#ff5b20">
@@ -21,6 +21,7 @@
<span>{{ item.serviceTypeText }}</span>
<b>{{ item.serviceStateText }}</b>
</header>
<span class="signed-stamp" :class="{ unsigned: item.isSigned === false }">{{ item.isSigned === false ? '未签收' : '已签收' }}</span>
<div class="after-sale-card__body">
<div
@@ -40,6 +41,7 @@
<div class="after-sale-meta">
<p><span>售后单号</span><b>{{ item.serviceOrderId }}</b></p>
<p><span>子订单号</span><b>{{ item.orderNumberId || item.subOrderId || '-' }}</b></p>
<p><span>申请时间</span><b>{{ item.applyTime || '-' }}</b></p>
<p><span>物流状态</span><b>{{ item.logisticsStateText }}</b></p>
<p v-if="item.reason"><span>申请原因</span><b>{{ item.reason }}</b></p>
@@ -56,6 +58,7 @@ import { mapActions, mapState } from 'pinia'
import { Toast } from 'vant'
import { postFunction } from '@/api/supplied-service'
import { useUserStore } from '@/store/user'
import { useUiStore } from '@/store/ui'
const SKU_COLOR_MAP = {
cream: '奶油白',
@@ -71,13 +74,21 @@ const SKU_SIZE_MAP = {
export default {
name: 'JoyfulSelectionAfterSale',
data: () => ({
activeTab: 'processing',
afterSaleItems: [],
loading: false,
refreshing: false
}),
computed: {
...mapState(useUserStore, ['supplierId', 'userId']),
...mapState(useUiStore, ['afterSaleTab']),
activeTab: {
get() {
return this.afterSaleTab
},
set(value) {
this.setAfterSaleTab(value)
}
},
currentAfterSaleItems() {
return this.afterSaleItems.filter(item => {
const serviceState = Number(item.serviceState)
@@ -91,10 +102,12 @@ export default {
}
},
created() {
this.loadUi()
this.loadAfterSales()
},
methods: {
...mapActions(useUserStore, ['fetchBridgeToken']),
...mapActions(useUiStore, ['loadUi', 'setAfterSaleTab']),
previewProducts(products = []) {
return products.slice(0, 3)
},
@@ -180,12 +193,48 @@ export default {
}
.after-sale-card {
position: relative;
overflow: hidden;
margin-bottom: 14px;
border-radius: 8px;
background: #fff;
}
.signed-stamp {
position: absolute;
z-index: 2;
right: 15px;
bottom: 25px;
display: flex;
width: 95px;
height: 60px;
align-items: center;
justify-content: center;
border: 2px solid rgba(255, 91, 32, .8);
border-radius: 50%;
color: #ff5b20;
font-size: 14px;
font-weight: 700;
line-height: 18px;
opacity: .9;
background: rgba(255, 241, 232, .55);
transform: rotate(-18deg);
}
.signed-stamp::before {
position: absolute;
inset: 4px;
border: 1px dashed currentColor;
border-radius: 50%;
content: '';
}
.signed-stamp.unsigned {
border-color: rgba(153, 153, 153, .9);
color: #666;
background: rgba(246, 247, 249, .7);
}
.skeleton-card {
padding: 14px 12px;
border-radius: 8px;
@@ -211,6 +260,15 @@ export default {
font-size: 13px;
}
.after-sale-card__header span {
padding: 3px 8px;
border-radius: 4px;
color: #ff5b20;
font-size: 12px;
line-height: 16px;
background: #fff0e9;
}
.after-sale-card__body {
display: flex;
flex-direction: column;
@@ -20,20 +20,15 @@
<span>x{{ item.number || 1 }}</span>
</article>
</section>
<section v-show="!loading" class="form-card">
<div class="form-row reason-row" @click="showReasonPopup = true">
<span>售后原因</span>
<div class="reason-value">
<b>{{ selectedReasonText }}</b>
<van-icon name="arrow" />
</div>
</div>
<div class="desc-block">
<h3>问题描述</h3>
<textarea v-model="description" maxlength="200" placeholder="请详细描述遇到的问题(选填)"></textarea>
<em>{{ description.length }}/200</em>
<div class="method-row">
<span>是否签收</span>
<button type="button" :class="{ active: isSigned === false }" @click="isSigned = false">未签收</button>
<button type="button" :class="{ active: isSigned === true }" @click="isSigned = true">已签收</button>
</div>
</section>
<section v-show="!loading && isSigned" class="form-card">
<div class="method-row">
<span>取件方式</span>
<button type="button" :class="{ active: pickupMethod === 'door' }" @click="pickupMethod = 'door'">上门取件</button>
@@ -47,6 +42,25 @@
</div>
<van-icon name="arrow" />
</div>
</section>
<section v-show="!loading" class="form-card">
<div class="form-row reason-row" @click="showReasonPopup = true">
<span>售后原因</span>
<div class="reason-value">
<b>{{ selectedReasonText }}</b>
<van-icon name="arrow" />
</div>
</div>
<div class="desc-block">
<h3>问题描述</h3>
<textarea v-model="description" maxlength="200" placeholder="请详细描述遇到的问题(选填)"></textarea>
<em>{{ description.length }}/200</em>
</div>
<div class="upload-block">
<h3>上传图片(最多3张)</h3>
<van-uploader
@@ -95,6 +109,11 @@ const ORDER_EVENT_TYPE = {
RETURN_ORDER: 4
}
const ORDER_STATE = {
RECEIVED: 4,
COMPLETED: 5
}
const SKU_COLOR_MAP = {
cream: '奶油白',
blue: '雾霾蓝',
@@ -125,18 +144,19 @@ const REASON_OPTIONS = [
export default {
name: 'JoyfulSelectionAfterSaleReturn',
data: () => ({
loading: false,
currentOrder: {},
currentSubOrder: {},
pickupAddress: {},
description: '',
pickupMethod: 'door',
showReasonPopup: false,
selectedReason: '',
fileList: [],
uploadedImages: [],
submitting: false
}),
loading: false,
currentOrder: {},
currentSubOrder: {},
pickupAddress: {},
description: '',
pickupMethod: 'door',
showReasonPopup: false,
selectedReason: '',
fileList: [],
uploadedImages: [],
submitting: false,
isSigned: false
}),
computed: {
...mapState(useUserStore, ['supplierId', 'userId']),
products() {
@@ -160,6 +180,12 @@ export default {
},
selectedReasonText() {
return this.reasonOptions.find(item => item.value === this.selectedReason)?.label || '请选择售后原因'
},
hasProductShipped() {
// Assuming currentSubOrder.logisticsState indicates if the product has been shipped to the user.
// If the product is not in 'NONE' (0) or 'WAIT_USER_SHIP' (1) state, it's considered shipped.
const logisticsState = Number(this.currentSubOrder.logisticsState);
return logisticsState !== 0 && logisticsState !== 1;
}
},
created() {
@@ -167,6 +193,7 @@ export default {
this.loadPickupAddress()
},
activated() {
this.loadOrderDetail()
this.loadPickupAddress()
},
methods: {
@@ -183,6 +210,20 @@ export default {
this.selectedReason = item.value
this.showReasonPopup = false
},
inferIsSigned(order = {}, subOrder = {}) {
const state = Number(subOrder.orderState ?? order.orderState)
return [ORDER_STATE.RECEIVED, ORDER_STATE.COMPLETED].includes(state)
},
checkSignedMatch() {
const realSigned = this.inferIsSigned(this.currentOrder, this.currentSubOrder)
if (this.isSigned !== realSigned) {
Toast(realSigned ? '该订单已签收,请选择已签收' : '该订单未签收,请选择未签收')
return false
}
return true
},
async afterRead(fileItems) {
const items = Array.isArray(fileItems) ? fileItems : [fileItems]
await Promise.all(items.map(async item => {
@@ -241,6 +282,7 @@ export default {
this.currentOrder = data?.order || {}
this.currentSubOrder = data?.subOrder || {}
this.isSigned = this.inferIsSigned(this.currentOrder, this.currentSubOrder)
} catch (error) {
Toast(error.message || '查询订单详情失败,请稍后再试')
} finally {
@@ -262,6 +304,25 @@ export default {
Toast('订单信息不完整,请稍后再试')
return
}
if (!this.checkSignedMatch()) {
return
}
// 售后原因必填
if (!this.selectedReason || this.selectedReason === '') {
Toast('请选择售后原因');
return;
}
// 如果已签收,且取件方式是上门取件,则取件信息必填
if (this.isSigned && this.pickupMethod === 'door') {
if (!this.receiverNameText || this.receiverNameText === '-' ||
!this.receiverMobileText || this.receiverMobileText === '-' ||
!this.receiverAddressText || this.receiverAddressText === '-') {
Toast('请填写完整的取件信息');
return;
}
}
const applied = await postFunction('after-sale-apply', {
supplierId: this.supplierId,
@@ -274,10 +335,10 @@ export default {
reason: this.selectedReasonText === '请选择售后原因' ? '退货' : this.selectedReasonText,
reasonType: this.selectedReason || 'OTHER',
problemDescription: this.description,
pickupMethod: this.pickupMethod,
pickupName: this.receiverNameText === '-' ? '' : this.receiverNameText,
pickupMobile: this.receiverMobileText === '-' ? '' : this.receiverMobileText,
pickupAddress: this.receiverAddressText === '-' ? '' : this.receiverAddressText,
pickupMethod: this.isSigned ? this.pickupMethod : undefined, // Conditionally set
pickupName: this.isSigned && this.receiverNameText !== '-' ? this.receiverNameText : undefined, // Conditionally set
pickupMobile: this.isSigned && this.receiverMobileText !== '-' ? this.receiverMobileText : undefined, // Conditionally set
pickupAddress: this.isSigned && this.receiverAddressText !== '-' ? this.receiverAddressText : undefined, // Conditionally set
images: this.uploadedImages
})
@@ -320,7 +381,7 @@ export default {
.skeleton-card,
.form-card {
margin-top: 10px;
padding: 14px 12px;
padding: 0 12px 5px;
border-radius: 8px;
background: #fff;
}
@@ -384,7 +445,6 @@ export default {
display: flex;
align-items: center;
min-height: 46px;
border-bottom: 1px solid #f2f2f2;
color: #222;
font-size: 13px;
}
@@ -0,0 +1,767 @@
<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>
@@ -4,7 +4,7 @@
<van-pull-refresh v-model="refreshing" class="merchant-refresh" @refresh="onRefresh">
<van-tabs v-model="activeTab" sticky offset-top="46" color="#ff5b20" @change="loadCurrentTab">
<van-tab title="待发货" name="ship">
<van-tab title="待发货" name="ship" :badge="tabBadge('ship')">
<section class="list-panel">
<section v-if="loading" class="skeleton-card">
<van-skeleton title :row="3" />
@@ -21,7 +21,7 @@
<van-image class="product-thumb" :src="product.image" fit="cover" />
<div class="product-info">
<h4>{{ product.name || '-' }}</h4>
<p>{{ product.sku || '-' }}</p>
<p>{{ skuText(product) }}</p>
<b>¥{{ product.price }}</b>
</div>
<em>x{{ product.number || 1 }}</em>
@@ -40,7 +40,7 @@
</section>
</van-tab>
<van-tab title="售后审核" name="audit">
<van-tab title="售后审核" name="audit" :badge="tabBadge('audit')">
<section class="list-panel">
<section v-if="loading" class="skeleton-card">
<van-skeleton title :row="3" />
@@ -48,16 +48,17 @@
</section>
<van-empty v-else-if="auditItems.length === 0" description="暂无待审核售后" />
<template v-else>
<article v-for="item in auditItems" :key="item.serviceOrderId" class="work-card">
<article v-for="item in auditItems" :key="item.serviceOrderId" class="work-card audit-card" @click="goAfterSaleDetail(item)">
<header>
<b>{{ item.serviceOrderId }}</b>
<span>{{ item.serviceTypeText }}</span>
</header>
<span class="signed-stamp" :class="{ unsigned: item.isSigned === false }">{{ item.isSigned === false ? '未签收' : '已签收' }}</span>
<div v-for="product in item.products" :key="product.id" class="product-row">
<van-image class="product-thumb" :src="product.image" fit="cover" />
<div class="product-info">
<h4>{{ product.name || '-' }}</h4>
<p>{{ product.sku || '-' }}</p>
<p>{{ skuText(product) }}</p>
<b>¥{{ product.price }}</b>
</div>
<em>x{{ product.number || 1 }}</em>
@@ -69,15 +70,16 @@
<span>描述</span><b>{{ item.problemDescription || '-' }}</b>
</div>
<footer>
<button type="button" @click="auditAfterSale(item, 2)">拒绝</button>
<button type="button" class="primary" @click="auditAfterSale(item, 1)">通过</button>
<button type="button" @click.stop="goAfterSaleDetail(item)">查看详情</button>
<button type="button" @click.stop="auditAfterSale(item, 2)">拒绝</button>
<button type="button" class="primary" @click.stop="auditAfterSale(item, 1)">通过</button>
</footer>
</article>
</template>
</section>
</van-tab>
<van-tab title="售后处理" name="process">
<van-tab title="售后处理" name="process" :badge="tabBadge('process')">
<section class="list-panel">
<section v-if="loading" class="skeleton-card">
<van-skeleton title :row="3" />
@@ -85,16 +87,17 @@
</section>
<van-empty v-else-if="processItems.length === 0" description="暂无处理中售后" />
<template v-else>
<article v-for="item in processItems" :key="item.serviceOrderId" class="work-card">
<article v-for="item in processItems" :key="item.serviceOrderId" class="work-card" @click="goAfterSaleDetail(item)">
<header>
<b>{{ item.serviceOrderId }}</b>
<span>{{ item.serviceStateText }}</span>
</header>
<span class="signed-stamp" :class="{ unsigned: item.isSigned === false }">{{ item.isSigned === false ? '未签收' : '已签收' }}</span>
<div v-for="product in item.products" :key="product.id" class="product-row">
<van-image class="product-thumb" :src="product.image" fit="cover" />
<div class="product-info">
<h4>{{ product.name || '-' }}</h4>
<p>{{ product.sku || '-' }}</p>
<p>{{ skuText(product) }}</p>
<b>¥{{ product.price }}</b>
</div>
<em>x{{ product.number || 1 }}</em>
@@ -106,8 +109,49 @@
<span>取件地址</span><b>{{ item.pickupAddress || '-' }}</b>
</div>
<footer>
<button v-if="canReceiveAfterSale(item)" type="button" class="primary" @click="receiveAfterSale(item)">确认收货</button>
<button v-if="canShipExchange(item)" type="button" class="primary" @click="openExchangeShipPopup(item)">换货发货</button>
<button type="button" @click.stop="goAfterSaleDetail(item)">查看详情</button>
<button v-if="canReceiveAfterSale(item)" type="button" class="primary" @click.stop="receiveAfterSale(item)">确认收货</button>
<button v-if="canShipExchange(item)" type="button" class="primary" @click.stop="openExchangeShipPopup(item)">换货发货</button>
</footer>
</article>
</template>
</section>
</van-tab>
<van-tab title="历史售后" name="history">
<section class="list-panel">
<section v-if="loading" class="skeleton-card">
<van-skeleton title :row="3" />
<van-skeleton title :row="3" />
</section>
<van-empty v-else-if="historyItems.length === 0" description="暂无历史售后" />
<template v-else>
<article v-for="item in historyItems" :key="item.serviceOrderId" class="work-card" @click="goAfterSaleDetail(item)">
<header>
<b>{{ item.serviceOrderId }}</b>
<span>{{ item.serviceStateText }}</span>
</header>
<span class="signed-stamp" :class="{ unsigned: item.isSigned === false }">{{ item.isSigned === false ? '未签收' : '已签收' }}</span>
<div v-for="product in item.products" :key="product.id" class="product-row">
<van-image class="product-thumb" :src="product.image" fit="cover" />
<div class="product-info">
<h4>{{ product.name || '-' }}</h4>
<p>{{ skuText(product) }}</p>
<b>¥{{ product.price }}</b>
</div>
<em>x{{ product.number || 1 }}</em>
</div>
<div class="meta-row">
<span>类型</span><b>{{ item.serviceTypeText || '-' }}</b>
</div>
<div class="meta-row">
<span>原因</span><b>{{ item.reason || '-' }}</b>
</div>
<div class="meta-row">
<span>申请时间</span><b>{{ item.applyTime || '-' }}</b>
</div>
<footer>
<button type="button" class="primary" @click.stop="goAfterSaleDetail(item)">查看详情</button>
</footer>
</article>
</template>
@@ -141,6 +185,7 @@ import { mapActions, mapState } from 'pinia'
import { Dialog, Toast } from 'vant'
import { postFunction } from '@/api/supplied-service'
import { useUserStore } from '@/store/user'
import { useUiStore } from '@/store/ui'
const ORDER_STATE = {
FINISH_PAY: 1
@@ -156,15 +201,26 @@ const SERVICE_STATE = {
RECEIVED: 4
}
const SKU_COLOR_MAP = {
cream: '奶油白',
blue: '雾霾蓝',
orange: '暖橙色'
}
const SKU_SIZE_MAP = {
standard: '标准款',
plus: '升级款'
}
export default {
name: 'JoyfulSelectionMerchant',
data: () => ({
loading: false,
refreshing: false,
activeTab: 'ship',
orderItems: [],
auditItems: [],
processItems: [],
historyItems: [],
currentOrder: null,
currentAfterSale: null,
showShipPopup: false,
@@ -179,19 +235,61 @@ export default {
}
}),
computed: {
...mapState(useUserStore, ['supplierId'])
...mapState(useUserStore, ['supplierId']),
...mapState(useUiStore, ['merchantTab']),
activeTab: {
get() {
return this.merchantTab
},
set(value) {
this.setMerchantTab(value)
}
}
},
created() {
this.prepareSupplier().then(() => this.loadCurrentTab())
this.loadUi()
this.prepareSupplier().then(() => this.loadAllTabs())
},
methods: {
...mapActions(useUserStore, ['fetchSupplierInfo', 'fetchBridgeToken']),
...mapActions(useUiStore, ['loadUi', 'setMerchantTab']),
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(' | ') : '-'
},
tabBadge(name) {
const countMap = {
ship: this.orderItems.length,
audit: this.auditItems.length,
process: this.processItems.length
}
const count = countMap[name] || 0
return count > 0 ? count : ''
},
async prepareSupplier() {
await this.fetchBridgeToken()
if (!this.supplierId) {
await this.fetchSupplierInfo()
}
},
async loadAllTabs() {
this.loading = true
try {
await Promise.all([
this.loadOrders(),
this.loadAfterSales('audit'),
this.loadAfterSales('process'),
this.loadAfterSales('history')
])
} finally {
this.loading = false
}
},
async loadCurrentTab(showLoading = true) {
const shouldShowLoading = typeof showLoading === 'boolean' ? showLoading : true
if (shouldShowLoading) {
@@ -209,12 +307,18 @@ export default {
this.loading = false
}
})
} else {
} else if (this.activeTab === 'process') {
await this.loadAfterSales('process').finally(() => {
if (shouldShowLoading) {
this.loading = false
}
})
} else {
await this.loadAfterSales('history').finally(() => {
if (shouldShowLoading) {
this.loading = false
}
})
}
},
async onRefresh() {
@@ -238,10 +342,20 @@ export default {
})
if (scene === 'audit') {
this.auditItems = data?.afterSaleItems || []
} else if (scene === 'history') {
this.historyItems = data?.afterSaleItems || []
} else {
this.processItems = data?.afterSaleItems || []
}
},
goAfterSaleDetail(item) {
this.$router.push({
path: '/joyful-selection/merchant/after-sale-detail',
query: {
serviceOrderId: item.serviceOrderId
}
})
},
openShipPopup(item) {
this.currentOrder = item
this.shipForm = {
@@ -285,7 +399,9 @@ export default {
return Number(item.serviceState) === SERVICE_STATE.USER_SHIPPED
},
canShipExchange(item) {
return Number(item.serviceType) === SERVICE_TYPE.EXCHANGE && Number(item.serviceState) === SERVICE_STATE.RECEIVED
return Number(item.serviceType) === SERVICE_TYPE.EXCHANGE &&
(Number(item.serviceState) === SERVICE_STATE.RECEIVED ||
(item.isSigned === false && Number(item.serviceState) === SERVICE_STATE.APPROVED))
},
async receiveAfterSale(item) {
await Dialog.confirm({
@@ -352,12 +468,52 @@ export default {
}
.work-card {
position: relative;
margin-bottom: 12px;
padding: 14px 12px;
border-radius: 8px;
background: #fff;
}
.audit-card {
overflow: hidden;
}
.signed-stamp {
position: absolute;
z-index: 2;
top: 110px;
right: 45px;
display: flex;
width: 95px;
height: 60px;
align-items: center;
justify-content: center;
border: 2px solid rgba(255, 91, 32, .8);
border-radius: 50%;
color: #ff5b20;
font-size: 14px;
font-weight: 700;
line-height: 18px;
opacity: .9;
background: rgba(255, 241, 232, .55);
transform: rotate(-18deg);
}
.signed-stamp::before {
position: absolute;
inset: 4px;
border: 1px dashed currentColor;
border-radius: 50%;
content: '';
}
.signed-stamp.unsigned {
border-color: rgba(153, 153, 153, .9);
color: #666;
background: rgba(246, 247, 249, .7);
}
.skeleton-card {
padding: 14px 12px;
border-radius: 8px;
@@ -376,12 +532,18 @@ export default {
}
.work-card header b {
flex: 1;
min-width: 0;
overflow: hidden;
overflow-x: auto;
scrollbar-width: none;
color: #222;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
.work-card header b::-webkit-scrollbar {
display: none;
}
.work-card header span {
@@ -393,6 +555,7 @@ export default {
.product-row {
display: flex;
position: relative;
align-items: center;
margin-bottom: 12px;
}
@@ -473,19 +636,20 @@ export default {
}
.work-card footer button {
min-width: 84px;
min-width: 88px;
height: 34px;
border: 1px solid #ddd;
border-radius: 17px;
padding: 0 16px;
border: 0;
border-radius: 6px;
color: #333;
font-size: 13px;
background: #fff;
font-size: 14px;
font-weight: 700;
background: #f6f7f9;
}
.work-card footer .primary {
border-color: #ff5b20;
color: #fff;
background: #ff5b20;
color: #ff5b20;
background: #fff1e8;
}
.action-popup {
@@ -2,7 +2,7 @@
<div>
<joyful-selection-payment-success-skeleton v-if="loading" />
<main v-else class="success-page">
<van-nav-bar title="支付成功" left-arrow @click-left="$router.back()" />
<van-nav-bar title="支付成功" left-arrow @click-left="goProductList" />
<section class="success-card">
<div class="success-icon">
@@ -62,6 +62,9 @@ export default {
})
},
methods: {
goProductList() {
this.$router.replace('/joyful-selection/products')
},
formatTime(date) {
const pad = value => String(value).padStart(2, '0')
@@ -2,7 +2,7 @@
<div>
<joyful-selection-orders-skeleton v-if="loading" />
<main v-else class="orders-page">
<van-nav-bar title="我的订单" left-arrow @click-left="$router.back()" />
<van-nav-bar title="我的订单" left-arrow @click-left="$router.replace('/joyful-selection/profile')" />
<van-tabs
v-model="active"
class="order-tabs"
@@ -89,6 +89,7 @@ import { mapActions, mapState } from 'pinia'
import { Dialog, Toast } from 'vant'
import { postFunction } from '@/api/supplied-service'
import { useUserStore } from '@/store/user'
import { useUiStore } from '@/store/ui'
import { savePaymentInfo } from '../../order/payment/payment-data'
import JoyfulSelectionOrdersSkeleton from '@/skeleton-view/joyful-selection/profile/orders/index.vue'
@@ -136,7 +137,6 @@ export default {
JoyfulSelectionOrdersSkeleton
},
data: () => ({
active: 0,
tabs: [
{ label: '全部', type: 'all' },
{ label: '待付款', type: 'pending-main' },
@@ -152,6 +152,15 @@ export default {
}),
computed: {
...mapState(useUserStore, ['supplierId', 'userId']),
...mapState(useUiStore, ['ordersTab']),
active: {
get() {
return this.ordersTab
},
set(value) {
this.setOrdersTab(value)
}
},
activeTab() {
return this.tabs[this.active] || this.tabs[0]
},
@@ -189,7 +198,9 @@ export default {
},
methods: {
...mapActions(useUserStore, ['fetchBridgeToken']),
...mapActions(useUiStore, ['loadUi', 'setOrdersTab']),
applyRouteTab() {
this.loadUi()
const tabIndexMap = {
'pending-pay': 1,
'pending-ship': 2,
@@ -199,7 +210,7 @@ export default {
const nextActive = tabIndexMap[this.$route.query.tab]
if (nextActive !== undefined) {
this.active = nextActive
this.setOrdersTab(nextActive)
}
},
previewProducts(products = []) {
@@ -0,0 +1,260 @@
<template>
<main class="supplier-logs-page">
<van-nav-bar title="供应商日志" left-arrow @click-left="$router.back()" />
<section class="toolbar">
<span :class="{ active: listening }">{{ listening ? '监听中' : '已暂停' }}</span>
<button type="button" @click="loadLogs">刷新</button>
<button type="button" @click="clearLogs">清除</button>
</section>
<section class="console-panel">
<div v-if="consoleLogs.length === 0" class="console-empty">暂无日志,正在监听请求...</div>
<article v-for="item in consoleLogs" :key="item.id" class="console-line" :class="item.type">
<p><span>{{ formatTime(item.time) }}</span><b>{{ item.title }}:</b></p>
<pre>{{ formatRequestLine(item) }}</pre>
<pre>请求参数:{{ formatRequestParams(item.content) }}</pre>
</article>
</section>
</main>
</template>
<script>
import { Toast } from 'vant'
import { getFunction } from '@/api/supplied-service'
const API_BASE = process.env.VUE_APP_SUPPLIED_API_BASE || 'http://localhost:8888/api'
const LOG_GROUPS = [
{ type: 'backend-receive', title: '当前后端接收请求', emptyText: '暂无后端接收请求' },
{ type: 'zhgh-send', title: '向ZHGH发送请求', emptyText: '暂无向ZHGH发送请求' },
{ type: 'zhgh-receive', title: '接收ZHGH请求', emptyText: '暂无接收ZHGH请求' }
]
const POLL_INTERVAL = 2000
export default {
name: 'JoyfulSelectionSupplierLogs',
data: () => ({
logs: [],
timer: null,
listening: false
}),
computed: {
consoleLogs() {
return this.logs
.filter(item => LOG_GROUPS.some(group => group.type === item.type))
.slice()
.reverse()
}
},
created() {
this.loadLogs()
this.startListening()
},
beforeDestroy() {
this.stopListening()
},
methods: {
async loadLogs() {
try {
const data = await getFunction('supplier-logs')
this.logs = data?.logs || []
} catch (error) {
Toast(error.message || '查询日志失败')
}
},
async clearLogs() {
try {
const response = await fetch(`${API_BASE.replace(/\/$/, '')}/supplier-logs`, {
method: 'DELETE'
})
const payload = await response.json().catch(() => ({}))
if (!response.ok || payload.success === false) {
throw new Error(payload.error?.message || payload.msg || '清除日志失败')
}
this.logs = []
Toast('日志已清除')
} catch (error) {
Toast(error.message || '清除日志失败')
}
},
startListening() {
this.stopListening()
this.listening = true
this.timer = window.setInterval(() => {
this.loadLogs()
}, POLL_INTERVAL)
},
stopListening() {
if (this.timer) {
window.clearInterval(this.timer)
this.timer = null
}
this.listening = false
},
formatTime(time) {
if (!time) return '-'
const date = new Date(time)
if (Number.isNaN(date.getTime())) return time
const pad = value => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
},
formatRequestLine(item) {
const content = item.content || {}
const method = content.method || 'POST'
const url = this.fullUrl(content.url || content.path || '-')
return `${method} ${url}`
},
fullUrl(url) {
if (!url || url === '-') return '-'
if (/^https?:\/\//i.test(url)) return url
const base = API_BASE.replace(/\/api\/?$/, '').replace(/\/$/, '')
const path = String(url).startsWith('/') ? url : `/${url}`
return `${base}${path}`
},
formatRequestParams(content = {}) {
const params = {
...(content.query || {}),
...(content.body || {})
}
const entries = Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== '')
if (entries.length === 0) {
return '-'
}
return entries.map(([key, value]) => `${key}=${this.formatParamValue(value)}`).join('&')
},
formatParamValue(value) {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return value
}
return JSON.stringify(value)
}
}
}
</script>
<style scoped lang="less">
.supplier-logs-page {
height: 100vh;
padding: 56px 10px 18px;
overflow: hidden;
background: #f7f8fa;
}
.supplier-logs-page ::v-deep .van-nav-bar {
position: fixed;
z-index: 20;
top: 0;
right: 0;
left: 0;
}
.toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-bottom: 10px;
}
.toolbar span {
position: relative;
padding-left: 14px;
color: #999;
font-size: 12px;
}
.toolbar span::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background: #bbb;
transform: translateY(-50%);
}
.toolbar span.active {
color: #20b879;
}
.toolbar span.active::before {
background: #20b879;
}
.toolbar button {
height: 32px;
padding: 0 14px;
border: 1px solid #ff5b20;
border-radius: 6px;
color: #ff5b20;
font-size: 13px;
background: #fff7f2;
}
.console-panel {
height: calc(100vh - 116px);
overflow: auto;
padding: 12px;
border-radius: 8px;
background: #111;
}
.console-empty {
color: #7fda89;
font-size: 12px;
line-height: 20px;
}
.console-line + .console-line {
margin-top: 14px;
}
.console-line p {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0;
color: #7fda89;
font-size: 12px;
line-height: 20px;
}
.console-line span {
color: #8aa0b4;
}
.console-line b {
color: #ffd166;
font-weight: 700;
}
.console-line.backend-receive b {
color: #7fda89;
}
.console-line.zhgh-send b {
color: #5cc8ff;
}
.console-line.zhgh-receive b {
color: #ff8a5c;
}
.console-line pre {
overflow-x: auto;
margin: 4px 0 0;
color: #e6edf3;
font-size: 12px;
line-height: 18px;
white-space: pre-wrap;
word-break: break-all;
}
</style>
@@ -37,6 +37,13 @@ export const merchantAfterSaleListController = withErrorBoundary(async (request:
return toHttpResponse(await merchantService.listAfterSales(await readJson<MerchantAfterSaleListInput>(request)))
})
export const merchantAfterSaleDetailController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await merchantService.detailAfterSale(await readJson<Pick<AfterSaleAuditInput, 'supplierId' | 'serviceOrderId'>>(request)))
})
export const merchantAfterSaleAuditController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
+14
View File
@@ -236,6 +236,20 @@ export const orderDao = {
return prisma.supplierAfterSale.count({ where })
},
findProcessingAfterSaleOrderIds(supplierId: string, userId: string) {
return prisma.supplierAfterSale.findMany({
where: {
supplierId,
userId,
deleted: 0,
serviceState: { in: [0, 1, 3, 4] } // PENDING_AUDIT, APPROVED, USER_SHIPPED, RECEIVED
},
select: {
subOrderId: true
}
})
},
updatePushLog(id: number, data: Parameters<typeof prisma.supplierOrderPushLog.update>[0]['data']) {
return prisma.supplierOrderPushLog.update({
where: { id },
+29
View File
@@ -37,6 +37,7 @@ import {
} from './controllers/order.controller.js'
import {
merchantAfterSaleAuditController,
merchantAfterSaleDetailController,
merchantAfterSaleListController,
merchantAfterSaleReceiveController,
merchantAfterSaleShipController,
@@ -68,6 +69,7 @@ const handlers: Record<string, FunctionHandler> = {
'cart-add': cartAddController,
login: loginController,
'merchant-after-sale-audit': merchantAfterSaleAuditController,
'merchant-after-sale-detail': merchantAfterSaleDetailController,
'merchant-after-sale-list': merchantAfterSaleListController,
'merchant-after-sale-receive': merchantAfterSaleReceiveController,
'merchant-after-sale-ship': merchantAfterSaleShipController,
@@ -124,6 +126,16 @@ const writeResponse = async (ctx: Koa.Context, response: Response) => {
const dispatch = (name: string) => async (ctx: Koa.Context) => {
logger.info('dispatch api request', { name, method: ctx.method, path: ctx.path })
logger.supplierDebug(ctx.path.startsWith('/supplierOpenApi/') ? 'zhgh-receive' : 'backend-receive',
ctx.path.startsWith('/supplierOpenApi/') ? '接收ZHGH请求' : '当前后端接收请求',
{
name,
method: ctx.method,
path: ctx.path,
url: ctx.originalUrl,
query: ctx.query,
body: ctx.request.body || {}
})
const handler = handlers[name]
if (!handler) {
ctx.status = 404
@@ -144,6 +156,23 @@ app.use(async (ctx, next) => {
await next()
})
router.get('/api/supplier-logs', ctx => {
Object.entries(corsHeaders).forEach(([key, value]) => ctx.set(key, value))
ctx.body = {
success: true,
data: {
logs: logger.getSupplierDebugLogs()
}
}
})
router.delete('/api/supplier-logs', ctx => {
Object.entries(corsHeaders).forEach(([key, value]) => ctx.set(key, value))
logger.clearSupplierDebugLogs()
ctx.body = {
success: true,
data: {}
}
})
router.all('/api/:name', async ctx => dispatch(ctx.params.name)(ctx))
router.post('/supplierOpenApi/pointOrder/queryOrderInfo', dispatch('supplier-order-info'))
router.post('/supplierOpenApi/debug/tokens', dispatch('supplier-debug-tokens'))
@@ -6,6 +6,7 @@ import {
} from '../constants/order.js'
import { orderDao } from '../dao/order.dao.js'
import { productDao } from '../dao/product.dao.js'
import { bridgeFetchWithAuth } from '../utils/bridge-auth.js'
import { logger } from '../utils/logger.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
@@ -28,6 +29,7 @@ export type AfterSaleApplyInput = {
exchangeProductSku?: string
exchangeProductName?: string
exchangeProductPrice?: number | string
isSigned?: boolean
}
export type AfterSaleListInput = {
@@ -72,6 +74,12 @@ const supportedServiceTypes = new Set<number>([
const afterSaleOrderStates = new Set<number>([
ORDER_STATE.FINISH_PAY,
ORDER_STATE.IN_PROGRESS,
ORDER_STATE.RECEIVED,
ORDER_STATE.COMPLETED
])
const signedOrderStates = new Set<number>([
ORDER_STATE.RECEIVED,
ORDER_STATE.COMPLETED
])
@@ -128,6 +136,71 @@ const createServiceOrderId = (subOrderId: string, serviceType: number) => {
return `AS${serviceType}_${subOrderId}_${Date.now()}`
}
const isOrderSigned = (orderState?: number | null) => signedOrderStates.has(Number(orderState))
const pushReturnOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>) => {
if (!record.supplierId || !record.userId || !record.subOrderId || !record.mainOrderId) {
return serviceFail('售后订单信息不完整,无法推送退货单')
}
const { response, payload } = await bridgeFetchWithAuth<{ resultCode?: number; message?: string }>(record.supplierId, '/pointOrder/pushOrderInfoMsg', {
supplierId: record.supplierId,
orderType: ORDER_EVENT_TYPE.RETURN_ORDER,
orderId: record.serviceOrderId,
mainOrderId: record.subOrderId,
userId: record.userId,
time: Date.now()
})
if (!response.ok || payload.resultCode !== 0) {
return serviceFail(payload.message || '退货单推送失败', 502, 'BRIDGE_RETURN_ORDER_FAILED')
}
return serviceOk({ serviceOrderId: record.serviceOrderId })
}
const pushExchangeOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>) => {
if (!record.supplierId || !record.userId || !record.subOrderId || !record.mainOrderId) {
return serviceFail('售后订单信息不完整,无法推送换货单')
}
const { response, payload } = await bridgeFetchWithAuth<{ resultCode?: number; message?: string }>(record.supplierId, '/pointOrder/pushOrderInfoMsg', {
supplierId: record.supplierId,
orderType: ORDER_EVENT_TYPE.EXCHANGE_ORDER,
orderId: record.serviceOrderId,
mainOrderId: record.subOrderId,
userId: record.userId,
time: Date.now()
})
if (!response.ok || payload.resultCode !== 0) {
return serviceFail(payload.message || '换货单推送失败', 502, 'BRIDGE_EXCHANGE_ORDER_FAILED')
}
return serviceOk({ serviceOrderId: record.serviceOrderId })
}
const pushCancelSubOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>) => {
if (!record.supplierId || !record.userId || !record.subOrderId || !record.mainOrderId) {
return serviceFail('售后订单信息不完整,无法取消子单')
}
const { response, payload } = await bridgeFetchWithAuth<{ resultCode?: number; message?: string }>(record.supplierId, '/pointOrder/pushOrderInfoMsg', {
supplierId: record.supplierId,
orderType: ORDER_EVENT_TYPE.CANCEL_SUB,
orderId: record.subOrderId,
mainOrderId: record.mainOrderId,
userId: record.userId,
time: Date.now()
})
if (!response.ok || payload.resultCode !== 0) {
return serviceFail(payload.message || '取消子单推送失败', 502, 'BRIDGE_CANCEL_SUB_FAILED')
}
return serviceOk({ serviceOrderId: record.serviceOrderId })
}
const afterSaleDetailPayload = (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>, subOrder?: Awaited<ReturnType<typeof orderDao.findOpenApiSubOrders>>[number] | null) => {
const requestBody = record.requestBody && typeof record.requestBody === 'object' ? record.requestBody as AfterSaleApplyInput : {}
const products = subOrder?.products.map(product => ({
@@ -168,6 +241,7 @@ const afterSaleDetailPayload = (record: NonNullable<Awaited<ReturnType<typeof or
price: requestBody.exchangeProductPrice || ''
} : null,
images: Array.isArray(record.images) ? record.images : [],
isSigned: Boolean(record.pickupMethod || record.pickupName || record.pickupMobile || record.pickupAddress) || isOrderSigned(subOrder?.orderState),
auditRemark: record.auditRemark || '',
userLogisticsNo: record.userLogisticsNo || '',
userLogisticsCompany: record.userLogisticsCompany || '',
@@ -247,6 +321,7 @@ export const afterSaleService = {
const now = new Date()
const amount = Number(input.amount || subOrder.totalPrice || 0)
const serviceOrderId = createServiceOrderId(subOrderId, serviceType)
const { isSigned: _isSigned, ...requestBody } = input
await orderDao.transaction(async tx => {
await tx.supplierAfterSale.create({
@@ -270,7 +345,7 @@ export const afterSaleService = {
pickupAddress: pickupAddress || null,
images,
applyTime: now,
requestBody: input
requestBody
}
})
await tx.supplierOrderOperateLog.create({
@@ -370,6 +445,30 @@ export const afterSaleService = {
return serviceOk({ afterSale: afterSaleDetailPayload(record, subOrders[0]) })
},
async merchantDetail(input: Pick<AfterSaleDetailInput, 'supplierId' | 'serviceOrderId'>) {
const supplierId = String(input.supplierId || '').trim()
const serviceOrderId = String(input.serviceOrderId || '').trim()
logger.info('merchant after sale detail query', { supplierId, serviceOrderId })
if (!supplierId || !serviceOrderId) {
return serviceFail('供应商或售后单不能为空')
}
const record = await orderDao.findAfterSale({
supplierId,
serviceOrderId,
deleted: 0
})
if (!record) {
return serviceFail('售后申请不存在', 404, 'AFTER_SALE_NOT_FOUND')
}
const subOrders = record.subOrderId
? await orderDao.findOpenApiSubOrders(supplierId, [record.subOrderId])
: []
return serviceOk({ afterSale: afterSaleDetailPayload(record, subOrders[0]) })
},
async audit(input: AfterSaleAuditInput) {
const supplierId = String(input.supplierId || '').trim()
const serviceOrderId = String(input.serviceOrderId || '').trim()
@@ -385,7 +484,27 @@ export const afterSaleService = {
return serviceFail('退款/售后申请不存在', 404, 'AFTER_SALE_NOT_FOUND')
}
const subOrders = record.subOrderId
? await orderDao.findOpenApiSubOrders(supplierId, [record.subOrderId])
: []
const isSigned = isOrderSigned(subOrders[0]?.orderState)
const approved = auditStatus === 1
const isExchange = record.serviceType === ORDER_EVENT_TYPE.EXCHANGE_ORDER
const finishUnsignedReturn = approved && !isSigned && record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER
const nextServiceState = approved
? (isSigned || isExchange ? AFTER_SALE_SERVICE_STATE.APPROVED : AFTER_SALE_SERVICE_STATE.FINISHED)
: AFTER_SALE_SERVICE_STATE.REJECTED
const nextLogisticsState = approved
? (isSigned ? AFTER_SALE_LOGISTICS_STATE.WAIT_USER_SHIP : AFTER_SALE_LOGISTICS_STATE.NONE)
: AFTER_SALE_LOGISTICS_STATE.NONE
if (finishUnsignedReturn) {
const cancelSubOrder = await pushCancelSubOrder(record)
if (!cancelSubOrder.success) {
return cancelSubOrder
}
}
const now = new Date()
await orderDao.transaction(async tx => {
await tx.supplierAfterSale.update({
@@ -394,13 +513,27 @@ export const afterSaleService = {
auditStatus,
auditRemark: auditRemark || null,
auditTime: now,
serviceState: approved ? AFTER_SALE_SERVICE_STATE.APPROVED : AFTER_SALE_SERVICE_STATE.REJECTED,
logisticsState: approved ? AFTER_SALE_LOGISTICS_STATE.WAIT_USER_SHIP : AFTER_SALE_LOGISTICS_STATE.NONE
serviceState: nextServiceState,
logisticsState: nextLogisticsState
}
})
if (finishUnsignedReturn && record.subOrderId) {
await tx.supplierOrderSub.updateMany({
where: {
supplierId,
orderId: record.subOrderId,
deleted: 0
},
data: {
orderState: ORDER_STATE.REFUNDED,
orderFinishTime: now,
updateTime: now
}
})
}
})
return serviceOk({ serviceOrderId, auditStatus, serviceState: approved ? AFTER_SALE_SERVICE_STATE.APPROVED : AFTER_SALE_SERVICE_STATE.REJECTED })
return serviceOk({ serviceOrderId, auditStatus, serviceState: nextServiceState })
},
async updateLogistics(input: AfterSaleLogisticsInput) {
@@ -417,9 +550,24 @@ export const afterSaleService = {
return serviceFail('退款/售后申请不存在', 404, 'AFTER_SALE_NOT_FOUND')
}
if (record.serviceType === ORDER_EVENT_TYPE.EXCHANGE_ORDER && logisticsState === AFTER_SALE_LOGISTICS_STATE.MERCHANT_SHIPPED) {
const exchangeOrder = await pushExchangeOrder(record)
if (!exchangeOrder.success) {
return exchangeOrder
}
}
if (record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER && logisticsState === AFTER_SALE_LOGISTICS_STATE.MERCHANT_RECEIVED) {
const returnOrder = await pushReturnOrder(record)
if (!returnOrder.success) {
return returnOrder
}
}
const now = new Date()
const finish = logisticsState === AFTER_SALE_LOGISTICS_STATE.USER_RECEIVED ||
(record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER && logisticsState === AFTER_SALE_LOGISTICS_STATE.MERCHANT_RECEIVED)
const finishReturn = record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER &&
logisticsState === AFTER_SALE_LOGISTICS_STATE.MERCHANT_RECEIVED
await orderDao.transaction(async tx => {
await tx.supplierAfterSale.update({
@@ -441,6 +589,20 @@ export const afterSaleService = {
finishTime: finish ? now : undefined
}
})
if (finishReturn && record.subOrderId) {
await tx.supplierOrderSub.updateMany({
where: {
supplierId,
orderId: record.subOrderId,
deleted: 0
},
data: {
orderState: ORDER_STATE.REFUNDED,
orderFinishTime: now,
updateTime: now
}
})
}
})
return serviceOk({ serviceOrderId, logisticsState, logisticsStateText: logisticsStateText(logisticsState) })
@@ -76,10 +76,32 @@ const afterSaleStatesByScene = (scene: string) => {
AFTER_SALE_SERVICE_STATE.RECEIVED
]
}
if (scene === 'history') {
return [
AFTER_SALE_SERVICE_STATE.REJECTED,
AFTER_SALE_SERVICE_STATE.FINISHED,
AFTER_SALE_SERVICE_STATE.CANCELLED
]
}
return undefined
}
const pendingShipExcludeAfterSaleStates = [
AFTER_SALE_SERVICE_STATE.PENDING_AUDIT,
AFTER_SALE_SERVICE_STATE.APPROVED,
AFTER_SALE_SERVICE_STATE.USER_SHIPPED,
AFTER_SALE_SERVICE_STATE.RECEIVED,
AFTER_SALE_SERVICE_STATE.FINISHED
]
const signedOrderStates = new Set<number>([
ORDER_STATE.RECEIVED,
ORDER_STATE.COMPLETED
])
const isOrderSigned = (orderState?: number | null) => signedOrderStates.has(Number(orderState))
export const merchantService = {
async listOrders(input: MerchantOrderListInput) {
const supplierId = String(input.supplierId || '').trim()
@@ -91,7 +113,9 @@ export const merchantService = {
}
const orders = await orderDao.findMerchantSubOrders(supplierId, orderState)
const orderItems = orders.map(order => ({
const afterSales = await orderDao.findMerchantAfterSales(supplierId, pendingShipExcludeAfterSaleStates)
const afterSaleSubOrderIds = new Set(afterSales.map(item => item.subOrderId).filter(Boolean))
const orderItems = orders.filter(order => !afterSaleSubOrderIds.has(order.orderId)).map(order => ({
orderId: order.orderId,
mainOrderId: order.mainOrderId,
userId: order.userId || '',
@@ -153,6 +177,7 @@ export const merchantService = {
serviceState: record.serviceState,
serviceStateText: serviceStateText(record.serviceState),
logisticsState: record.logisticsState,
isSigned: isOrderSigned(subOrder?.orderState),
reason: record.reason || '',
problemDescription: record.problemDescription || '',
pickupMethod: record.pickupMethod || '',
@@ -176,6 +201,10 @@ export const merchantService = {
return serviceOk({ afterSaleItems })
},
detailAfterSale(input: Pick<AfterSaleAuditInput, 'supplierId' | 'serviceOrderId'>) {
return afterSaleService.merchantDetail(input)
},
auditAfterSale(input: AfterSaleAuditInput) {
return afterSaleService.audit(input)
},
@@ -19,6 +19,7 @@ const statusMap: Record<number, OrderStatusView> = {
[ORDER_STATE.EXCHANGE]: { text: '换货', statusClass: 'muted', actionText: '查看订单', buttonClass: 'dark-action' },
[ORDER_STATE.REFUNDED]: { text: '退货', statusClass: 'muted', actionText: '查看订单', buttonClass: 'dark-action' }
}
const hiddenOrderStates = new Set<number>([ORDER_STATE.EXCHANGE, ORDER_STATE.REFUNDED])
const toYuanText = (value: { toFixed: (digits: number) => string } | number | string | null | undefined) => {
if (value && typeof value === 'object' && 'toFixed' in value) {
@@ -204,6 +205,10 @@ export const orderListService = {
return serviceFail('供应商或用户信息不能为空')
}
// 查询正在售后中的订单ID列表
const processingAfterSales = await orderDao.findProcessingAfterSaleOrderIds(supplierId, userId)
const excludeOrderIds = processingAfterSales.map(item => item.subOrderId)
const mainOrders = await orderDao.findMainOrdersWithSubs(supplierId, userId)
const orderItems = mainOrders.flatMap(mainOrder => {
const mainStatus = statusMap[mainOrder.orderState] || statusMap[ORDER_STATE.FINISH_PAY]
@@ -259,7 +264,9 @@ export const orderListService = {
products,
subOrders
}]
: subOrders.map(subOrder => ({
: subOrders
.filter(subOrder => !excludeOrderIds.includes(subOrder.orderId) && !hiddenOrderStates.has(Number(subOrder.orderState)))
.map(subOrder => ({
...subOrder,
viewId: `sub-${subOrder.orderId}`,
displayType: 'sub',
@@ -267,7 +274,10 @@ export const orderListService = {
}))
})
logger.info('order list result', { supplierId, userId, count: orderItems.length })
return serviceOk({ orderItems })
// 过滤掉正在售后中的订单
const filteredOrderItems = orderItems.filter(item => !excludeOrderIds.includes(item.orderId))
logger.info('order list result', { supplierId, userId, count: filteredOrderItems.length })
return serviceOk({ orderItems: filteredOrderItems })
}
}
@@ -1,5 +1,6 @@
import { supplierDao } from '../dao/supplier.dao.js'
import { orderDao } from '../dao/order.dao.js'
import { ORDER_EVENT_TYPE, ORDER_STATE } from '../constants/order.js'
import { logger } from '../utils/logger.js'
import { sm4CbcDecrypt, verifySupplierOpenSignature } from '../utils/supplier-open-crypto.js'
@@ -40,6 +41,17 @@ const productSku = (product: { sku: string; subOrderId: string; name: string })
return `${product.subOrderId}-${product.name || 'SKU'}`
}
const toOrderProductInfo = (order: Awaited<ReturnType<typeof orderDao.findOpenApiSubOrders>>[number]) => order.products.map(product => ({
sku: productSku(product),
number: product.number,
price: toMoney(product.price),
name: product.name,
taxRate: Number(product.taxRate || 0),
picInfo: Array.isArray(product.picInfo) ? product.picInfo : product.productImgUrl ? [product.productImgUrl] : [],
fileId: product.fileId,
filePath: product.filePath
}))
export const supplierOpenOrderService = {
parseBody,
@@ -67,6 +79,48 @@ export const supplierOpenOrderService = {
return { resultCode: 401, message: '签名无效或已过期', orderInfo: [] }
}
if ([ORDER_EVENT_TYPE.RETURN_ORDER, ORDER_EVENT_TYPE.EXCHANGE_ORDER].includes(Number(body.orderType))) {
const records = await orderDao.findAfterSales({
supplierId,
serviceOrderId: { in: orderIds },
deleted: 0
})
const subOrderIds = records.map(item => item.subOrderId).filter(Boolean) as string[]
const subOrders = subOrderIds.length ? await orderDao.findOpenApiSubOrders(supplierId, subOrderIds) : []
const subOrderMap = new Map(subOrders.map(item => [item.orderId, item]))
const orderInfo = records.map(record => {
const order = record.subOrderId ? subOrderMap.get(record.subOrderId) : null
return {
orderId: record.serviceOrderId,
orderNumberId: order?.orderNumberId || record.serviceOrderId,
supplierId: record.supplierId,
token: order?.token || '',
aplName: order?.aplName || '',
createTime: toText(record.applyTime || record.createTime),
updateTime: toText(record.updateTime),
deliveryTime: toText(order?.deliveryTime),
orderFinishTime: toText(record.finishTime),
completeTime: toText(record.finishTime),
deliveryStatus: order?.deliveryStatus || '',
freightCost: toMoney(order?.freightCost),
wPayOrAPayFreightCost: toMoney(order?.wPayOrAPayFreightCost),
refund: record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER ? toMoney(record.amount) : '0.00',
wPayOrAPayRefund: '0.00',
logisticsOrderId: order?.logisticsOrderId || '',
crrgBsnNm: order?.crrgBsnNm || '',
orderState: record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER ? ORDER_STATE.REFUNDED : ORDER_STATE.EXCHANGE,
totalPrice: toMoney(record.amount),
wPayOrAPay: '0.00',
pointsPrice: toMoney(record.amount),
invoiceCode: order?.invoiceCode || '',
orderProInfos: order ? toOrderProductInfo(order) : []
}
})
return { resultCode: 0, message: '成功', orderInfo }
}
const orders = await orderDao.findOpenApiSubOrders(supplierId, orderIds)
const orderInfo = orders.map(order => ({
orderId: order.orderId,
@@ -91,16 +145,7 @@ export const supplierOpenOrderService = {
wPayOrAPay: toMoney(order.wPayOrAPay),
pointsPrice: toMoney(order.pointsPrice),
invoiceCode: order.invoiceCode,
orderProInfos: order.products.map(product => ({
sku: productSku(product),
number: product.number,
price: toMoney(product.price),
name: product.name,
taxRate: Number(product.taxRate || 0),
picInfo: Array.isArray(product.picInfo) ? product.picInfo : product.productImgUrl ? [product.productImgUrl] : [],
fileId: product.fileId,
filePath: product.filePath
}))
orderProInfos: toOrderProductInfo(order)
}))
return { resultCode: 0, message: '成功', orderInfo }
+14 -5
View File
@@ -1,6 +1,7 @@
import type { SupplierInfo } from '@prisma/client'
import { supplierDao } from '../dao/supplier.dao.js'
import { createBridgeSignature } from './crypto.js'
import { logger } from './logger.js'
import { getRedis } from './redis.js'
type BridgeTokenResponse = {
@@ -168,17 +169,25 @@ export const bridgeFetchWithAuth = async <T>(
const call = async (forceRefresh = false) => {
const token = await getBridgeToken(supplierId, forceRefresh)
const requestBody = {
...body,
supplierId,
token
}
logger.supplierDebug('zhgh-send', '向ZHGH发送请求', {
method: 'POST',
path,
supplierId,
url: `${apiBase}${path}`,
body: requestBody
})
const response = await fetch(`${apiBase}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
token
},
body: JSON.stringify({
...body,
supplierId,
token
})
body: JSON.stringify(requestBody)
})
const payload = (await response.json().catch(() => ({}))) as T
return { response, payload, token }
+28 -1
View File
@@ -1,4 +1,17 @@
type LogLevel = 'info' | 'warn' | 'error'
type SupplierLogType = 'backend-receive' | 'zhgh-send' | 'zhgh-receive'
export type SupplierDebugLog = {
id: number
type: SupplierLogType
title: string
time: string
content: unknown
}
const supplierDebugLogs: SupplierDebugLog[] = []
let supplierDebugLogId = 0
const MAX_SUPPLIER_DEBUG_LOGS = 200
const write = (level: LogLevel, message: string, meta?: unknown) => {
const line = `[supplied-service] ${new Date().toISOString()} ${level.toUpperCase()} ${message}`
@@ -19,5 +32,19 @@ const write = (level: LogLevel, message: string, meta?: unknown) => {
export const logger = {
info: (message: string, meta?: unknown) => write('info', message, meta),
warn: (message: string, meta?: unknown) => write('warn', message, meta),
error: (message: string, meta?: unknown) => write('error', message, meta)
error: (message: string, meta?: unknown) => write('error', message, meta),
supplierDebug: (type: SupplierLogType, title: string, content: unknown) => {
supplierDebugLogs.unshift({
id: ++supplierDebugLogId,
type,
title,
time: new Date().toISOString(),
content
})
supplierDebugLogs.splice(MAX_SUPPLIER_DEBUG_LOGS)
},
getSupplierDebugLogs: () => supplierDebugLogs,
clearSupplierDebugLogs: () => {
supplierDebugLogs.splice(0)
}
}