Переглянути джерело

feat(refund): 增加商城售后审核流程

- 商城订单详情支持提交、撤销并查看售后申请进度
- 商家端支持按状态查看申请并审核通过或驳回
- 补充售后相关路由与接口封装
shizhongqi 1 день тому
батько
коміт
dabf83eb7c

+ 8 - 0
hdApp/src/admin/order/detail.vue

@@ -183,6 +183,7 @@
         <view class="module-tit">
           <text class="module-tit__text">订单信息</text>
           <view class="module-tit__actions" v-if="orderInfo.payStatus == 1 && orderInfo.forward == 0">
+            <button class="admin-button-com middle" style="margin-right:16upx;" @click.stop="goMallRefundList()">商城售后</button>
             <button class="admin-button-com middle" @click.stop="goRefund()">发起售后</button>
           </view>
         </view>
@@ -660,6 +661,13 @@ export default {
     goRefund() {
       this.$util.pageTo({url: '/admin/order/refund?id=' + this.option.id})
     },
+    /** 跳转商城顾客售后申请列表(可按当前订单号过滤) */
+    goMallRefundList() {
+      this.$util.pageTo({
+        url: '/admin/order/mallRefundList',
+        query: { orderSn: this.orderInfo.orderSn || '' }
+      })
+    },
     goShOrder(info) {
       this.$util.pageTo({url: '/admin/order/detail?id=' + info.shOrderId})
     },

+ 354 - 0
hdApp/src/admin/order/mallRefundDetail.vue

@@ -0,0 +1,354 @@
+<!--
+  商城顾客售后申请详情 / 审核页
+  供 mallRefundList 进入;待审核时底栏可驳回或通过,通过后走现有退款资金处理
+-->
+<template>
+	<view class="app-content">
+		<view class="card" v-if="info.id">
+			<view class="row">
+				<text class="label">状态</text>
+				<text class="value" :class="'st-' + info.status">{{ info.statusText || statusText(info.status) }}</text>
+			</view>
+			<view class="row">
+				<text class="label">订单号</text>
+				<text class="value link" @click="goOrder">{{ info.orderSn }}</text>
+			</view>
+			<view class="row">
+				<text class="label">退款类型</text>
+				<text class="value">{{ info.refundType == 1 ? '退货并退款' : '仅退款' }}</text>
+			</view>
+			<view class="row">
+				<text class="label">退款金额</text>
+				<text class="value price">¥{{ parseFloat(info.refundPrice) || 0 }}</text>
+			</view>
+			<view class="row" v-if="info.remark">
+				<text class="label">顾客备注</text>
+				<text class="value">{{ info.remark }}</text>
+			</view>
+			<view class="row" v-if="info.status == 2 && info.rejectReason">
+				<text class="label">驳回原因</text>
+				<text class="value reject">{{ info.rejectReason }}</text>
+			</view>
+			<view class="row" v-if="info.auditAdminName">
+				<text class="label">审核人</text>
+				<text class="value">{{ info.auditAdminName }}</text>
+			</view>
+			<view class="row" v-if="info.auditTime">
+				<text class="label">审核时间</text>
+				<text class="value">{{ info.auditTime }}</text>
+			</view>
+			<view class="row">
+				<text class="label">申请时间</text>
+				<text class="value">{{ info.addTime }}</text>
+			</view>
+		</view>
+
+		<view class="card" v-if="!$util.isEmpty(productList)">
+			<view class="card-title">退货商品</view>
+			<view class="product-row" v-for="(p, idx) in productList" :key="idx">
+				<text class="pname">{{ p.name || ('商品' + p.productId) }}</text>
+				<text class="pmeta">
+					{{ p.num }}{{ p.unitName || (p.property == 0 ? '份' : '') }} × ¥{{ parseFloat(p.unitPrice) || 0 }}
+				</text>
+			</view>
+		</view>
+
+		<view class="footer" v-if="info.status == 0">
+			<button class="btn reject-btn" @click="showReject = true">驳回</button>
+			<button class="btn pass-btn" @click="askPass">通过</button>
+		</view>
+
+		<!-- 驳回原因弹层(兼容各端,避免依赖 showModal editable) -->
+		<view class="reject-mask" v-if="showReject" @click="showReject = false">
+			<view class="reject-panel" @click.stop="">
+				<view class="reject-title">填写驳回原因</view>
+				<textarea
+					class="reject-textarea"
+					v-model="rejectReason"
+					placeholder="请填写驳回原因"
+					placeholder-style="color:#999"
+				/>
+				<view class="reject-actions">
+					<button class="btn reject-btn" @click="showReject = false">取消</button>
+					<button class="btn pass-btn" @click="confirmReject">确认驳回</button>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import { mallApplyDetail, mallApplyPass, mallApplyReject } from '@/api/refund'
+
+export default {
+	name: 'mallRefundDetail',
+	data() {
+		return {
+			info: {},
+			productList: [],
+			busy: false,
+			showReject: false,
+			rejectReason: ''
+		}
+	},
+	methods: {
+		statusText(status) {
+			const map = { 0: '待审核', 1: '已通过', 2: '已驳回', 3: '已取消' }
+			return map[status] || ''
+		},
+		init() {
+			const id = this.option.id
+			if (!id) {
+				return
+			}
+			uni.showLoading({ mask: true })
+			mallApplyDetail({ id }).then(res => {
+				uni.hideLoading()
+				if (res.code != 1) {
+					return
+				}
+				this.info = res.data || {}
+				this.productList = this.info.productList || []
+			}).catch(() => {
+				uni.hideLoading()
+			})
+		},
+		goOrder() {
+			if (!this.info.orderId) {
+				return
+			}
+			this.$util.pageTo({ url: '/admin/order/detail?id=' + this.info.orderId })
+		},
+		askPass() {
+			if (this.busy) {
+				return
+			}
+			this.$util.confirmModal(
+				{ content: '确认通过并退款 ¥' + (parseFloat(this.info.refundPrice) || 0) + '?' },
+				() => {
+					this.doPass()
+				}
+			)
+		},
+		doPass() {
+			this.busy = true
+			uni.showLoading({ mask: true, title: '处理中' })
+			mallApplyPass({ id: this.info.id }).then(res => {
+				uni.hideLoading()
+				this.busy = false
+				if (res.code == 1) {
+					this.$msg(res.msg || '已通过')
+					this.init()
+				}
+			}).catch(() => {
+				uni.hideLoading()
+				this.busy = false
+			})
+		},
+		confirmReject() {
+			const reason = (this.rejectReason || '').trim()
+			if (!reason) {
+				this.$msg('请填写驳回原因')
+				return
+			}
+			this.showReject = false
+			this.doReject(reason)
+		},
+		doReject(reason) {
+			this.busy = true
+			uni.showLoading({ mask: true })
+			mallApplyReject({ id: this.info.id, rejectReason: reason }).then(res => {
+				uni.hideLoading()
+				this.busy = false
+				if (res.code == 1) {
+					this.$msg(res.msg || '已驳回')
+					this.rejectReason = ''
+					this.init()
+				}
+			}).catch(() => {
+				uni.hideLoading()
+				this.busy = false
+			})
+		}
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.app-content {
+	min-height: 100vh;
+	background: #f5f6f8;
+	padding: 20upx 20upx 180upx;
+}
+.card {
+	background: #fff;
+	border-radius: 16upx;
+	padding: 10upx 30upx 20upx;
+	margin-bottom: 20upx;
+	.card-title {
+		padding: 20upx 0;
+		font-size: 30upx;
+		font-weight: 600;
+		border-bottom: 1upx solid #f0f0f0;
+		margin-bottom: 10upx;
+	}
+	.row {
+		display: flex;
+		justify-content: space-between;
+		align-items: flex-start;
+		padding: 22upx 0;
+		border-bottom: 1upx solid #f5f5f5;
+		&:last-child {
+			border-bottom: none;
+		}
+		.label {
+			font-size: 28upx;
+			color: #666;
+			width: 180upx;
+			flex-shrink: 0;
+		}
+		.value {
+			flex: 1;
+			text-align: right;
+			font-size: 28upx;
+			color: #333;
+			&.price {
+				color: #ff4757;
+				font-weight: 600;
+				font-size: 32upx;
+			}
+			&.link {
+				color: #3385ff;
+			}
+			&.reject {
+				color: #ff4757;
+			}
+			&.st-0 {
+				color: #fa8c16;
+				font-weight: 600;
+			}
+			&.st-1 {
+				color: #09c567;
+				font-weight: 600;
+			}
+			&.st-2 {
+				color: #ff4757;
+				font-weight: 600;
+			}
+			&.st-3 {
+				color: #999;
+			}
+		}
+	}
+	.product-row {
+		display: flex;
+		justify-content: space-between;
+		padding: 18upx 0;
+		border-bottom: 1upx solid #f5f5f5;
+		&:last-child {
+			border-bottom: none;
+		}
+		.pname {
+			font-size: 28upx;
+			color: #333;
+			flex: 1;
+			margin-right: 20upx;
+		}
+		.pmeta {
+			font-size: 26upx;
+			color: #666;
+		}
+	}
+}
+.footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	display: flex;
+	background: #fff;
+	padding: 24upx 30upx;
+	padding-bottom: calc(24upx + env(safe-area-inset-bottom));
+	box-shadow: 0 -4upx 12upx rgba(0, 0, 0, 0.08);
+	.btn {
+		flex: 1;
+		height: 88upx;
+		line-height: 88upx;
+		border-radius: 12upx;
+		font-size: 32upx;
+		font-weight: 600;
+		border: none;
+		margin-right: 24upx;
+		&:last-child {
+			margin-right: 0;
+		}
+		&.reject-btn {
+			background: #f8f9fa;
+			color: #666;
+			border: 1upx solid #ddd;
+		}
+		&.pass-btn {
+			background: linear-gradient(135deg, #09c567 0%, #00b894 100%);
+			color: #fff;
+		}
+	}
+}
+.reject-mask {
+	position: fixed;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	background: rgba(0, 0, 0, 0.45);
+	z-index: 99;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+.reject-panel {
+	width: 86%;
+	background: #fff;
+	border-radius: 16upx;
+	padding: 30upx;
+	.reject-title {
+		font-size: 32upx;
+		font-weight: 600;
+		text-align: center;
+		margin-bottom: 24upx;
+	}
+	.reject-textarea {
+		width: 100%;
+		height: 180upx;
+		background: #fafbfc;
+		border: 1upx solid #ddd;
+		border-radius: 8upx;
+		padding: 16upx;
+		font-size: 28upx;
+		box-sizing: border-box;
+	}
+	.reject-actions {
+		display: flex;
+		margin-top: 24upx;
+		.btn {
+			flex: 1;
+			height: 80upx;
+			line-height: 80upx;
+			border-radius: 12upx;
+			font-size: 30upx;
+			margin-right: 20upx;
+			&:last-child {
+				margin-right: 0;
+			}
+			&.reject-btn {
+				background: #f8f9fa;
+				color: #666;
+				border: 1upx solid #ddd;
+			}
+			&.pass-btn {
+				background: #ff4757;
+				color: #fff;
+			}
+		}
+	}
+}
+</style>

+ 177 - 0
hdApp/src/admin/order/mallRefundList.vue

@@ -0,0 +1,177 @@
+<!--
+  商城顾客售后申请列表(商家端)
+  供订单详情「商城售后」入口进入;按状态筛选待审核/已通过/已驳回,点击进入审核详情
+-->
+<template>
+	<view class="app-content">
+		<view class="tabs">
+			<view
+				v-for="(tab, idx) in tabs"
+				:key="idx"
+				class="tab-item"
+				:class="{ active: statusFilter === tab.value }"
+				@click="switchTab(tab.value)"
+			>{{ tab.label }}</view>
+		</view>
+
+		<block v-if="!$util.isEmpty(list.data)">
+			<view
+				class="flex-space list"
+				v-for="(item, index) in list.data"
+				:key="index"
+				@click="goDetail(item)"
+			>
+				<view class="label">
+					<text>日期:{{ item.addTime ? item.addTime.substr(5, 11) : '' }}</text>
+					<text>单号:{{ item.orderSn }}</text>
+					<text>类型:{{ item.refundType == 1 ? '退货并退款' : '仅退款' }}</text>
+					<text class="status-text" :class="'st-' + item.status">
+						{{ item.statusText || statusText(item.status) }}
+					</text>
+				</view>
+				<view class="flex val">
+					¥{{ parseFloat(item.refundPrice) }}
+					<view class="iconfont iconxiangyou"></view>
+				</view>
+			</view>
+		</block>
+		<block v-else>
+			<app-wrapper-empty title="暂无数据" :is-empty="$util.isEmpty(list.data)" />
+		</block>
+	</view>
+</template>
+
+<script>
+import { list } from '@/mixins'
+import { mallApplyList } from '@/api/refund'
+
+export default {
+	name: 'mallRefundList',
+	mixins: [list],
+	data() {
+		return {
+			statusFilter: '0',
+			tabs: [
+				{ label: '待审核', value: '0' },
+				{ label: '已通过', value: '1' },
+				{ label: '已驳回', value: '2' },
+				{ label: '全部', value: 'all' }
+			]
+		}
+	},
+	onPullDownRefresh() {
+		this.resetList()
+		this._list().then(() => {
+			uni.stopPullDownRefresh()
+		})
+	},
+	onReachBottom() {
+		if (!this.list.finished) {
+			this._list()
+		}
+	},
+	onShow() {
+		this.resetList()
+		this._list()
+	},
+	methods: {
+		statusText(status) {
+			const map = { 0: '待审核', 1: '已通过', 2: '已驳回', 3: '已取消' }
+			return map[status] || ''
+		},
+		switchTab(val) {
+			if (this.statusFilter === val) {
+				return
+			}
+			this.statusFilter = val
+			this.resetList()
+			this._list()
+		},
+		goDetail(item) {
+			this.$util.pageTo({ url: '/admin/order/mallRefundDetail', query: { id: item.id } })
+		},
+		_list() {
+			const params = {
+				page: this.list.page,
+				status: this.statusFilter
+			}
+			if (this.option.orderSn) {
+				params.orderSn = this.option.orderSn
+			}
+			return mallApplyList(params).then(res => {
+				this.completes(res)
+			})
+		}
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.app-content {
+	.tabs {
+		display: flex;
+		background: #fff;
+		padding: 10upx 0;
+		margin-bottom: 16upx;
+		.tab-item {
+			flex: 1;
+			text-align: center;
+			font-size: 28upx;
+			color: #666;
+			padding: 16upx 0;
+			position: relative;
+			&.active {
+				color: #09c567;
+				font-weight: 600;
+				&::after {
+					content: '';
+					position: absolute;
+					left: 50%;
+					bottom: 0;
+					width: 48upx;
+					height: 4upx;
+					background: #09c567;
+					transform: translateX(-50%);
+					border-radius: 2upx;
+				}
+			}
+		}
+	}
+	.list {
+		padding: 20upx 30upx;
+		margin-bottom: 20upx;
+		background: #ffffff;
+		.label {
+			position: relative;
+			line-height: 50upx;
+			text {
+				display: block;
+			}
+			.status-text {
+				position: absolute;
+				right: -425upx;
+				top: 0;
+				&.st-0 {
+					color: #fa8c16;
+				}
+				&.st-1 {
+					color: #09c567;
+				}
+				&.st-2 {
+					color: #ff4757;
+				}
+				&.st-3 {
+					color: #999;
+				}
+			}
+		}
+		.val {
+			font-size: 28upx;
+			.iconfont {
+				margin-left: 12upx;
+				color: #999999;
+			}
+		}
+	}
+}
+</style>

+ 20 - 0
hdApp/src/api/refund/index.js

@@ -16,3 +16,23 @@ export const refundDetail = data => {
 export const refundList = data => {
 	return https.get('/refund/list', data)
 }
+
+/** 商城顾客售后申请列表(商家审核) */
+export const mallApplyList = data => {
+	return https.get('/refund/mall-apply-list', data)
+}
+
+/** 商城顾客售后申请详情 */
+export const mallApplyDetail = data => {
+	return https.get('/refund/mall-apply-detail', data)
+}
+
+/** 审核通过并执行退款 */
+export const mallApplyPass = data => {
+	return https.post('/refund/mall-apply-pass', data)
+}
+
+/** 驳回申请 */
+export const mallApplyReject = data => {
+	return https.post('/refund/mall-apply-reject', data)
+}

+ 2 - 0
hdApp/src/pages.json

@@ -492,6 +492,8 @@
 				{ "path": "refund", "style": { "navigationBarTitleText": "退款" } },
 				{ "path": "refundList", "style": { "navigationBarTitleText": "售后记录" } },
 				{ "path": "refundDetail", "style": { "navigationBarTitleText": "售后详情" } },
+				{ "path": "mallRefundList", "style": { "navigationBarTitleText": "商城售后", "enablePullDownRefresh": true } },
+				{ "path": "mallRefundDetail", "style": { "navigationBarTitleText": "售后审核" } },
 				{ "path": "ship", "style": { "navigationBarTitleText": "发货" } },
 				{ "path": "fill-form", "style": { "navigationBarTitleText": "填写收花人" } },
 				{ "path": "debtList", "style": { "navigationBarTitleText": "待结订单" } },

+ 30 - 0
mallApp/src/api/refund/index.js

@@ -0,0 +1,30 @@
+/**
+ * 商城售后申请 API
+ * 供 pages/order/refund、订单详情「申请售后」使用
+ */
+import https from '@/plugins/luch-request_0.0.7/request'
+
+/** 申请页初始化:订单商品、可退金额、已有申请进度 */
+export const getRefundData = data => {
+	return https.get('/refund/get-refund-data', data)
+}
+
+/** 顾客提交售后申请(待审核) */
+export const createRefund = data => {
+	return https.post('/refund/create-order', data)
+}
+
+/** 顾客撤销待审核申请 */
+export const cancelRefund = data => {
+	return https.post('/refund/cancel-order', data)
+}
+
+/** 顾客售后申请列表 */
+export const refundList = data => {
+	return https.get('/refund/list', data)
+}
+
+/** 顾客售后申请详情 */
+export const refundDetail = data => {
+	return https.get('/refund/detail', data)
+}

+ 2 - 1
mallApp/src/pages.json

@@ -125,7 +125,8 @@
             "pages": [
                 { "path": "list", "style": { "navigationBarTitleText": "订单列表" } },
                 { "path": "detail", "style": { "navigationBarTitleText": "订单详情" } },
-                { "path": "comment", "style": { "navigationBarTitleText": "评价" } }
+                { "path": "comment", "style": { "navigationBarTitleText": "评价" } },
+                { "path": "refund", "style": { "navigationBarTitleText": "申请售后" } }
             ]
         },
         {

+ 32 - 0
mallApp/src/pages/order/detail.vue

@@ -126,6 +126,10 @@
       <template v-if="data.status == 1">
         <button class="button-com red big" @click="payFn">立即支付</button>
       </template>
+      <!-- 已支付且非待付/取消/已退款时,展示申请售后入口 -->
+      <template v-else-if="showRefundBtn">
+        <button class="button-com default big refund-btn" @click="goRefund">申请售后</button>
+      </template>
     </view>
   </view>
 </template>
@@ -150,6 +154,21 @@ export default {
   },
   computed: {
     ...mapGetters({ shopUser: "getShopUser" }),
+    /** 已付款且非待付/取消/已退款、非红冲单时显示申请售后 */
+    showRefundBtn() {
+      const d = this.data || {}
+      if (Number(d.payStatus) !== 1) {
+        return false
+      }
+      if (Number(d.forward) === 1) {
+        return false
+      }
+      const status = Number(d.status)
+      if ([1, 5, 6].indexOf(status) !== -1) {
+        return false
+      }
+      return true
+    },
     statusImg() {
       let img = "";
       if (this.data.status == 0) {
@@ -168,6 +187,13 @@ export default {
     goShOrder(info){
       this.$util.pageTo({url:'/pages/order/detail?id='+info.shOrderId})
     },
+    /** 跳转申请售后页 */
+    goRefund() {
+      if (!this.data.id) {
+        return
+      }
+      this.$util.pageTo({ url: '/pages/order/refund?id=' + this.data.id })
+    },
     init() {
       this.getDetail().then(() => {
         getShopUser().then(res => {
@@ -310,6 +336,12 @@ export default {
   .default {
     width: 260upx;
   }
+  /* 申请售后按钮:主色描边,与「立即支付」实心主色区分主次 */
+  .refund-btn {
+    color: $mainColor !important;
+    border: 1upx solid $mainColor !important;
+    background-color: #fff !important;
+  }
 }
 // 公共模块
 .module-com {

+ 738 - 0
mallApp/src/pages/order/refund.vue

@@ -0,0 +1,738 @@
+<!--
+  商城订单申请售后页
+  供订单详情「申请售后」进入;顾客勾选商品/金额后提交待审核申请,商家在 hdApp 审核
+-->
+<template>
+	<view class="refund-page">
+		<!-- 已有申请:展示进度 -->
+		<template v-if="apply && apply.id">
+			<view class="card-section">
+				<view class="section-title">
+					<text class="title-text">申请进度</text>
+				</view>
+				<view class="status-block">
+					<text class="status-tag" :class="'st-' + apply.status">{{ apply.statusText || statusText(apply.status) }}</text>
+					<view class="status-row">
+						<text class="label">退款金额</text>
+						<text class="price">¥{{ parseFloat(apply.refundPrice) || 0 }}</text>
+					</view>
+					<view class="status-row" v-if="apply.remark">
+						<text class="label">备注</text>
+						<text class="value">{{ apply.remark }}</text>
+					</view>
+					<view class="status-row" v-if="apply.status == 2 && apply.rejectReason">
+						<text class="label">驳回原因</text>
+						<text class="value reject">{{ apply.rejectReason }}</text>
+					</view>
+					<view class="status-row">
+						<text class="label">申请时间</text>
+						<text class="value">{{ apply.addTime || '' }}</text>
+					</view>
+				</view>
+			</view>
+			<view class="bottom-actions" v-if="apply.status == 0">
+				<button class="action-btn cancel-btn" @tap="doCancel">撤销申请</button>
+			</view>
+			<view class="bottom-actions" v-else>
+				<button class="action-btn cancel-btn" @tap="goBack">返回</button>
+			</view>
+		</template>
+
+		<!-- 无可申请金额 / 不可申请 -->
+		<template v-else-if="!canApply">
+			<view class="card-section empty-tip">
+				<text>当前订单暂不支持申请售后</text>
+				<text class="sub" v-if="refundDeadlineTip">{{ refundDeadlineTip }}</text>
+			</view>
+			<view class="bottom-actions">
+				<button class="action-btn cancel-btn" @tap="goBack">返回</button>
+			</view>
+		</template>
+
+		<!-- 申请表单 -->
+		<template v-else>
+			<view class="tip-bar" v-if="refundDeadlineTip">
+				<text>{{ refundDeadlineTip }}</text>
+			</view>
+
+			<!-- 退款方式 -->
+			<view class="card-section">
+				<view class="section-title">
+					<text class="title-text">退款方式</text>
+				</view>
+				<view class="refund-type-buttons">
+					<button
+						class="type-btn"
+						:class="refundType == 1 ? 'active' : ''"
+						@tap="refundType = 1"
+					>退货并退款</button>
+					<button
+						class="type-btn"
+						:class="refundType == 2 ? 'active' : ''"
+						@tap="onOnlyRefund"
+					>只退款</button>
+				</view>
+			</view>
+
+			<!-- 商品选择(退货并退款) -->
+			<view class="card-section" v-if="refundType == 1">
+				<view class="section-title">
+					<text class="title-text">退货商品</text>
+				</view>
+				<view class="product-list-compact">
+					<template v-if="!$util.isEmpty(goodsInfoList)">
+						<!-- key 勿用 'g'+i 字符串拼接:会随 v-model 编进 data-event-opts 导致小程序编译失败 -->
+						<view class="product-row" v-for="(res, i) in goodsInfoList" :key="res.goodsId || i">
+							<view class="product-basic-info">
+								<text class="product-name-compact">{{ res.name }}</text>
+								<view class="product-meta">
+									<text class="stock-info">{{ parseFloat(res.num) }}份 × ¥{{ parseFloat(res.unitPrice) }}</text>
+									<text class="available-info">可退 {{ getRemainNum(res) }}份</text>
+								</view>
+							</view>
+							<view class="refund-control">
+								<view class="input-item">
+									<text class="input-label">数量</text>
+									<input
+										class="refund-input-compact"
+										type="number"
+										placeholder="0"
+										:value="res.refundCount"
+										placeholder-style="color:#ccc"
+										@input="onGoodsCountInput(i, $event)"
+									/>
+									<text class="unit-label">份</text>
+								</view>
+							</view>
+						</view>
+					</template>
+					<template v-if="!$util.isEmpty(itemInfoList)">
+						<view class="product-row" v-for="(res, i) in itemInfoList" :key="res.itemId || i">
+							<view class="product-basic-info">
+								<text class="product-name-compact">{{ res.name }}</text>
+								<view class="product-meta">
+									<text class="stock-info">{{ parseFloat(res.num) }}{{ res.unitName || '份' }} × ¥{{ parseFloat(res.unitPrice) }}</text>
+									<text class="available-info">可退 {{ getRemainNum(res) }}{{ res.unitName || '份' }}</text>
+								</view>
+							</view>
+							<view class="refund-control">
+								<view class="input-item">
+									<text class="input-label">数量</text>
+									<input
+										class="refund-input-compact"
+										type="number"
+										placeholder="0"
+										:value="res.refundCount"
+										placeholder-style="color:#ccc"
+										@input="onItemCountInput(i, $event)"
+									/>
+									<text class="unit-label">{{ res.unitName || '份' }}</text>
+								</view>
+							</view>
+						</view>
+					</template>
+					<view v-if="$util.isEmpty(goodsInfoList) && $util.isEmpty(itemInfoList)" class="empty-tip">
+						<text>暂无可退商品</text>
+					</view>
+				</view>
+			</view>
+
+			<!-- 金额信息 -->
+			<view class="card-section">
+				<view class="section-title">
+					<text class="title-text">金额信息</text>
+				</view>
+				<view class="amount-info">
+					<view class="amount-row">
+						<text class="amount-label">订单金额</text>
+						<text class="amount-value">¥{{ parseFloat(orderInfo.orderPrice) || 0 }}</text>
+					</view>
+					<view class="amount-row" v-if="hasTkPrice">
+						<text class="amount-label">已退金额</text>
+						<text class="amount-value">¥{{ parseFloat(orderInfo.tkPrice) || 0 }}</text>
+					</view>
+					<view class="amount-row">
+						<text class="amount-label">可退金额</text>
+						<text class="amount-value available">¥{{ couldRefundPrice }}</text>
+					</view>
+					<view class="amount-row" v-if="hasHbAmount">
+						<text class="amount-label">红包减免</text>
+						<text class="amount-value">¥{{ parseFloat(orderInfo.hbAmount) || 0 }}</text>
+					</view>
+					<view class="amount-row">
+						<text class="amount-label required">退款金额</text>
+						<text class="amount-value refund-money">¥{{ refundMoney }}</text>
+					</view>
+					<view class="amount-hint" v-if="refundType == 1">按勾选数量自动计算,不可修改</view>
+					<view class="amount-hint" v-else>仅退款默认按可退金额申请,全额退清后未过期红包将自动退回</view>
+				</view>
+			</view>
+
+			<!-- 备注 -->
+			<view class="card-section">
+				<view class="section-title">
+					<text class="title-text">备注</text>
+				</view>
+				<view class="remark-container">
+					<textarea
+						class="remark-textarea"
+						v-model="remark"
+						placeholder="选填,说明售后原因"
+						placeholder-style="color:#999"
+					/>
+				</view>
+			</view>
+
+			<view class="bottom-actions">
+				<button class="action-btn cancel-btn" @tap="goBack">取消</button>
+				<button class="action-btn confirm-btn" @tap="askSubmit">提交申请</button>
+			</view>
+		</template>
+	</view>
+</template>
+
+<script>
+import { getRefundData, createRefund, cancelRefund } from '@/api/refund'
+
+export default {
+	name: 'orderRefund',
+	data() {
+		return {
+			orderInfo: {},
+			goodsInfoList: [],
+			itemInfoList: [],
+			couldRefundPrice: 0,
+			refundType: 1,
+			remark: '',
+			apply: null,
+			canApply: 0,
+			refundDeadlineTip: '',
+			submitting: false
+		}
+	},
+	computed: {
+		/** 是否展示已退金额行(避免模板里写 > 比较,兼容小程序 WXML) */
+		hasTkPrice() {
+			return Number(this.orderInfo.tkPrice) > 0
+		},
+		/** 是否展示红包减免行 */
+		hasHbAmount() {
+			return this.orderInfo.hbAmount && Number(this.orderInfo.hbAmount) > 0
+		},
+		/**
+		 * 退款金额:退货并退款按勾选数量×单价;仅退款用可退金额
+		 */
+		refundMoney() {
+			if (this.refundType == 2) {
+				return this.couldRefundPrice
+			}
+			let price = 0
+			;(this.goodsInfoList || []).forEach(item => {
+				const count = Number(item.refundCount) || 0
+				if (count > 0) {
+					price += count * Number(item.unitPrice || 0)
+				}
+			})
+			;(this.itemInfoList || []).forEach(item => {
+				const count = Number(item.refundCount) || 0
+				if (count > 0) {
+					price += count * Number(item.unitPrice || 0)
+				}
+			})
+			return parseFloat(price.toFixed(2))
+		}
+	},
+	methods: {
+		statusText(status) {
+			const map = { 0: '待审核', 1: '已通过', 2: '已驳回', 3: '已取消' }
+			return map[status] || ''
+		},
+		/** 可退剩余数量 */
+		getRemainNum(res) {
+			return Number(res.num) - Number(res.refundNum || 0)
+		},
+		/**
+		 * 成品退货数量输入
+		 * 不用 v-model 绑 v-for 项,避免 key 表达式写入 data-event-opts
+		 */
+		onGoodsCountInput(index, e) {
+			const val = e && e.detail ? e.detail.value : ''
+			this.$set(this.goodsInfoList[index], 'refundCount', val)
+		},
+		/** 单品退货数量输入 */
+		onItemCountInput(index, e) {
+			const val = e && e.detail ? e.detail.value : ''
+			this.$set(this.itemInfoList[index], 'refundCount', val)
+		},
+		goBack() {
+			uni.navigateBack({ delta: 1 })
+		},
+		/** 切换为只退款 */
+		onOnlyRefund() {
+			this.refundType = 2
+		},
+		init() {
+			const id = this.option.id
+			if (!id) {
+				this.$msg('缺少订单信息')
+				return
+			}
+			uni.showLoading({ mask: true })
+			getRefundData({ id }).then(res => {
+				uni.hideLoading()
+				if (res.code != 1) {
+					return
+				}
+				const data = res.data || {}
+				this.orderInfo = data.orderInfo || {}
+				this.couldRefundPrice = Number(data.couldRefundPrice) || 0
+				this.apply = data.apply || null
+				this.canApply = data.canApply || 0
+				this.refundDeadlineTip = data.refundDeadlineTip || ''
+				this.goodsInfoList = (data.goodsInfoList || []).map(ele => ({
+					...ele,
+					refundCount: '',
+					refundNum: ele.refundNum || 0
+				}))
+				this.itemInfoList = (data.itemInfoList || []).map(ele => ({
+					...ele,
+					refundCount: '',
+					refundNum: ele.refundNum || 0
+				}))
+			}).catch(() => {
+				uni.hideLoading()
+			})
+		},
+		/**
+		 * 组装勾选商品参数并校验数量
+		 */
+		buildProduct() {
+			const selectProduct = []
+			let hasError = false
+			;(this.goodsInfoList || []).forEach(ele => {
+				const count = Number(ele.refundCount) || 0
+				const remain = Number(ele.num) - Number(ele.refundNum || 0)
+				if (count > remain) {
+					this.$msg(ele.name + '超过可退数量')
+					hasError = true
+					return
+				}
+				if (count > 0) {
+					selectProduct.push({
+						productId: ele.goodsId,
+						name: ele.name || '',
+						num: count,
+						unitType: ele.unitType || 0,
+						unitName: ele.unitName || '份',
+						unitPrice: ele.unitPrice,
+						property: 0
+					})
+				}
+			})
+			;(this.itemInfoList || []).forEach(ele => {
+				const count = Number(ele.refundCount) || 0
+				const remain = Number(ele.num) - Number(ele.refundNum || 0)
+				if (count > remain) {
+					this.$msg(ele.name + '超过可退数量')
+					hasError = true
+					return
+				}
+				if (count > 0) {
+					selectProduct.push({
+						productId: ele.itemId,
+						name: ele.name || '',
+						num: count,
+						unitType: ele.unitType || 0,
+						unitName: ele.unitName || '',
+						unitPrice: ele.unitPrice,
+						property: 1
+					})
+				}
+			})
+			return { selectProduct, hasError }
+		},
+		askSubmit() {
+			if (this.submitting) {
+				return
+			}
+			let selectProduct = []
+			if (this.refundType == 1) {
+				const built = this.buildProduct()
+				if (built.hasError) {
+					return
+				}
+				selectProduct = built.selectProduct
+				if (this.$util.isEmpty(selectProduct)) {
+					this.$msg('请选择要退的商品')
+					return
+				}
+			}
+			if (Number(this.refundMoney) <= 0) {
+				this.$msg('退款金额必须大于0')
+				return
+			}
+			if (Number(this.refundMoney) > Number(this.couldRefundPrice)) {
+				this.$msg('退款金额超过可退金额')
+				return
+			}
+			uni.showModal({
+				title: '确认提交',
+				content: '申请退款 ¥' + this.refundMoney + ',提交后需商家审核',
+				success: res => {
+					if (res.confirm) {
+						this.doSubmit(selectProduct)
+					}
+				}
+			})
+		},
+		doSubmit(selectProduct) {
+			this.submitting = true
+			uni.showLoading({ mask: true, title: '提交中' })
+			const params = {
+				id: this.option.id,
+				price: this.refundMoney,
+				remark: this.remark,
+				refundType: this.refundType,
+				product: JSON.stringify(selectProduct || [])
+			}
+			createRefund(params).then(res => {
+				uni.hideLoading()
+				this.submitting = false
+				if (res.code == 1) {
+					this.$msg(res.msg || '提交成功')
+					this.init()
+				}
+			}).catch(() => {
+				uni.hideLoading()
+				this.submitting = false
+			})
+		},
+		doCancel() {
+			if (!this.apply || !this.apply.id) {
+				return
+			}
+			uni.showModal({
+				title: '提示',
+				content: '确认撤销该售后申请?',
+				success: res => {
+					if (!res.confirm) {
+						return
+					}
+					uni.showLoading({ mask: true })
+					cancelRefund({ id: this.apply.id }).then(r => {
+						uni.hideLoading()
+						if (r.code == 1) {
+							this.$msg('已撤销')
+							this.apply = null
+							this.init()
+						}
+					}).catch(() => {
+						uni.hideLoading()
+					})
+				}
+			})
+		}
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.refund-page {
+	min-height: 100vh;
+	background: #f5f6f8;
+	padding: 20upx 0 200upx;
+}
+
+.tip-bar {
+	margin: 0 20upx 20upx;
+	padding: 20upx;
+	background: #fff5f5;
+	border: 1upx solid #ffd6dc;
+	border-radius: 12upx;
+	color: $mainColor;
+	font-size: 24upx;
+	line-height: 1.5;
+}
+
+.card-section {
+	background: #fff;
+	margin: 0 20upx 20upx;
+	border-radius: 16upx;
+	box-shadow: 0 2upx 12upx rgba(0, 0, 0, 0.06);
+	overflow: hidden;
+}
+
+.section-title {
+	padding: 30upx 30upx 20upx;
+	border-bottom: 1upx solid #f0f0f0;
+	.title-text {
+		font-size: 32upx;
+		font-weight: 600;
+		color: #333;
+	}
+}
+
+.refund-type-buttons {
+	padding: 30upx;
+	display: flex;
+	.type-btn {
+		flex: 1;
+		height: 80upx;
+		line-height: 80upx;
+		border-radius: 40upx;
+		font-size: 28upx;
+		background: #f8f9fa;
+		color: #666;
+		border: 1upx solid #ddd;
+		margin-right: 24upx;
+		&:last-child {
+			margin-right: 0;
+		}
+		&.active {
+			background: linear-gradient(135deg, #ff8fa3, #ff4d6d);
+			color: #fff;
+			border-color: transparent;
+		}
+	}
+}
+
+.product-list-compact {
+	padding: 0 20upx 20upx;
+	margin-top: 10upx;
+}
+
+.product-row {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	padding: 20upx;
+	margin-top: 12upx;
+	background: #fafbfc;
+	border-radius: 8upx;
+	border: 1upx solid #eef0f2;
+	&:first-child {
+		margin-top: 0;
+	}
+}
+
+.product-basic-info {
+	flex: 1;
+	margin-right: 16upx;
+	.product-name-compact {
+		font-size: 30upx;
+		font-weight: 600;
+		color: #333;
+		display: block;
+		margin-bottom: 8upx;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+		max-width: 380upx;
+	}
+	.product-meta {
+		display: flex;
+		flex-wrap: wrap;
+		.stock-info {
+			font-size: 24upx;
+			color: #666;
+			background: #f5f5f5;
+			padding: 4upx 8upx;
+			border-radius: 4upx;
+			margin-right: 12upx;
+		}
+		.available-info {
+			font-size: 24upx;
+			color: $mainColor;
+			background: rgba(255, 40, 66, 0.08);
+			padding: 4upx 8upx;
+			border-radius: 4upx;
+		}
+	}
+}
+
+.refund-control {
+	.input-item {
+		display: flex;
+		align-items: center;
+		.input-label {
+			font-size: 24upx;
+			color: #666;
+			margin-right: 8upx;
+		}
+		.unit-label {
+			font-size: 24upx;
+			color: #666;
+			margin-left: 8upx;
+		}
+	}
+	.refund-input-compact {
+		width: 100upx;
+		height: 60upx;
+		background: #fff;
+		border: 1upx solid #ddd;
+		border-radius: 6upx;
+		text-align: center;
+		font-size: 26upx;
+		color: #333;
+		&:focus {
+			border-color: $mainColor;
+		}
+	}
+}
+
+.amount-info {
+	padding: 20upx 30upx 30upx;
+}
+.amount-row {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	padding: 18upx 0;
+	border-bottom: 1upx solid #f0f0f0;
+	&:last-child {
+		border-bottom: none;
+	}
+	.amount-label {
+		font-size: 28upx;
+		color: #666;
+		&.required::after {
+			content: '*';
+			color: $mainColor;
+			margin-left: 4upx;
+		}
+	}
+	.amount-value {
+		font-size: 30upx;
+		font-weight: 600;
+		color: #333;
+		&.available {
+			color: $mainColor;
+		}
+		&.refund-money {
+			color: $mainColor;
+			font-size: 36upx;
+		}
+	}
+}
+.amount-hint {
+	font-size: 22upx;
+	color: #999;
+	margin-top: 8upx;
+	line-height: 1.4;
+}
+
+.remark-container {
+	padding: 20upx 30upx 30upx;
+}
+.remark-textarea {
+	width: 100%;
+	height: 140upx;
+	background: #fafbfc;
+	border: 1upx solid #ddd;
+	border-radius: 8upx;
+	padding: 16upx;
+	font-size: 28upx;
+	color: #333;
+	box-sizing: border-box;
+}
+
+.status-block {
+	padding: 30upx;
+	.status-tag {
+		display: inline-block;
+		padding: 8upx 20upx;
+		border-radius: 8upx;
+		font-size: 26upx;
+		margin-bottom: 24upx;
+		&.st-0 {
+			background: #fff7e6;
+			color: #fa8c16;
+		}
+		&.st-1 {
+			background: rgba(255, 40, 66, 0.1);
+			color: $mainColor;
+		}
+		&.st-2 {
+			background: #fff1f0;
+			color: #ff4757;
+		}
+		&.st-3 {
+			background: #f5f5f5;
+			color: #999;
+		}
+	}
+	.status-row {
+		display: flex;
+		justify-content: space-between;
+		padding: 14upx 0;
+		font-size: 28upx;
+		.label {
+			color: #666;
+		}
+		.price {
+			color: $mainColor;
+			font-weight: 600;
+			font-size: 32upx;
+		}
+		.value {
+			color: #333;
+			max-width: 70%;
+			text-align: right;
+			&.reject {
+				color: #ff4757;
+			}
+		}
+	}
+}
+
+.empty-tip {
+	padding: 60upx 30upx;
+	text-align: center;
+	color: #999;
+	font-size: 28upx;
+	.sub {
+		display: block;
+		margin-top: 16upx;
+		font-size: 24upx;
+		color: $mainColor;
+	}
+}
+
+.bottom-actions {
+	z-index: 39;
+	position: fixed;
+	bottom: 0;
+	left: 0;
+	right: 0;
+	background: #fff;
+	padding: 24upx 30upx;
+	padding-bottom: calc(24upx + env(safe-area-inset-bottom));
+	border-top: 1upx solid #eee;
+	display: flex;
+	box-shadow: 0 -4upx 12upx rgba(0, 0, 0, 0.06);
+}
+
+.action-btn {
+	flex: 1;
+	height: 88upx;
+	line-height: 88upx;
+	border-radius: 44upx;
+	font-size: 32upx;
+	font-weight: 600;
+	border: none;
+	margin-right: 24upx;
+	&:last-child {
+		margin-right: 0;
+	}
+	&.cancel-btn {
+		background: #f8f9fa;
+		color: #666;
+		border: 1upx solid #ddd;
+	}
+	&.confirm-btn {
+		background: linear-gradient(135deg, #ff8fa3, #ff4d6d);
+		color: #fff;
+		box-shadow: 0 4upx 12upx rgba(255, 77, 109, 0.3);
+	}
+}
+</style>