feat(ui): 新增确认对话框组件并优化结账页面
- 新增 `ConfirmDialog` 组件,用于确认删除操作 - 在 `CheckoutPage` 中集成 `ConfirmDialog`,支持删除单个商品和清空购物车 - 优化 `FrostedGlass` 组件,支持自定义背景透明度和模糊级别 - 改进结账页面的表单验证和错误处理逻辑
Showing
3 changed files
with
140 additions
and
12 deletions
src/components/ui/ConfirmDialog.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <Teleport to="body"> | ||
| 3 | + <div | ||
| 4 | + v-if="show" | ||
| 5 | + class="fixed inset-0 z-50 flex items-center justify-center px-4" | ||
| 6 | + > | ||
| 7 | + <div | ||
| 8 | + class="fixed inset-0 bg-black/50 backdrop-blur-sm" | ||
| 9 | + @click="handleCancel" | ||
| 10 | + ></div> | ||
| 11 | + <FrostedGlass class="w-full max-w-sm p-6 relative z-10" :bg-opacity="90" blur-level="md"> | ||
| 12 | + <h3 class="text-lg font-medium mb-2">{{ title }}</h3> | ||
| 13 | + <p class="text-gray-600 text-sm mb-6">{{ message }}</p> | ||
| 14 | + <div class="flex justify-end space-x-3"> | ||
| 15 | + <button | ||
| 16 | + @click="handleCancel" | ||
| 17 | + class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800" | ||
| 18 | + > | ||
| 19 | + 取消 | ||
| 20 | + </button> | ||
| 21 | + <button | ||
| 22 | + @click="handleConfirm" | ||
| 23 | + class="px-4 py-2 text-sm text-white bg-red-500 hover:bg-red-600 rounded-lg" | ||
| 24 | + > | ||
| 25 | + 确认 | ||
| 26 | + </button> | ||
| 27 | + </div> | ||
| 28 | + </FrostedGlass> | ||
| 29 | + </div> | ||
| 30 | + </Teleport> | ||
| 31 | +</template> | ||
| 32 | + | ||
| 33 | +<script setup> | ||
| 34 | +import { defineProps, defineEmits } from 'vue' | ||
| 35 | +import FrostedGlass from './FrostedGlass.vue' | ||
| 36 | + | ||
| 37 | +const props = defineProps({ | ||
| 38 | + show: { | ||
| 39 | + type: Boolean, | ||
| 40 | + default: false | ||
| 41 | + }, | ||
| 42 | + title: { | ||
| 43 | + type: String, | ||
| 44 | + default: '确认' | ||
| 45 | + }, | ||
| 46 | + message: { | ||
| 47 | + type: String, | ||
| 48 | + required: true | ||
| 49 | + } | ||
| 50 | +}) | ||
| 51 | + | ||
| 52 | +const emit = defineEmits(['confirm', 'cancel', 'update:show']) | ||
| 53 | + | ||
| 54 | +const handleConfirm = () => { | ||
| 55 | + emit('confirm') | ||
| 56 | + emit('update:show', false) | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +const handleCancel = () => { | ||
| 60 | + emit('cancel') | ||
| 61 | + emit('update:show', false) | ||
| 62 | +} | ||
| 63 | +</script> |
| 1 | <template> | 1 | <template> |
| 2 | <div | 2 | <div |
| 3 | :class="[ | 3 | :class="[ |
| 4 | - 'bg-white/20 backdrop-blur-md rounded-xl border border-white/30 shadow-lg', | 4 | + `bg-white/${bgOpacity} backdrop-blur-${blurLevel} rounded-xl border border-white/30 shadow-lg`, |
| 5 | className | 5 | className |
| 6 | ]" | 6 | ]" |
| 7 | > | 7 | > |
| ... | @@ -16,6 +16,16 @@ defineProps({ | ... | @@ -16,6 +16,16 @@ defineProps({ |
| 16 | className: { | 16 | className: { |
| 17 | type: String, | 17 | type: String, |
| 18 | default: '' | 18 | default: '' |
| 19 | + }, | ||
| 20 | + bgOpacity: { | ||
| 21 | + type: Number, | ||
| 22 | + default: 80, | ||
| 23 | + validator: (value) => value >= 0 && value <= 100 | ||
| 24 | + }, | ||
| 25 | + blurLevel: { | ||
| 26 | + type: String, | ||
| 27 | + default: 'sm', | ||
| 28 | + validator: (value) => ['none', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'].includes(value) | ||
| 19 | } | 29 | } |
| 20 | }) | 30 | }) |
| 21 | </script> | 31 | </script> | ... | ... |
| ... | @@ -51,12 +51,24 @@ | ... | @@ -51,12 +51,24 @@ |
| 51 | @error="handleImageError" | 51 | @error="handleImageError" |
| 52 | /> | 52 | /> |
| 53 | </div> | 53 | </div> |
| 54 | - <div> | 54 | + <div class="flex-1"> |
| 55 | <p class="font-medium text-sm line-clamp-1">{{ item.title }}</p> | 55 | <p class="font-medium text-sm line-clamp-1">{{ item.title }}</p> |
| 56 | <p class="text-xs text-gray-500"> | 56 | <p class="text-xs text-gray-500"> |
| 57 | {{ item.type === 'course' ? '课程' : '活动' }} · {{ item.quantity }} 份 | 57 | {{ item.type === 'course' ? '课程' : '活动' }} · {{ item.quantity }} 份 |
| 58 | </p> | 58 | </p> |
| 59 | </div> | 59 | </div> |
| 60 | + <button | ||
| 61 | + @click="() => { | ||
| 62 | + itemToDelete = item | ||
| 63 | + showConfirmDialog = true | ||
| 64 | + }" | ||
| 65 | + class="text-red-500 hover:text-red-600 p-1 -mr-1" | ||
| 66 | + title="删除" | ||
| 67 | + > | ||
| 68 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 69 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> | ||
| 70 | + </svg> | ||
| 71 | + </button> | ||
| 60 | </div> | 72 | </div> |
| 61 | <div class="ml-2 text-right"> | 73 | <div class="ml-2 text-right"> |
| 62 | <p class="font-medium text-sm">{{ formatPrice(item.price * item.quantity) }}</p> | 74 | <p class="font-medium text-sm">{{ formatPrice(item.price * item.quantity) }}</p> |
| ... | @@ -204,7 +216,10 @@ | ... | @@ -204,7 +216,10 @@ |
| 204 | </div> | 216 | </div> |
| 205 | <button | 217 | <button |
| 206 | type="button" | 218 | type="button" |
| 207 | - @click="clearCart" | 219 | + @click="() => { |
| 220 | + showConfirmDialog = true | ||
| 221 | + itemToDelete = null | ||
| 222 | + }" | ||
| 208 | class="text-sm text-red-500" | 223 | class="text-sm text-red-500" |
| 209 | > | 224 | > |
| 210 | 清空购物车 | 225 | 清空购物车 |
| ... | @@ -227,6 +242,26 @@ | ... | @@ -227,6 +242,26 @@ |
| 227 | </form> | 242 | </form> |
| 228 | </div> | 243 | </div> |
| 229 | </div> | 244 | </div> |
| 245 | + | ||
| 246 | + <!-- Confirm Dialog --> | ||
| 247 | + <ConfirmDialog | ||
| 248 | + v-model:show="showConfirmDialog" | ||
| 249 | + title="确认删除" | ||
| 250 | + :message="itemToDelete ? `确定要删除 ${itemToDelete.title || '此商品'} 吗?` : '确定要清空购物车吗?'" | ||
| 251 | + @confirm="() => { | ||
| 252 | + if (itemToDelete) { | ||
| 253 | + if (cartItems.length === 1) { | ||
| 254 | + clearCart() | ||
| 255 | + } else { | ||
| 256 | + removeFromCart(itemToDelete.id, itemToDelete.type) | ||
| 257 | + } | ||
| 258 | + itemToDelete = null | ||
| 259 | + } else { | ||
| 260 | + clearCart() | ||
| 261 | + } | ||
| 262 | + }" | ||
| 263 | + @cancel="itemToDelete = null" | ||
| 264 | + /> | ||
| 230 | </AppLayout> | 265 | </AppLayout> |
| 231 | </template> | 266 | </template> |
| 232 | 267 | ||
| ... | @@ -235,10 +270,11 @@ import { ref } from 'vue' | ... | @@ -235,10 +270,11 @@ import { ref } from 'vue' |
| 235 | import { useRouter } from 'vue-router' | 270 | import { useRouter } from 'vue-router' |
| 236 | import AppLayout from '@/components/layout/AppLayout.vue' | 271 | import AppLayout from '@/components/layout/AppLayout.vue' |
| 237 | import FrostedGlass from '@/components/ui/FrostedGlass.vue' | 272 | import FrostedGlass from '@/components/ui/FrostedGlass.vue' |
| 273 | +import ConfirmDialog from '@/components/ui/ConfirmDialog.vue' | ||
| 238 | import { useCart } from '@/contexts/cart' | 274 | import { useCart } from '@/contexts/cart' |
| 239 | 275 | ||
| 240 | const router = useRouter() | 276 | const router = useRouter() |
| 241 | -const { items: cartItems, getTotalPrice, handleCheckout, clearCart } = useCart() | 277 | +const { items: cartItems, getTotalPrice, handleCheckout, clearCart, removeFromCart } = useCart() |
| 242 | 278 | ||
| 243 | // Form state | 279 | // Form state |
| 244 | const formData = ref({ | 280 | const formData = ref({ |
| ... | @@ -255,6 +291,10 @@ const isProcessing = ref(false) | ... | @@ -255,6 +291,10 @@ const isProcessing = ref(false) |
| 255 | const orderComplete = ref(false) | 291 | const orderComplete = ref(false) |
| 256 | const orderId = ref('') | 292 | const orderId = ref('') |
| 257 | 293 | ||
| 294 | +// Confirm dialog state | ||
| 295 | +const showConfirmDialog = ref(false) | ||
| 296 | +const itemToDelete = ref(null) | ||
| 297 | + | ||
| 258 | // Format price with Chinese Yuan symbol | 298 | // Format price with Chinese Yuan symbol |
| 259 | const formatPrice = (price) => { | 299 | const formatPrice = (price) => { |
| 260 | return `¥${price.toFixed(2)}` | 300 | return `¥${price.toFixed(2)}` |
| ... | @@ -267,24 +307,39 @@ const handleImageError = (e) => { | ... | @@ -267,24 +307,39 @@ const handleImageError = (e) => { |
| 267 | 307 | ||
| 268 | // Handle form submission | 308 | // Handle form submission |
| 269 | const handleSubmit = async (e) => { | 309 | const handleSubmit = async (e) => { |
| 270 | - if (!formData.value.name || !formData.value.phone || !formData.value.address) { | 310 | + try { |
| 271 | - alert('请填写必要信息') | 311 | + // 表单验证 |
| 272 | - return | 312 | + if (!formData.value.name?.trim()) { |
| 273 | - } | 313 | + throw new Error('请输入姓名') |
| 314 | + } | ||
| 315 | + if (!formData.value.phone?.trim()) { | ||
| 316 | + throw new Error('请输入手机号码') | ||
| 317 | + } | ||
| 318 | + if (!formData.value.address?.trim()) { | ||
| 319 | + throw new Error('请输入联系地址') | ||
| 320 | + } | ||
| 321 | + if (!formData.value.paymentMethod) { | ||
| 322 | + throw new Error('请选择支付方式') | ||
| 323 | + } | ||
| 274 | 324 | ||
| 275 | - isProcessing.value = true | 325 | + isProcessing.value = true |
| 276 | 326 | ||
| 277 | - try { | ||
| 278 | // Process checkout | 327 | // Process checkout |
| 279 | const result = await handleCheckout(formData.value) | 328 | const result = await handleCheckout(formData.value) |
| 280 | 329 | ||
| 330 | + if (!result) { | ||
| 331 | + throw new Error('支付处理失败,请重试') | ||
| 332 | + } | ||
| 333 | + | ||
| 281 | if (result.success) { | 334 | if (result.success) { |
| 282 | - orderId.value = result.orderId | 335 | + orderId.value = result.orderId || '' |
| 283 | orderComplete.value = true | 336 | orderComplete.value = true |
| 337 | + } else { | ||
| 338 | + throw new Error(result.message || '支付失败,请重试') | ||
| 284 | } | 339 | } |
| 285 | } catch (error) { | 340 | } catch (error) { |
| 286 | console.error('Checkout failed:', error) | 341 | console.error('Checkout failed:', error) |
| 287 | - alert('支付失败,请重试') | 342 | + alert(error.message || '支付失败,请重试') |
| 288 | } finally { | 343 | } finally { |
| 289 | isProcessing.value = false | 344 | isProcessing.value = false |
| 290 | } | 345 | } | ... | ... |
-
Please register or login to post a comment