xsh_1997 il y a 1 semaine
Parent
commit
229f83c776

+ 7 - 2
shop-app/components/goods/DetailBottomBar.vue

@@ -5,8 +5,8 @@
5 5
 			<text class="bottom-bar__shop-text">店铺</text>
6 6
 		</view>
7 7
 		<view class="bottom-bar__actions">
8
-			<button class="btn btn-cart" :disabled="disabled" @click="emit('cart')">加入购物车</button>
9
-			<button class="btn btn-buy" :disabled="disabled" @click="emit('buy')">立即购买</button>
8
+			<button class="btn btn-cart" :disabled="disabled || busy" @click="emit('cart')">加入购物车</button>
9
+			<button class="btn btn-buy" :disabled="disabled || busy" @click="emit('buy')">立即购买</button>
10 10
 		</view>
11 11
 	</view>
12 12
 </template>
@@ -16,6 +16,11 @@ defineProps({
16 16
 	disabled: {
17 17
 		type: Boolean,
18 18
 		default: false
19
+	},
20
+	/** 提交中:防抖加锁,禁止重复点击 */
21
+	busy: {
22
+		type: Boolean,
23
+		default: false
19 24
 	}
20 25
 })
21 26
 const emit = defineEmits(['cart', 'buy', 'shop'])

+ 35 - 38
shop-app/pages/cart/index.vue

@@ -53,7 +53,7 @@
53 53
 					</view>
54 54
 					<button
55 55
 						class="cart-footer__btn"
56
-						:disabled="!checkedSummary.checkedCount"
56
+						:disabled="!checkedSummary.checkedCount || actionLock"
57 57
 						@click="onCheckout"
58 58
 					>
59 59
 						去结算({{ checkedSummary.checkedCount }})
@@ -89,8 +89,11 @@ import { CART_EMPTY_TEXT, CART_MSG_CROSS_SHOP } from '@/constants/cart'
89 89
 import { goGoodsDetail } from '@/utils/goodsDetail'
90 90
 import { goShopHome } from '@/utils/shopNav'
91 91
 import { PAGE_LOGIN, PAGE_HOME } from '@/utils/pageRoute'
92
+import { useActionGuard } from '@/utils/actionGuard'
92 93
 import CartShopGroup from '@/components/cart/CartShopGroup.vue'
93 94
 
95
+const { locked: actionLock, run: runCartAction } = useActionGuard()
96
+
94 97
 const loggedIn = ref(false)
95 98
 const loading = ref(false)
96 99
 const loadFailed = ref(false)
@@ -102,7 +105,6 @@ const checkedSummary = ref({
102 105
 })
103 106
 const scrollHeight = ref('500px')
104 107
 const emptyText = CART_EMPTY_TEXT
105
-const actionLock = ref(false)
106 108
 
107 109
 const showInvalidBar = computed(() => hasInvalidCartItems(groups.value))
108 110
 
@@ -157,16 +159,15 @@ function buildCheckedPayload(items) {
157 159
 }
158 160
 
159 161
 async function syncChecked(items) {
160
-	if (!items.length || actionLock.value) return
161
-	actionLock.value = true
162
-	try {
163
-		await updateCartChecked(buildCheckedPayload(items))
164
-		await loadCart()
165
-	} catch (e) {
166
-		await loadCart()
167
-	} finally {
168
-		actionLock.value = false
169
-	}
162
+	if (!items.length) return
163
+	runCartAction(async () => {
164
+		try {
165
+			await updateCartChecked(buildCheckedPayload(items))
166
+			await loadCart()
167
+		} catch (e) {
168
+			await loadCart()
169
+		}
170
+	})
170 171
 }
171 172
 
172 173
 function onItemCheck(item, checked) {
@@ -188,17 +189,15 @@ function onToggleSelectAll() {
188 189
 	syncChecked(items)
189 190
 }
190 191
 
191
-async function onQuantityChange(item, quantity) {
192
-	if (actionLock.value) return
193
-	actionLock.value = true
194
-	try {
195
-		await updateCartQuantity(item.cartItemId, quantity)
196
-		await loadCart()
197
-	} catch (e) {
198
-		await loadCart()
199
-	} finally {
200
-		actionLock.value = false
201
-	}
192
+function onQuantityChange(item, quantity) {
193
+	runCartAction(async () => {
194
+		try {
195
+			await updateCartQuantity(item.cartItemId, quantity)
196
+			await loadCart()
197
+		} catch (e) {
198
+			await loadCart()
199
+		}
200
+	})
202 201
 }
203 202
 
204 203
 function confirmRemove(onConfirm) {
@@ -268,23 +267,21 @@ async function onCleanInvalid() {
268 267
 	})
269 268
 }
270 269
 
271
-async function onCheckout() {
272
-	const ids = getCheckedPurchasableIds(groups.value)
273
-	if (!ids.length) {
274
-		uni.showToast({ title: '请先勾选要结算的商品', icon: 'none' })
275
-		return
276
-	}
277
-	const shopIds = getCheckedShopIds(groups.value)
278
-	if (shopIds.size > 1) {
279
-		uni.showToast({ title: CART_MSG_CROSS_SHOP, icon: 'none' })
280
-		return
281
-	}
282
-	try {
270
+function onCheckout() {
271
+	runCartAction(async () => {
272
+		const ids = getCheckedPurchasableIds(groups.value)
273
+		if (!ids.length) {
274
+			uni.showToast({ title: '请先勾选要结算的商品', icon: 'none' })
275
+			return
276
+		}
277
+		const shopIds = getCheckedShopIds(groups.value)
278
+		if (shopIds.size > 1) {
279
+			uni.showToast({ title: CART_MSG_CROSS_SHOP, icon: 'none' })
280
+			return
281
+		}
283 282
 		await prepareCartCheckout(ids)
284 283
 		goCartCheckout(ids)
285
-	} catch (e) {
286
-		// request 已提示
287
-	}
284
+	})
288 285
 }
289 286
 
290 287
 function onShopClick(group) {

+ 18 - 21
shop-app/pages/login/index.vue

@@ -59,6 +59,7 @@
59 59
 
60 60
 				<view
61 61
 					:class="['auth-btn', { 'auth-btn--loading': loading }]"
62
+					:disabled="loading"
62 63
 					@click="handleLogin"
63 64
 				>
64 65
 					<text class="auth-btn__txt">{{ loading ? '登录中…' : '登 录' }}</text>
@@ -84,8 +85,10 @@ import { useUserStore } from '@/store/user'
84 85
 import { loadServiceAgreement } from '@/utils/memberAgreement'
85 86
 import { PAGE_HOME, PAGE_REGISTER } from '@/utils/pageRoute'
86 87
 import AgreementBlock from '@/components/account/AgreementBlock.vue'
88
+import { useActionGuard } from '@/utils/actionGuard'
87 89
 
88
-const loading = ref(false)
90
+const loginGuard = useActionGuard()
91
+const loading = loginGuard.locked
89 92
 const agreement = reactive({
90 93
   enabled: false,
91 94
   requireAgreementOnLogin: false,
@@ -154,30 +157,24 @@ function validateForm() {
154 157
 }
155 158
 
156 159
 function handleLogin() {
157
-  if (!validateForm() || loading.value) {
158
-    return
159
-  }
160
-  const account = form.account.trim()
161
-  if (form.rememberMe) {
162
-    uni.setStorageSync(REMEMBER_ACCOUNT_KEY, account)
163
-  } else {
164
-    uni.removeStorageSync(REMEMBER_ACCOUNT_KEY)
165
-  }
166
-  loading.value = true
167
-  const userStore = useUserStore()
168
-  userStore.fedLogOut()
169
-  userStore
170
-    .login({
160
+  loginGuard.run(async () => {
161
+    if (!validateForm()) return
162
+    const account = form.account.trim()
163
+    if (form.rememberMe) {
164
+      uni.setStorageSync(REMEMBER_ACCOUNT_KEY, account)
165
+    } else {
166
+      uni.removeStorageSync(REMEMBER_ACCOUNT_KEY)
167
+    }
168
+    const userStore = useUserStore()
169
+    userStore.fedLogOut()
170
+    await userStore.login({
171 171
       account,
172 172
       password: form.password,
173 173
       agreementAccepted: needAgreement.value ? form.agreementAccepted : true
174 174
     })
175
-    .then(() => userStore.fetchUserInfo())
176
-    .then(() => goHome())
177
-    .catch(() => {})
178
-    .finally(() => {
179
-      loading.value = false
180
-    })
175
+    await userStore.fetchUserInfo()
176
+    goHome()
177
+  })
181 178
 }
182 179
 </script>
183 180
 

+ 11 - 13
shop-app/subpackage/account/address-edit.vue

@@ -45,10 +45,13 @@ import { ensureApiToken } from '@/utils/apiAuth'
45 45
 import { validateMobile } from '@/utils/memberValidate'
46 46
 import { loadRegionCascaderTree, findRegionPath } from '@/utils/region'
47 47
 import RegionFields from '@/components/mine/RegionFields.vue'
48
+import { useActionGuard } from '@/utils/actionGuard'
49
+
50
+const saveGuard = useActionGuard()
51
+const saving = saveGuard.locked
48 52
 
49 53
 const mode = ref('add')
50 54
 const addressId = ref(null)
51
-const saving = ref(false)
52 55
 
53 56
 const form = reactive({
54 57
   consigneeName: '',
@@ -139,18 +142,13 @@ function buildPayload() {
139 142
 }
140 143
 
141 144
 function handleSave() {
142
-  if (saving.value || !validate()) return
143
-  saving.value = true
144
-  const api = mode.value === 'edit' ? updateAddress : addAddress
145
-  api(buildPayload())
146
-    .then(() => {
147
-      uni.showToast({ title: '保存成功', icon: 'none' })
148
-      setTimeout(() => uni.navigateBack(), 600)
149
-    })
150
-    .catch(() => {})
151
-    .finally(() => {
152
-      saving.value = false
153
-    })
145
+  saveGuard.run(async () => {
146
+    if (!validate()) return
147
+    const api = mode.value === 'edit' ? updateAddress : addAddress
148
+    await api(buildPayload())
149
+    uni.showToast({ title: '保存成功', icon: 'none' })
150
+    setTimeout(() => uni.navigateBack(), 600)
151
+  })
154 152
 }
155 153
 </script>
156 154
 

+ 11 - 13
shop-app/subpackage/account/entry-apply.vue

@@ -104,6 +104,10 @@ import EntryEnterpriseBiz from '@/components/mine/entry/EntryEnterpriseBiz.vue'
104 104
 import EntryShopFields from '@/components/mine/entry/EntryShopFields.vue'
105 105
 import { PAGE_ENTRY_LIST, PAGE_PROFILE } from '@/utils/pageRoute'
106 106
 import { useUserStore } from '@/store/user'
107
+import { useActionGuard } from '@/utils/actionGuard'
108
+
109
+const submitGuard = useActionGuard()
110
+const submitting = submitGuard.locked
107 111
 
108 112
 const form = reactive(createEntryForm(MERCHANT_TYPE_PERSON))
109 113
 const agreement = reactive({
@@ -116,7 +120,6 @@ const agreement = reactive({
116 120
 })
117 121
 
118 122
 const stepIndex = ref(0)
119
-const submitting = ref(false)
120 123
 const entryOpen = ref(true)
121 124
 const blocked = ref(false)
122 125
 const regionBiz = ref({ regionCode: '', regionName: '', pathCodes: [] })
@@ -228,18 +231,13 @@ function doSubmit() {
228 231
     content: '提交后不可修改,是否确认?',
229 232
     success: (res) => {
230 233
       if (!res.confirm) return
231
-      submitting.value = true
232
-      submitEntryApply(buildEntrySubmitPayload(form))
233
-        .then(() => {
234
-          uni.showToast({ title: '提交成功,请等待审核', icon: 'none', duration: 2500 })
235
-          setTimeout(() => {
236
-            uni.redirectTo({ url: PAGE_ENTRY_LIST })
237
-          }, 1500)
238
-        })
239
-        .catch(() => {})
240
-        .finally(() => {
241
-          submitting.value = false
242
-        })
234
+      submitGuard.run(async () => {
235
+        await submitEntryApply(buildEntrySubmitPayload(form))
236
+        uni.showToast({ title: '提交成功,请等待审核', icon: 'none', duration: 2500 })
237
+        setTimeout(() => {
238
+          uni.redirectTo({ url: PAGE_ENTRY_LIST })
239
+        }, 1500)
240
+      })
243 241
     }
244 242
   })
245 243
 }

+ 13 - 16
shop-app/subpackage/account/password.vue

@@ -43,6 +43,10 @@ import { onLoad } from '@dcloudio/uni-app'
43 43
 import { changeMemberPassword } from '@/api/member'
44 44
 import { ensureApiToken } from '@/utils/apiAuth'
45 45
 import { validatePassword } from '@/utils/memberValidate'
46
+import { useActionGuard } from '@/utils/actionGuard'
47
+
48
+const saveGuard = useActionGuard()
49
+const saving = saveGuard.locked
46 50
 
47 51
 const form = reactive({
48 52
   oldPassword: '',
@@ -50,8 +54,6 @@ const form = reactive({
50 54
   confirmPassword: ''
51 55
 })
52 56
 
53
-const saving = ref(false)
54
-
55 57
 onLoad(() => {
56 58
   ensureApiToken()
57 59
 })
@@ -78,21 +80,16 @@ function validate() {
78 80
 }
79 81
 
80 82
 function handleSave() {
81
-  if (saving.value || !validate()) return
82
-  saving.value = true
83
-  changeMemberPassword({
84
-    oldPassword: form.oldPassword,
85
-    newPassword: form.newPassword,
86
-    confirmPassword: form.confirmPassword
87
-  })
88
-    .then(() => {
89
-      uni.showToast({ title: '修改成功', icon: 'none' })
90
-      setTimeout(() => uni.navigateBack(), 800)
91
-    })
92
-    .catch(() => {})
93
-    .finally(() => {
94
-      saving.value = false
83
+  saveGuard.run(async () => {
84
+    if (!validate()) return
85
+    await changeMemberPassword({
86
+      oldPassword: form.oldPassword,
87
+      newPassword: form.newPassword,
88
+      confirmPassword: form.confirmPassword
95 89
     })
90
+    uni.showToast({ title: '修改成功', icon: 'none' })
91
+    setTimeout(() => uni.navigateBack(), 800)
92
+  })
96 93
 }
97 94
 </script>
98 95
 

+ 15 - 19
shop-app/subpackage/account/profile.vue

@@ -57,6 +57,9 @@ import { useUserStore } from '@/store/user'
57 57
 import { ensureApiToken } from '@/utils/apiAuth'
58 58
 import { GENDER_OPTIONS } from '@/utils/entryConstants'
59 59
 import ImageUpload from '@/components/mine/ImageUpload.vue'
60
+import { useActionGuard } from '@/utils/actionGuard'
61
+
62
+const saveGuard = useActionGuard()
60 63
 
61 64
 const profile = reactive({
62 65
   memberId: '',
@@ -71,7 +74,7 @@ const form = reactive({
71 74
   sex: ''
72 75
 })
73 76
 
74
-const saving = ref(false)
77
+const saving = saveGuard.locked
75 78
 const genderLabels = GENDER_OPTIONS.map((g) => g.label)
76 79
 const genderIndex = ref(-1)
77 80
 /** 昵称最长 30 字(对齐功能需求 MS-P2) */
@@ -130,25 +133,18 @@ function validate() {
130 133
 }
131 134
 
132 135
 function handleSave() {
133
-  if (saving.value || !validate()) return
134
-  saving.value = true
135
-  updateMemberProfile({
136
-    nickName: form.nickName.trim(),
137
-    avatar: form.avatar,
138
-    email: (form.email || '').trim(),
139
-    sex: form.sex
140
-  })
141
-    .then(() => {
142
-      uni.showToast({ title: '保存成功', icon: 'none' })
143
-      return userStore.fetchUserInfo()
144
-    })
145
-    .then(() => {
146
-      setTimeout(() => uni.navigateBack(), 800)
147
-    })
148
-    .catch(() => {})
149
-    .finally(() => {
150
-      saving.value = false
136
+  saveGuard.run(async () => {
137
+    if (!validate()) return
138
+    await updateMemberProfile({
139
+      nickName: form.nickName.trim(),
140
+      avatar: form.avatar,
141
+      email: (form.email || '').trim(),
142
+      sex: form.sex
151 143
     })
144
+    uni.showToast({ title: '保存成功', icon: 'none' })
145
+    await userStore.fetchUserInfo()
146
+    setTimeout(() => uni.navigateBack(), 800)
147
+  })
152 148
 }
153 149
 </script>
154 150
 

+ 19 - 21
shop-app/subpackage/account/register.vue

@@ -81,6 +81,7 @@
81 81
 
82 82
 					<view
83 83
 						:class="['auth-btn', { 'auth-btn--loading': loading }]"
84
+						:disabled="loading"
84 85
 						@click="handleRegister"
85 86
 					>
86 87
 						<text class="auth-btn__txt">{{ loading ? '提交中…' : '注 册' }}</text>
@@ -105,7 +106,10 @@ import { validateMobile, validatePassword } from '@/utils/memberValidate'
105 106
 import { PAGE_LOGIN } from '@/utils/pageRoute'
106 107
 import AgreementBlock from '@/components/account/AgreementBlock.vue'
107 108
 
108
-const loading = ref(false)
109
+import { useActionGuard } from '@/utils/actionGuard'
110
+
111
+const registerGuard = useActionGuard()
112
+const loading = registerGuard.locked
109 113
 const agreement = reactive({
110 114
   enabled: false,
111 115
   registrationOpen: true,
@@ -161,27 +165,21 @@ function validateForm() {
161 165
 }
162 166
 
163 167
 function handleRegister() {
164
-  if (!agreement.registrationOpen || loading.value || !validateForm()) {
165
-    return
166
-  }
167
-  loading.value = true
168
-  memberRegister({
169
-    mobile: form.mobile.trim(),
170
-    password: form.password,
171
-    confirmPassword: form.confirmPassword,
172
-    memberCode: (form.memberCode || '').trim() || undefined,
173
-    agreementAccepted: agreement.enabled ? form.agreementAccepted : true
174
-  })
175
-    .then(() => {
176
-      uni.showToast({ title: '注册成功,请登录', icon: 'none', duration: 2000 })
177
-      setTimeout(() => {
178
-        goLogin()
179
-      }, 1500)
180
-    })
181
-    .catch(() => {})
182
-    .finally(() => {
183
-      loading.value = false
168
+  if (!agreement.registrationOpen) return
169
+  registerGuard.run(async () => {
170
+    if (!validateForm()) return
171
+    await memberRegister({
172
+      mobile: form.mobile.trim(),
173
+      password: form.password,
174
+      confirmPassword: form.confirmPassword,
175
+      memberCode: (form.memberCode || '').trim() || undefined,
176
+      agreementAccepted: agreement.enabled ? form.agreementAccepted : true
184 177
     })
178
+    uni.showToast({ title: '注册成功,请登录', icon: 'none', duration: 2000 })
179
+    setTimeout(() => {
180
+      goLogin()
181
+    }, 1500)
182
+  })
185 183
 }
186 184
 </script>
187 185
 

+ 16 - 10
shop-app/subpackage/goods/detail.vue

@@ -139,6 +139,7 @@
139 139
 
140 140
 			<detail-bottom-bar
141 141
 				:disabled="!canOperate"
142
+				:busy="tradeGuard.locked"
142 143
 				@shop="onEnterShop"
143 144
 				@cart="onAddCart"
144 145
 				@buy="onBuyNow"
@@ -164,7 +165,7 @@
164 165
 						integer
165 166
 					/>
166 167
 				</view>
167
-				<button class="qty-popup__btn" @click="confirmQty">确定</button>
168
+				<button class="qty-popup__btn" :disabled="tradeGuard.locked" @click="confirmQty">确定</button>
168 169
 			</view>
169 170
 		</u-popup>
170 171
 	</view>
@@ -185,8 +186,11 @@ import { addCartItem } from '@/api/cart'
185 186
 import { PAGE_GOODS_REVIEWS, PAGE_CHECKOUT } from '@/utils/pageRoute'
186 187
 import { goShopHome } from '@/utils/shopNav'
187 188
 import ReviewCard from '@/components/goods/ReviewCard.vue'
189
+import { useActionGuard } from '@/utils/actionGuard'
188 190
 import DetailBottomBar from '@/components/goods/DetailBottomBar.vue'
189 191
 
192
+const tradeGuard = useActionGuard()
193
+
190 194
 const goodsId = ref('')
191 195
 const detail = ref(null)
192 196
 const pageLoading = ref(true)
@@ -292,14 +296,16 @@ function openQtyPopup() {
292 296
 }
293 297
 
294 298
 function confirmQty() {
295
-	qtyPopupShow.value = false
296
-	const action = pendingAction.value
297
-	pendingAction.value = ''
298
-	if (action === 'cart') {
299
-		doAddCart()
300
-	} else if (action === 'buy') {
301
-		doBuyNow()
302
-	}
299
+	tradeGuard.run(async () => {
300
+		qtyPopupShow.value = false
301
+		const action = pendingAction.value
302
+		pendingAction.value = ''
303
+		if (action === 'cart') {
304
+			await doAddCart()
305
+		} else if (action === 'buy') {
306
+			await doBuyNow()
307
+		}
308
+	})
303 309
 }
304 310
 
305 311
 async function onAddCart() {
@@ -312,7 +318,7 @@ async function onAddCart() {
312 318
 		openQtyPopup()
313 319
 		return
314 320
 	}
315
-	await doAddCart()
321
+	tradeGuard.run(() => doAddCart())
316 322
 }
317 323
 
318 324
 async function doAddCart() {

+ 16 - 19
shop-app/subpackage/order/aftersale-submit.vue

@@ -92,14 +92,17 @@ import {
92 92
 } from '@/constants/order'
93 93
 import { goAftersaleDetail } from '@/utils/orderNav'
94 94
 import OrderGoodsRow from '@/components/order/OrderGoodsRow.vue'
95
+import { useActionGuard } from '@/utils/actionGuard'
95 96
 import ImageUploadGrid from '@/components/order/ImageUploadGrid.vue'
96 97
 
98
+const submitGuard = useActionGuard()
99
+
97 100
 const orderId = ref('')
98 101
 const orderItemId = ref('')
99 102
 const orderItem = ref(null)
100 103
 const payAmount = ref(0)
101 104
 const pageLoading = ref(true)
102
-const submitting = ref(false)
105
+const submitting = submitGuard.locked
103 106
 
104 107
 const applyType = ref(AFTERSALE_APPLY_TYPE.REFUND_UNSHIPPED)
105 108
 const applyReason = ref('')
@@ -154,19 +157,17 @@ async function loadOrderItem() {
154 157
 	}
155 158
 }
156 159
 
157
-async function onSubmit() {
158
-	if (!applyReason.value) {
159
-		uni.showToast({ title: '请选择售后原因', icon: 'none' })
160
-		return
161
-	}
162
-	const amount = Number(applyAmountInput.value)
163
-	if (!amount || amount <= 0) {
164
-		uni.showToast({ title: '请填写申请金额', icon: 'none' })
165
-		return
166
-	}
167
-	if (submitting.value) return
168
-	submitting.value = true
169
-	try {
160
+function onSubmit() {
161
+	submitGuard.run(async () => {
162
+		if (!applyReason.value) {
163
+			uni.showToast({ title: '请选择售后原因', icon: 'none' })
164
+			return
165
+		}
166
+		const amount = Number(applyAmountInput.value)
167
+		if (!amount || amount <= 0) {
168
+			uni.showToast({ title: '请填写申请金额', icon: 'none' })
169
+			return
170
+		}
170 171
 		const body = {
171 172
 			orderId: orderId.value,
172 173
 			orderItemId: orderItemId.value,
@@ -189,11 +190,7 @@ async function onSubmit() {
189 190
 				uni.navigateBack()
190 191
 			}
191 192
 		}, 500)
192
-	} catch (e) {
193
-		// request 已 Toast
194
-	} finally {
195
-		submitting.value = false
196
-	}
193
+	})
197 194
 }
198 195
 
199 196
 onLoad((options) => {

+ 23 - 35
shop-app/subpackage/order/checkout-cart.vue

@@ -120,15 +120,19 @@ import {
120 120
 } from '@/constants/checkout'
121 121
 import { ensureApiToken } from '@/utils/apiAuth'
122 122
 import { PAGE_ADDRESS_EDIT, PAGE_PAY_RESULT } from '@/utils/pageRoute'
123
+import { useActionGuard } from '@/utils/actionGuard'
123 124
 import AddressBar from '@/components/order/AddressBar.vue'
124 125
 import CheckoutMultiGoodsRow from '@/components/order/CheckoutMultiGoodsRow.vue'
125 126
 
127
+const submitGuard = useActionGuard()
128
+const payGuard = useActionGuard()
129
+
126 130
 const cartItemIds = ref([])
127 131
 const selectedAddressId = ref(null)
128 132
 const preview = ref(null)
129 133
 const pageLoading = ref(true)
130 134
 const pageError = ref('')
131
-const submitting = ref(false)
135
+const submitting = submitGuard.locked
132 136
 const previewing = ref(false)
133 137
 const scrollHeight = ref('600px')
134 138
 
@@ -138,7 +142,7 @@ const addressOptions = ref([])
138 142
 const payModalShow = ref(false)
139 143
 const payModalContent = ref('')
140 144
 const pendingOrderId = ref(null)
141
-const paying = ref(false)
145
+const paying = payGuard.locked
142 146
 
143 147
 let previewTimer = null
144 148
 
@@ -264,31 +268,24 @@ function goAddAddress() {
264 268
 	uni.navigateTo({ url: `${PAGE_ADDRESS_EDIT}?mode=add` })
265 269
 }
266 270
 
267
-async function onSubmit() {
268
-	if (!selectedAddressId.value) {
269
-		uni.showToast({ title: '请选择收货地址', icon: 'none' })
270
-		return
271
-	}
272
-	if (submitting.value) return
273
-	submitting.value = true
274
-	try {
271
+function onSubmit() {
272
+	submitGuard.run(async () => {
273
+		if (!selectedAddressId.value) {
274
+			uni.showToast({ title: '请选择收货地址', icon: 'none' })
275
+			return
276
+		}
275 277
 		const res = await submitCheckout(buildSubmitBody())
276 278
 		const data = res.data || {}
277 279
 		pendingOrderId.value = data.orderId
278 280
 		payModalContent.value = `订单号:${data.orderNo || '—'}\n应付金额:¥${data.payAmount != null ? data.payAmount : preview.value?.payAmountText || '—'}`
279 281
 		payModalShow.value = true
280
-	} catch (e) {
281
-		// request 已 Toast
282
-	} finally {
283
-		submitting.value = false
284
-	}
282
+	})
285 283
 }
286 284
 
287
-async function onConfirmPay() {
288
-	if (!pendingOrderId.value || paying.value) return
289
-	paying.value = true
290
-	payModalShow.value = false
291
-	try {
285
+function onConfirmPay() {
286
+	if (!pendingOrderId.value) return
287
+	payGuard.run(async () => {
288
+		payModalShow.value = false
292 289
 		await payOrder(pendingOrderId.value)
293 290
 		try {
294 291
 			uni.removeStorageSync(CART_CHECKOUT_IDS_KEY)
@@ -298,27 +295,18 @@ async function onConfirmPay() {
298 295
 		uni.redirectTo({
299 296
 			url: `${PAGE_PAY_RESULT}?status=success&orderId=${pendingOrderId.value}`
300 297
 		})
301
-	} catch (e) {
302
-		// request 已 Toast
303
-	} finally {
304
-		paying.value = false
305
-	}
298
+	})
306 299
 }
307 300
 
308
-async function onCancelPay() {
309
-	if (!pendingOrderId.value || paying.value) return
310
-	paying.value = true
311
-	payModalShow.value = false
312
-	try {
301
+function onCancelPay() {
302
+	if (!pendingOrderId.value) return
303
+	payGuard.run(async () => {
304
+		payModalShow.value = false
313 305
 		await cancelPay(pendingOrderId.value)
314 306
 		uni.redirectTo({
315 307
 			url: `${PAGE_PAY_RESULT}?status=closed&orderId=${pendingOrderId.value}`
316 308
 		})
317
-	} catch (e) {
318
-		// request 已 Toast
319
-	} finally {
320
-		paying.value = false
321
-	}
309
+	})
322 310
 }
323 311
 
324 312
 function onBack() {

+ 23 - 35
shop-app/subpackage/order/checkout.vue

@@ -130,9 +130,13 @@ import {
130 130
 } from '@/constants/checkout'
131 131
 import { ensureApiToken } from '@/utils/apiAuth'
132 132
 import { PAGE_ADDRESS_EDIT, PAGE_PAY_RESULT } from '@/utils/pageRoute'
133
+import { useActionGuard } from '@/utils/actionGuard'
133 134
 import AddressBar from '@/components/order/AddressBar.vue'
134 135
 import CheckoutGoodsRow from '@/components/order/CheckoutGoodsRow.vue'
135 136
 
137
+const submitGuard = useActionGuard()
138
+const payGuard = useActionGuard()
139
+
136 140
 const goodsId = ref('')
137 141
 const quantity = ref(1)
138 142
 const specText = ref('')
@@ -141,7 +145,7 @@ const buyerRemark = ref('')
141 145
 const preview = ref(null)
142 146
 const pageLoading = ref(true)
143 147
 const pageError = ref('')
144
-const submitting = ref(false)
148
+const submitting = submitGuard.locked
145 149
 const previewing = ref(false)
146 150
 const scrollHeight = ref('600px')
147 151
 const remarkMax = CHECKOUT_REMARK_MAX
@@ -152,7 +156,7 @@ const addressOptions = ref([])
152 156
 const payModalShow = ref(false)
153 157
 const payModalContent = ref('')
154 158
 const pendingOrderId = ref(null)
155
-const paying = ref(false)
159
+const paying = payGuard.locked
156 160
 
157 161
 let previewTimer = null
158 162
 
@@ -255,56 +259,40 @@ function goAddAddress() {
255 259
 	uni.navigateTo({ url: `${PAGE_ADDRESS_EDIT}?mode=add` })
256 260
 }
257 261
 
258
-async function onSubmit() {
259
-	if (!selectedAddressId.value) {
260
-		uni.showToast({ title: '请选择收货地址', icon: 'none' })
261
-		return
262
-	}
263
-	if (submitting.value) return
264
-	submitting.value = true
265
-	try {
262
+function onSubmit() {
263
+	submitGuard.run(async () => {
264
+		if (!selectedAddressId.value) {
265
+			uni.showToast({ title: '请选择收货地址', icon: 'none' })
266
+			return
267
+		}
266 268
 		const res = await submitCheckout(buildSubmitBody())
267 269
 		const data = res.data || {}
268 270
 		pendingOrderId.value = data.orderId
269 271
 		payModalContent.value = `订单号:${data.orderNo || '—'}\n应付金额:¥${data.payAmount != null ? data.payAmount : preview.value?.payAmountText || '—'}`
270 272
 		payModalShow.value = true
271
-	} catch (e) {
272
-		// request 已 Toast
273
-	} finally {
274
-		submitting.value = false
275
-	}
273
+	})
276 274
 }
277 275
 
278
-async function onConfirmPay() {
279
-	if (!pendingOrderId.value || paying.value) return
280
-	paying.value = true
281
-	payModalShow.value = false
282
-	try {
276
+function onConfirmPay() {
277
+	if (!pendingOrderId.value) return
278
+	payGuard.run(async () => {
279
+		payModalShow.value = false
283 280
 		await payOrder(pendingOrderId.value)
284 281
 		uni.redirectTo({
285 282
 			url: `${PAGE_PAY_RESULT}?status=success&orderId=${pendingOrderId.value}`
286 283
 		})
287
-	} catch (e) {
288
-		// request 已 Toast
289
-	} finally {
290
-		paying.value = false
291
-	}
284
+	})
292 285
 }
293 286
 
294
-async function onCancelPay() {
295
-	if (!pendingOrderId.value || paying.value) return
296
-	paying.value = true
297
-	payModalShow.value = false
298
-	try {
287
+function onCancelPay() {
288
+	if (!pendingOrderId.value) return
289
+	payGuard.run(async () => {
290
+		payModalShow.value = false
299 291
 		await cancelPay(pendingOrderId.value)
300 292
 		uni.redirectTo({
301 293
 			url: `${PAGE_PAY_RESULT}?status=closed&orderId=${pendingOrderId.value}`
302 294
 		})
303
-	} catch (e) {
304
-		// request 已 Toast
305
-	} finally {
306
-		paying.value = false
307
-	}
295
+	})
308 296
 }
309 297
 
310 298
 function onBack() {

+ 15 - 15
shop-app/subpackage/order/detail.vue

@@ -110,13 +110,16 @@ import { ORDER_ACTION } from '@/constants/order'
110 110
 import { runOrderAction, openPayModal } from '@/utils/orderAction'
111 111
 import OrderStatusBar from '@/components/order/OrderStatusBar.vue'
112 112
 import OrderGoodsRow from '@/components/order/OrderGoodsRow.vue'
113
+import { useActionGuard } from '@/utils/actionGuard'
113 114
 import OrderActionBar from '@/components/order/OrderActionBar.vue'
114 115
 
116
+const actionGuard = useActionGuard()
117
+
115 118
 const orderId = ref('')
116 119
 const detail = ref(null)
117 120
 const pageLoading = ref(true)
118 121
 const pageError = ref('')
119
-const actionLoading = ref(false)
122
+const actionLoading = actionGuard.locked
120 123
 const scrollHeight = ref('600px')
121 124
 
122 125
 const statusSubText = computed(() => {
@@ -157,11 +160,10 @@ async function loadDetail() {
157 160
 	}
158 161
 }
159 162
 
160
-async function onAction(code) {
161
-	if (!detail.value || actionLoading.value) return
162
-	if (code === ORDER_ACTION.PAY) {
163
-		actionLoading.value = true
164
-		try {
163
+function onAction(code) {
164
+	if (!detail.value) return
165
+	actionGuard.run(async () => {
166
+		if (code === ORDER_ACTION.PAY) {
165 167
 			const result = await openPayModal(
166 168
 				detail.value.orderId,
167 169
 				detail.value.orderNo,
@@ -170,16 +172,14 @@ async function onAction(code) {
170 172
 			if (result && result.status) {
171 173
 				await loadDetail()
172 174
 			}
173
-		} finally {
174
-			actionLoading.value = false
175
+			return
175 176
 		}
176
-		return
177
-	}
178
-	await runOrderAction(code, {
179
-		orderId: detail.value.orderId,
180
-		firstItem: detail.value.items && detail.value.items[0],
181
-		items: detail.value.items,
182
-		onRefresh: loadDetail
177
+		await runOrderAction(code, {
178
+			orderId: detail.value.orderId,
179
+			firstItem: detail.value.items && detail.value.items[0],
180
+			items: detail.value.items,
181
+			onRefresh: loadDetail
182
+		})
183 183
 	})
184 184
 }
185 185
 

+ 13 - 14
shop-app/subpackage/order/list.vue

@@ -102,9 +102,12 @@ import { ensureApiToken } from '@/utils/apiAuth'
102 102
 import { goOrderDetail, goReviewList, goAftersaleList } from '@/utils/orderNav'
103 103
 import { runOrderAction, openPayModal } from '@/utils/orderAction'
104 104
 import { PAGE_HOME } from '@/utils/pageRoute'
105
+import { useActionGuard } from '@/utils/actionGuard'
105 106
 import OrderGoodsRow from '@/components/order/OrderGoodsRow.vue'
106 107
 import OrderActionBar from '@/components/order/OrderActionBar.vue'
107 108
 
109
+const actionGuard = useActionGuard()
110
+
108 111
 const tabs = ORDER_LIST_TABS
109 112
 const activeTab = ref('ALL')
110 113
 const list = ref([])
@@ -113,7 +116,7 @@ const loadingMore = ref(false)
113 116
 const finished = ref(false)
114 117
 const loadFailed = ref(false)
115 118
 const refreshing = ref(false)
116
-const actionLoading = ref(false)
119
+const actionLoading = actionGuard.locked
117 120
 const pageNum = ref(1)
118 121
 const scrollHeight = ref('600px')
119 122
 
@@ -188,24 +191,20 @@ function onCardClick(card) {
188 191
 	goOrderDetail(card.orderId)
189 192
 }
190 193
 
191
-async function onAction(code, card) {
192
-	if (actionLoading.value) return
193
-	if (code === ORDER_ACTION.PAY) {
194
-		actionLoading.value = true
195
-		try {
194
+function onAction(code, card) {
195
+	actionGuard.run(async () => {
196
+		if (code === ORDER_ACTION.PAY) {
196 197
 			const result = await openPayModal(card.orderId, card.orderNo, card.payAmountText)
197 198
 			if (result && result.status) {
198 199
 				await reloadList(true)
199 200
 			}
200
-		} finally {
201
-			actionLoading.value = false
201
+			return
202 202
 		}
203
-		return
204
-	}
205
-	await runOrderAction(code, {
206
-		orderId: card.orderId,
207
-		firstItem: card.firstItem,
208
-		onRefresh: () => reloadList(true)
203
+		await runOrderAction(code, {
204
+			orderId: card.orderId,
205
+			firstItem: card.firstItem,
206
+			onRefresh: () => reloadList(true)
207
+		})
209 208
 	})
210 209
 }
211 210
 

+ 11 - 14
shop-app/subpackage/order/review-edit.vue

@@ -46,26 +46,27 @@ import { onLoad } from '@dcloudio/uni-app'
46 46
 import { submitReview } from '@/api/orderReview'
47 47
 import { ensureApiToken } from '@/utils/apiAuth'
48 48
 import { REVIEW_SCORE_MAX, REVIEW_PIC_MAX, REVIEW_CONTENT_MAX } from '@/constants/order'
49
+import { useActionGuard } from '@/utils/actionGuard'
49 50
 import ImageUploadGrid from '@/components/order/ImageUploadGrid.vue'
50 51
 
52
+const submitGuard = useActionGuard()
53
+
51 54
 const orderId = ref('')
52 55
 const score = ref(5)
53 56
 const content = ref('')
54 57
 const pics = ref([])
55
-const submitting = ref(false)
58
+const submitting = submitGuard.locked
56 59
 const pageLoading = ref(false)
57 60
 const scoreMax = REVIEW_SCORE_MAX
58 61
 const picMax = REVIEW_PIC_MAX
59 62
 const contentMax = REVIEW_CONTENT_MAX
60 63
 
61
-async function onSubmit() {
62
-	if (score.value < 1) {
63
-		uni.showToast({ title: '请选择星级', icon: 'none' })
64
-		return
65
-	}
66
-	if (submitting.value) return
67
-	submitting.value = true
68
-	try {
64
+function onSubmit() {
65
+	submitGuard.run(async () => {
66
+		if (score.value < 1) {
67
+			uni.showToast({ title: '请选择星级', icon: 'none' })
68
+			return
69
+		}
69 70
 		await submitReview(orderId.value, {
70 71
 			score: score.value,
71 72
 			content: content.value.trim(),
@@ -75,11 +76,7 @@ async function onSubmit() {
75 76
 		setTimeout(() => {
76 77
 			uni.navigateBack()
77 78
 		}, 500)
78
-	} catch (e) {
79
-		// request 已 Toast
80
-	} finally {
81
-		submitting.value = false
82
-	}
79
+	})
83 80
 }
84 81
 
85 82
 onLoad((options) => {

+ 4 - 1
shop-app/subpackage/search/index.vue

@@ -59,6 +59,9 @@ import {
59 59
   clearSearchHistory
60 60
 } from '@/utils/searchHistory'
61 61
 import { goSearchResult, navigateBackFromSearchIndex } from '@/utils/searchNav'
62
+import { useActionGuard } from '@/utils/actionGuard'
63
+
64
+const searchGuard = useActionGuard()
62 65
 
63 66
 const placeholder = SEARCH_PLACEHOLDER
64 67
 const keyword = ref('')
@@ -84,7 +87,7 @@ function submitSearch() {
84 87
 }
85 88
 
86 89
 function onSubmit() {
87
-  submitSearch()
90
+  searchGuard.run(() => submitSearch())
88 91
 }
89 92
 
90 93
 function onHistoryTap(text) {

+ 13 - 8
shop-app/subpackage/shop/search.vue

@@ -44,6 +44,9 @@ import { SHOP_SEARCH_PLACEHOLDER } from '@/constants/shop'
44 44
 import { goGoodsDetail } from '@/utils/goodsDetail'
45 45
 import { smartNavigateBack } from '@/utils/navBack'
46 46
 import ShopGoodsBlock from '@/components/shop/ShopGoodsBlock.vue'
47
+import { useActionGuard } from '@/utils/actionGuard'
48
+
49
+const searchGuard = useActionGuard()
47 50
 
48 51
 const shopId = ref('')
49 52
 const keyword = ref('')
@@ -69,14 +72,16 @@ function onBack() {
69 72
 }
70 73
 
71 74
 function onSubmit() {
72
-	const text = (keyword.value || '').trim()
73
-	if (!text) {
74
-		uni.showToast({ title: '请输入搜索内容', icon: 'none' })
75
-		return
76
-	}
77
-	searchKeyword.value = text
78
-	searched.value = true
79
-	scrollKey.value += 1
75
+	searchGuard.run(() => {
76
+		const text = (keyword.value || '').trim()
77
+		if (!text) {
78
+			uni.showToast({ title: '请输入搜索内容', icon: 'none' })
79
+			return
80
+		}
81
+		searchKeyword.value = text
82
+		searched.value = true
83
+		scrollKey.value += 1
84
+	})
80 85
 }
81 86
 
82 87
 function onGoodsClick(item) {

+ 40 - 0
shop-app/utils/actionGuard.js

@@ -0,0 +1,40 @@
1
+import { ref } from 'vue'
2
+
3
+/** 提交类按钮默认防抖间隔(毫秒) */
4
+export const ACTION_GUARD_DEBOUNCE_MS = 300
5
+
6
+/**
7
+ * 提交/支付类操作:防抖 + 执行期间加锁(禁止重复点击)
8
+ * @param {number} debounceMs 两次有效触发的最小间隔
9
+ * @returns {{ locked: import('vue').Ref<boolean>, run: Function, wrap: Function }}
10
+ */
11
+export function useActionGuard(debounceMs = ACTION_GUARD_DEBOUNCE_MS) {
12
+  const locked = ref(false)
13
+  let lastAt = 0
14
+
15
+  /**
16
+   * 在锁内执行异步任务;校验失败可在 task 内 return,仍会释放锁
17
+   * @param {() => Promise<void|boolean>|void|boolean} task
18
+   * @returns {Promise<boolean>} 是否实际执行了 task
19
+   */
20
+  async function run(task) {
21
+    if (locked.value) return false
22
+    const now = Date.now()
23
+    if (now - lastAt < debounceMs) return false
24
+    lastAt = now
25
+    locked.value = true
26
+    try {
27
+      await task()
28
+      return true
29
+    } finally {
30
+      locked.value = false
31
+    }
32
+  }
33
+
34
+  /** 包装函数,适合直接绑 @click */
35
+  function wrap(fn) {
36
+    return (...args) => run(() => fn(...args))
37
+  }
38
+
39
+  return { locked, run, wrap }
40
+}

+ 42 - 34
shop-app/utils/orderAction.js

@@ -1,4 +1,8 @@
1 1
 import { payOrder, cancelPay, confirmReceive } from '@/api/order'
2
+import { useActionGuard } from '@/utils/actionGuard'
3
+
4
+const payModalGuard = useActionGuard()
5
+const confirmReceiveGuard = useActionGuard()
2 6
 import { ORDER_ACTION } from '@/constants/order'
3 7
 import { goGoodsDetail } from '@/utils/goodsDetail'
4 8
 import { goReviewEdit, goAftersaleSubmit } from '@/utils/orderNav'
@@ -71,32 +75,34 @@ function doConfirmReceive(orderId, onRefresh) {
71 75
     uni.showModal({
72 76
       title: '确认收货',
73 77
       content: '请确认您已收到商品',
74
-      success: async (res) => {
78
+      success: (res) => {
75 79
         if (!res.confirm) {
76 80
           resolve()
77 81
           return
78 82
         }
79
-        try {
80
-          await confirmReceive(orderId)
81
-          uni.showToast({ title: '已确认收货', icon: 'success' })
82
-          if (typeof onRefresh === 'function') {
83
-            await onRefresh()
84
-          }
85
-          uni.showModal({
86
-            title: '提示',
87
-            content: '交易成功,是否去评价?',
88
-            confirmText: '去评价',
89
-            cancelText: '稍后',
90
-            success: (r) => {
91
-              if (r.confirm) {
92
-                goReviewEdit(orderId)
93
-              }
83
+        confirmReceiveGuard.run(async () => {
84
+          try {
85
+            await confirmReceive(orderId)
86
+            uni.showToast({ title: '已确认收货', icon: 'success' })
87
+            if (typeof onRefresh === 'function') {
88
+              await onRefresh()
94 89
             }
95
-          })
96
-        } catch (e) {
97
-          // request 已 Toast
98
-        }
99
-        resolve()
90
+            uni.showModal({
91
+              title: '提示',
92
+              content: '交易成功,是否去评价?',
93
+              confirmText: '去评价',
94
+              cancelText: '稍后',
95
+              success: (r) => {
96
+                if (r.confirm) {
97
+                  goReviewEdit(orderId)
98
+                }
99
+              }
100
+            })
101
+          } catch (e) {
102
+            // request 已 Toast
103
+          }
104
+          resolve()
105
+        })
100 106
       }
101 107
     })
102 108
   })
@@ -110,20 +116,22 @@ export function openPayModal(orderId, orderNo, payAmountText) {
110 116
       content: `订单号:${orderNo || '—'}\n应付金额:¥${payAmountText || '—'}`,
111 117
       confirmText: '确认支付',
112 118
       cancelText: '取消支付',
113
-      success: async (res) => {
114
-        try {
115
-          if (res.confirm) {
116
-            await payOrder(orderId)
117
-            uni.showToast({ title: '支付成功', icon: 'success' })
118
-            resolve({ status: 'success' })
119
-            return
119
+      success: (res) => {
120
+        payModalGuard.run(async () => {
121
+          try {
122
+            if (res.confirm) {
123
+              await payOrder(orderId)
124
+              uni.showToast({ title: '支付成功', icon: 'success' })
125
+              resolve({ status: 'success' })
126
+              return
127
+            }
128
+            await cancelPay(orderId)
129
+            uni.showToast({ title: '订单已关闭', icon: 'none' })
130
+            resolve({ status: 'closed' })
131
+          } catch (e) {
132
+            resolve({ status: 'error' })
120 133
           }
121
-          await cancelPay(orderId)
122
-          uni.showToast({ title: '订单已关闭', icon: 'none' })
123
-          resolve({ status: 'closed' })
124
-        } catch (e) {
125
-          resolve({ status: 'error' })
126
-        }
134
+        })
127 135
       },
128 136
       fail: () => resolve({ status: 'cancel' })
129 137
     })