巴青农资商城

aftersale-submit.vue 8.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. <template>
  2. <view class="as-submit-page">
  3. <view v-if="pageLoading" class="as-submit-state">
  4. <u-loading-icon mode="circle" />
  5. </view>
  6. <template v-else-if="orderItems.length">
  7. <view class="as-submit-card">
  8. <text class="as-submit-card__title">订单商品</text>
  9. <text class="as-submit-card__tip">本单共 {{ orderItems.length }} 件商品,售后按整单申请</text>
  10. <view class="as-submit-goods">
  11. <order-goods-row
  12. v-for="item in orderItems"
  13. :key="item.orderItemId"
  14. :item="item"
  15. show-subtotal
  16. />
  17. </view>
  18. </view>
  19. <view class="as-submit-card">
  20. <text class="as-submit-card__title">申请类型</text>
  21. <view
  22. v-for="opt in applyTypeOptions"
  23. :key="opt.value"
  24. class="as-type-opt"
  25. :class="{ 'as-type-opt--on': applyType === opt.value }"
  26. @click="onApplyTypeChange(opt.value)"
  27. >
  28. {{ opt.label }}
  29. </view>
  30. </view>
  31. <view class="as-submit-card">
  32. <text class="as-submit-card__title">售后原因</text>
  33. <view
  34. v-for="reason in reasonOptions"
  35. :key="reason"
  36. class="as-reason-opt"
  37. :class="{ 'as-reason-opt--on': applyReason === reason }"
  38. @click="applyReason = reason"
  39. >
  40. {{ reason }}
  41. </view>
  42. </view>
  43. <view v-if="needReturnQty" class="as-submit-card">
  44. <text class="as-submit-card__title">退货数量</text>
  45. <view class="as-qty-row">
  46. <view class="as-qty-btn" @click="changeReturnQty(-1)">-</view>
  47. <text class="as-qty-num">{{ returnQuantity }}</text>
  48. <view class="as-qty-btn" @click="changeReturnQty(1)">+</view>
  49. </view>
  50. <text class="as-submit-card__tip">最多 {{ totalQuantity }} 件</text>
  51. </view>
  52. <view class="as-submit-card">
  53. <text class="as-submit-card__title">申请金额</text>
  54. <input
  55. class="as-amount-input"
  56. type="digit"
  57. v-model="applyAmountInput"
  58. placeholder="默认订单实付金额"
  59. />
  60. </view>
  61. <view class="as-submit-card">
  62. <text class="as-submit-card__title">补充描述</text>
  63. <textarea
  64. class="as-desc-textarea"
  65. v-model="description"
  66. :maxlength="descMax"
  67. placeholder="选填"
  68. />
  69. </view>
  70. <view class="as-submit-card">
  71. <image-upload-grid v-model="evidencePics" label="凭证图片" :max="evidenceMax" />
  72. </view>
  73. <button class="as-submit-btn" :disabled="submitting" @click="onSubmit">
  74. {{ submitting ? '提交中...' : '提交申请' }}
  75. </button>
  76. </template>
  77. </view>
  78. </template>
  79. <script setup>
  80. import { ref, computed } from 'vue'
  81. import { onLoad } from '@dcloudio/uni-app'
  82. import { getOrderDetail } from '@/api/order'
  83. import { submitAftersale } from '@/api/orderAftersale'
  84. import { mapOrderDetail } from '@/utils/orderDisplay'
  85. import { ensureApiToken } from '@/utils/apiAuth'
  86. import {
  87. ORDER_STATUS,
  88. AFTERSALE_APPLY_TYPE,
  89. AFTERSALE_APPLY_TYPE_OPTIONS,
  90. AFTERSALE_REASON_MAP,
  91. AFTERSALE_DESC_MAX,
  92. AFTERSALE_EVIDENCE_MAX
  93. } from '@/constants/order'
  94. /** 订单状态 → 售后申请类型(与后端 OrderConstants 一致) */
  95. const APPLY_TYPE_BY_ORDER_STATUS = {
  96. [ORDER_STATUS.PENDING_SHIP]: AFTERSALE_APPLY_TYPE.REFUND_UNSHIPPED,
  97. [ORDER_STATUS.SHIPPED]: AFTERSALE_APPLY_TYPE.REFUND_SHIPPED,
  98. [ORDER_STATUS.COMPLETED]: AFTERSALE_APPLY_TYPE.RETURN_REFUND
  99. }
  100. function resolveApplyTypeByOrderStatus(orderStatus) {
  101. return APPLY_TYPE_BY_ORDER_STATUS[String(orderStatus)] || ''
  102. }
  103. import { goAftersaleDetail } from '@/utils/orderNav'
  104. import OrderGoodsRow from '@/components/order/OrderGoodsRow.vue'
  105. import { useActionGuard } from '@/utils/actionGuard'
  106. import ImageUploadGrid from '@/components/order/ImageUploadGrid.vue'
  107. const submitGuard = useActionGuard()
  108. const orderId = ref('')
  109. const orderStatus = ref('')
  110. /** 整单商品行 */
  111. const orderItems = ref([])
  112. const payAmount = ref(0)
  113. const pageLoading = ref(true)
  114. const submitting = submitGuard.locked
  115. const applyType = ref('')
  116. const applyReason = ref('')
  117. const returnQuantity = ref(1)
  118. const applyAmountInput = ref('')
  119. const description = ref('')
  120. const evidencePics = ref([])
  121. /** 按订单状态只展示对应的一种申请类型 */
  122. const applyTypeOptions = computed(() => {
  123. const type = resolveApplyTypeByOrderStatus(orderStatus.value)
  124. if (!type) return []
  125. return AFTERSALE_APPLY_TYPE_OPTIONS.filter((opt) => opt.value === type)
  126. })
  127. const descMax = AFTERSALE_DESC_MAX
  128. const evidenceMax = AFTERSALE_EVIDENCE_MAX
  129. const reasonOptions = computed(() => AFTERSALE_REASON_MAP[applyType.value] || [])
  130. const totalQuantity = computed(() =>
  131. orderItems.value.reduce((sum, item) => sum + (Number(item.quantity) || 0), 0)
  132. )
  133. const needReturnQty = computed(
  134. () =>
  135. applyType.value === AFTERSALE_APPLY_TYPE.REFUND_SHIPPED ||
  136. applyType.value === AFTERSALE_APPLY_TYPE.RETURN_REFUND
  137. )
  138. function onApplyTypeChange(value) {
  139. applyType.value = value
  140. applyReason.value = ''
  141. }
  142. function changeReturnQty(delta) {
  143. const max = totalQuantity.value || 1
  144. const next = returnQuantity.value + delta
  145. returnQuantity.value = Math.max(1, Math.min(max, next))
  146. }
  147. async function loadOrder() {
  148. pageLoading.value = true
  149. try {
  150. const res = await getOrderDetail(orderId.value)
  151. const mapped = mapOrderDetail(res.data)
  152. const items = mapped.items || []
  153. if (!items.length) {
  154. throw new Error('订单商品不存在')
  155. }
  156. const type = resolveApplyTypeByOrderStatus(mapped.orderStatus)
  157. if (!type) {
  158. throw new Error('当前订单状态不可申请售后')
  159. }
  160. orderStatus.value = mapped.orderStatus
  161. applyType.value = type
  162. applyReason.value = ''
  163. orderItems.value = items
  164. payAmount.value = mapped.payAmount
  165. applyAmountInput.value = String(mapped.payAmount || '')
  166. returnQuantity.value = totalQuantity.value || 1
  167. } catch (e) {
  168. uni.showToast({ title: (e && e.message) || '加载失败', icon: 'none' })
  169. setTimeout(() => uni.navigateBack(), 800)
  170. } finally {
  171. pageLoading.value = false
  172. }
  173. }
  174. function onSubmit() {
  175. submitGuard.run(async () => {
  176. if (!applyReason.value) {
  177. uni.showToast({ title: '请选择售后原因', icon: 'none' })
  178. return
  179. }
  180. const amount = Number(applyAmountInput.value)
  181. if (!amount || amount <= 0) {
  182. uni.showToast({ title: '请填写申请金额', icon: 'none' })
  183. return
  184. }
  185. const firstItem = orderItems.value[0]
  186. if (!firstItem?.orderItemId) {
  187. uni.showToast({ title: '订单商品信息异常', icon: 'none' })
  188. return
  189. }
  190. const body = {
  191. orderId: orderId.value,
  192. // 接口仍要求 orderItemId;整单售后传首行 ID,金额/数量为整单口径
  193. orderItemId: firstItem.orderItemId,
  194. applyType: applyType.value,
  195. applyReason: applyReason.value,
  196. applyAmount: amount,
  197. description: description.value.trim(),
  198. evidencePics: evidencePics.value
  199. }
  200. if (needReturnQty.value) {
  201. body.returnQuantity = returnQuantity.value
  202. }
  203. const res = await submitAftersale(body)
  204. const data = res.data || {}
  205. uni.showToast({ title: '提交成功', icon: 'success' })
  206. setTimeout(() => {
  207. if (data.aftersaleId) {
  208. goAftersaleDetail(data.aftersaleId)
  209. } else {
  210. uni.navigateBack()
  211. }
  212. }, 500)
  213. })
  214. }
  215. onLoad((options) => {
  216. if (!ensureApiToken(true)) return
  217. orderId.value = options.orderId || ''
  218. if (!orderId.value) {
  219. uni.showToast({ title: '订单信息缺失', icon: 'none' })
  220. setTimeout(() => uni.navigateBack(), 800)
  221. return
  222. }
  223. loadOrder()
  224. })
  225. </script>
  226. <style lang="scss" scoped>
  227. .as-submit-page {
  228. min-height: 100vh;
  229. padding: 24rpx;
  230. padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
  231. background: #f5f6f8;
  232. box-sizing: border-box;
  233. }
  234. .as-submit-state {
  235. padding: 120rpx 0;
  236. display: flex;
  237. justify-content: center;
  238. }
  239. .as-submit-card {
  240. margin-bottom: 16rpx;
  241. padding: 24rpx;
  242. background: #fff;
  243. border-radius: 12rpx;
  244. }
  245. .as-submit-card__title {
  246. display: block;
  247. margin-bottom: 16rpx;
  248. font-size: 28rpx;
  249. font-weight: 600;
  250. color: #333;
  251. }
  252. .as-submit-card__tip {
  253. display: block;
  254. margin-bottom: 12rpx;
  255. font-size: 24rpx;
  256. color: #999;
  257. }
  258. .as-submit-goods :deep(.order-goods-row + .order-goods-row) {
  259. border-top: 1rpx solid #f5f5f5;
  260. }
  261. .as-type-opt,
  262. .as-reason-opt {
  263. padding: 16rpx 20rpx;
  264. margin-bottom: 12rpx;
  265. font-size: 28rpx;
  266. color: #333;
  267. background: #f9f9f9;
  268. border-radius: 8rpx;
  269. border: 2rpx solid transparent;
  270. }
  271. .as-type-opt--on,
  272. .as-reason-opt--on {
  273. border-color: #2e7d32;
  274. background: #f1f8f2;
  275. color: #2e7d32;
  276. }
  277. .as-qty-row {
  278. display: inline-flex;
  279. align-items: center;
  280. border: 1rpx solid #e0e0e0;
  281. border-radius: 8rpx;
  282. overflow: hidden;
  283. }
  284. .as-qty-btn {
  285. width: 64rpx;
  286. height: 64rpx;
  287. line-height: 64rpx;
  288. text-align: center;
  289. font-size: 32rpx;
  290. background: #f5f5f5;
  291. }
  292. .as-qty-num {
  293. min-width: 72rpx;
  294. text-align: center;
  295. font-size: 28rpx;
  296. }
  297. .as-amount-input {
  298. height: 72rpx;
  299. padding: 0 16rpx;
  300. font-size: 28rpx;
  301. background: #f9f9f9;
  302. border-radius: 8rpx;
  303. }
  304. .as-desc-textarea {
  305. width: 100%;
  306. min-height: 160rpx;
  307. padding: 16rpx;
  308. font-size: 28rpx;
  309. background: #f9f9f9;
  310. border-radius: 8rpx;
  311. box-sizing: border-box;
  312. }
  313. .as-submit-btn {
  314. margin-top: 16rpx;
  315. height: 88rpx;
  316. line-height: 88rpx;
  317. background: linear-gradient(135deg, #43a047 0%, #2e7d32 100%);
  318. color: #fff;
  319. font-size: 30rpx;
  320. border-radius: 44rpx;
  321. border: none;
  322. }
  323. .as-submit-btn[disabled] {
  324. opacity: 0.5;
  325. }
  326. </style>