GroomingBookingView.vue 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. <template>
  2. <div class="grooming-booking">
  3. <el-steps :active="step" finish-status="success" class="steps">
  4. <el-step title="选择套餐" />
  5. <el-step title="选择预约时间" />
  6. <el-step title="选择宠物" />
  7. <el-step title="确认提交" />
  8. </el-steps>
  9. <!-- Step 1: 选择套餐 -->
  10. <div v-if="step === 1" class="step-content">
  11. <h2>选择洗护套餐</h2>
  12. <div v-if="packages.length === 0" class="empty">
  13. <el-empty description="暂无可用套餐" />
  14. </div>
  15. <el-radio-group v-else v-model="selectedPackageId" class="package-list">
  16. <el-card
  17. v-for="pkg in packages"
  18. :key="pkg.id"
  19. :class="['package-card', { selected: selectedPackageId === pkg.id }]"
  20. @click="selectedPackageId = pkg.id"
  21. >
  22. <el-radio :value="pkg.id" class="package-radio">
  23. <strong>{{ pkg.name }}</strong>
  24. <p>{{ pkg.description }}</p>
  25. <p class="price">¥{{ pkg.price }}</p>
  26. </el-radio>
  27. </el-card>
  28. </el-radio-group>
  29. <div class="step-actions">
  30. <el-button type="primary" :disabled="!selectedPackageId" @click="step = 2">下一步</el-button>
  31. </div>
  32. </div>
  33. <!-- Step 2: 选择预约时间 -->
  34. <div v-if="step === 2" class="step-content">
  35. <h2>选择预约时间</h2>
  36. <el-form label-width="120px" class="time-form">
  37. <el-form-item label="选择日期">
  38. <el-date-picker v-model="selectedDate" type="date" placeholder="选择日期" />
  39. </el-form-item>
  40. <el-form-item label="选择时段">
  41. <el-radio-group v-model="selectedTimeSlot">
  42. <el-radio value="MORNING">上午 (9:00-12:00)</el-radio>
  43. <el-radio value="AFTERNOON">下午 (14:00-18:00)</el-radio>
  44. </el-radio-group>
  45. </el-form-item>
  46. </el-form>
  47. <div class="step-actions">
  48. <el-button @click="step = 1">上一步</el-button>
  49. <el-button type="primary" :disabled="!selectedDate || !selectedTimeSlot" @click="step = 3">下一步</el-button>
  50. </div>
  51. </div>
  52. <!-- Step 3: 选择宠物 -->
  53. <div v-if="step === 3" class="step-content">
  54. <h2>选择宠物</h2>
  55. <PetSelector
  56. :pet-list="myPets"
  57. :selected="selectedPet"
  58. @update:selected="onPetSelected"
  59. />
  60. <div class="step-actions">
  61. <el-button @click="step = 2">上一步</el-button>
  62. <el-button type="primary" :disabled="!selectedPet" @click="step = 4">下一步</el-button>
  63. </div>
  64. </div>
  65. <!-- Step 4: 确认提交 -->
  66. <div v-if="step === 4" class="step-content">
  67. <h2>确认提交</h2>
  68. <el-card class="summary-card">
  69. <el-descriptions :column="2" border>
  70. <el-descriptions-item label="套餐">{{ selectedPackage?.name }}</el-descriptions-item>
  71. <el-descriptions-item label="价格">¥{{ selectedPackage?.price }}</el-descriptions-item>
  72. <el-descriptions-item label="日期">{{ formatDate(selectedDate) }}</el-descriptions-item>
  73. <el-descriptions-item label="时段">{{ timeSlotLabel }}</el-descriptions-item>
  74. <el-descriptions-item label="宠物">{{ selectedPet?.name }}</el-descriptions-item>
  75. </el-descriptions>
  76. </el-card>
  77. <div class="step-actions">
  78. <el-button @click="step = 3">上一步</el-button>
  79. <el-button type="primary" @click="handleSubmit">提交预约</el-button>
  80. </div>
  81. </div>
  82. <PaymentDialog
  83. v-model:visible="showPayment"
  84. :amount="depositAmount"
  85. :order-id="createdOrderId"
  86. @success="onPaymentSuccess"
  87. />
  88. </div>
  89. </template>
  90. <script setup lang="ts">
  91. import { computed, ref, onMounted } from 'vue'
  92. import { useRouter } from 'vue-router'
  93. import { ElMessage } from 'element-plus'
  94. import type { Pet, Package } from '@/type'
  95. import { getPackageController } from '@/api/package-controller'
  96. import { getPetController } from '@/api/pet-controller'
  97. import { getAppointmentController } from '@/api/appointment-controller'
  98. import type { AppointmentDto } from '@/api/models'
  99. import PetSelector from '@/components/PetSelector.vue'
  100. import PaymentDialog from '@/components/PaymentDialog.vue'
  101. const router = useRouter()
  102. const step = ref(1)
  103. // Step 1
  104. const packages = ref<Package[]>([])
  105. const selectedPackageId = ref('')
  106. const selectedPackage = computed(() => packages.value.find(p => p.id === selectedPackageId.value))
  107. // Step 2
  108. const selectedDate = ref<Date | null>(null)
  109. const selectedTimeSlot = ref('')
  110. const timeSlotLabel = computed(() => {
  111. const map: Record<string, string> = { MORNING: '上午', AFTERNOON: '下午' }
  112. return map[selectedTimeSlot.value] || ''
  113. })
  114. // Step 3
  115. const myPets = ref<Pet[]>([])
  116. const selectedPet = ref<Pet | null>(null)
  117. // Payment
  118. const showPayment = ref(false)
  119. const depositAmount = ref(0)
  120. const createdOrderId = ref('')
  121. const fetchPackages = async () => {
  122. try {
  123. const api = getPackageController()
  124. const res = await api.list4({ type: 'GROOMING' })
  125. if (res.code === 200 && res.data) {
  126. packages.value = res.data.map(p => ({
  127. id: String(p.id ?? ''),
  128. type: 'GROOMING' as const,
  129. name: p.name ?? '',
  130. price: p.price ?? 0,
  131. description: p.description,
  132. durationDays: p.duration ?? 0,
  133. isActive: true
  134. }))
  135. }
  136. } catch {
  137. // ignore
  138. }
  139. }
  140. const fetchPets = async () => {
  141. try {
  142. const api = getPetController()
  143. const res = await api.list()
  144. if (res.code === 200 && res.data) {
  145. myPets.value = res.data.map(p => ({
  146. id: String(p.id ?? ''),
  147. name: p.name ?? '',
  148. breed: p.breed ?? '',
  149. age: p.birthDate ? Math.floor((Date.now() - new Date(p.birthDate).getTime()) / 31557600000) : 0,
  150. weight: p.weight ?? 0,
  151. gender: (p.gender as Pet['gender']) ?? 'MALE',
  152. notes: p.remark
  153. }))
  154. }
  155. } catch {
  156. // ignore
  157. }
  158. }
  159. const formatDate = (d: Date | null): string =>
  160. d
  161. ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
  162. : ''
  163. const onPetSelected = (pet: Pet) => {
  164. selectedPet.value = pet
  165. }
  166. const handleSubmit = async () => {
  167. try {
  168. const api = getAppointmentController()
  169. const dto: AppointmentDto = {
  170. packageId: Number(selectedPackageId.value),
  171. petId: Number(selectedPet.value?.id),
  172. startDate: selectedDate.value?.toISOString().slice(0, 10)
  173. }
  174. const res = await api.create(dto)
  175. if (res.code === 200 && res.data) {
  176. ElMessage.success('预约提交成功')
  177. const pkg = selectedPackage.value
  178. depositAmount.value = pkg ? Math.round(pkg.price * 0.3) : 0
  179. createdOrderId.value = String(res.data.id ?? '')
  180. showPayment.value = true
  181. } else {
  182. ElMessage.error(res.message || '预约提交失败')
  183. }
  184. } catch (err: any) {
  185. ElMessage.error(err?.response?.data?.message || err?.message || err || '预约提交失败')
  186. }
  187. }
  188. const onPaymentSuccess = () => {
  189. ElMessage.success('支付成功')
  190. router.push('/orders')
  191. }
  192. onMounted(() => {
  193. fetchPackages()
  194. fetchPets()
  195. })
  196. </script>
  197. <style scoped>
  198. .grooming-booking {
  199. max-width: 800px;
  200. margin: 0 auto;
  201. padding: 40px 20px;
  202. }
  203. .steps {
  204. margin-bottom: 40px;
  205. }
  206. .step-content {
  207. text-align: center;
  208. }
  209. .step-actions {
  210. margin-top: 32px;
  211. display: flex;
  212. gap: 12px;
  213. justify-content: center;
  214. }
  215. .package-list {
  216. display: flex;
  217. flex-direction: column;
  218. gap: 12px;
  219. max-width: 500px;
  220. margin: 20px auto;
  221. }
  222. .package-card {
  223. cursor: pointer;
  224. transition: all 0.2s;
  225. text-align: left;
  226. }
  227. .package-card:hover {
  228. border-color: var(--el-color-primary);
  229. }
  230. .package-card.selected {
  231. border-color: var(--el-color-primary);
  232. background-color: rgba(227, 72, 137, 0.05);
  233. }
  234. .package-radio {
  235. width: 100%;
  236. }
  237. .price {
  238. color: var(--el-color-primary);
  239. font-weight: bold;
  240. }
  241. .time-form {
  242. max-width: 400px;
  243. margin: 20px auto;
  244. text-align: left;
  245. }
  246. .summary-card {
  247. max-width: 500px;
  248. margin: 20px auto;
  249. text-align: left;
  250. }
  251. .empty {
  252. margin: 40px 0;
  253. }
  254. </style>