index.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. <template>
  2. <div class="store-page goods-detail-page app-page--wide">
  3. <Breadcrumb />
  4. <a-skeleton animation :loading="loading" class="store-spin">
  5. <StoreDetailSkeleton variant="goods" />
  6. <template #content>
  7. <div v-if="loadError" class="load-error">
  8. <a-empty description="商品加载失败">
  9. <a-button type="primary" @click="getGoodsDetail">重新加载</a-button>
  10. </a-empty>
  11. </div>
  12. <div v-else-if="data?.id" class="detail-layout">
  13. <aside class="detail-summary">
  14. <div class="summary-card">
  15. <div class="summary-visual">
  16. <GoodsEmoji :icon="data?.icon" size="xl" :aria-label="data?.name" />
  17. </div>
  18. <h1 class="summary-title">
  19. {{ data?.name }}
  20. <a-tag v-if="data?.limit > 0" color="orange">限购 {{ data.limit }} 件</a-tag>
  21. <a-tag v-if="data?.isHot" color="orangered">热销</a-tag>
  22. </h1>
  23. <p v-if="data?.description" class="summary-desc">{{ data.description }}</p>
  24. <ul v-if="featureList.length" class="summary-features">
  25. <li v-for="(f, i) in featureList" :key="i">
  26. <icon-check-circle-fill /> {{ f }}
  27. </li>
  28. </ul>
  29. <div class="summary-meta">
  30. <span class="price">¥{{ data?.price }}</span>
  31. <a-tag :color="(data?.num ?? 0) > 0 ? 'green' : 'red'">
  32. 库存 {{ stockLabel(data?.num) }}
  33. </a-tag>
  34. </div>
  35. <a-button
  36. type="primary"
  37. size="large"
  38. long
  39. class="buy-btn"
  40. :disabled="!(data?.num > 0)"
  41. @click="openCheckout"
  42. >
  43. {{ data?.num > 0 ? '立即购买' : '暂时缺货' }}
  44. </a-button>
  45. </div>
  46. </aside>
  47. <main class="detail-content">
  48. <a-card :bordered="false" class="content-card">
  49. <template #title>
  50. <span class="content-card__title">商品详情</span>
  51. </template>
  52. <div class="rich-content" v-html="content" />
  53. </a-card>
  54. </main>
  55. </div>
  56. </template>
  57. </a-skeleton>
  58. <div v-if="!loading && data?.num > 0" class="mobile-buy-bar">
  59. <span class="mobile-price">¥{{ payablePrice }}</span>
  60. <a-button type="primary" size="large" @click="openCheckout">立即购买</a-button>
  61. </div>
  62. <a-drawer
  63. v-model:visible="checkoutVisible"
  64. title="确认订单"
  65. :width="420"
  66. unmount-on-close
  67. class="checkout-drawer"
  68. >
  69. <div class="checkout-order">
  70. <div class="checkout-order__row">
  71. <span class="label">商品</span>
  72. <span class="value">{{ data?.name }}</span>
  73. </div>
  74. <div class="checkout-order__row">
  75. <span class="label">商品原价</span>
  76. <span class="value">¥{{ data?.price }}</span>
  77. </div>
  78. <div v-if="couponApplied" class="checkout-order__row checkout-order__row--discount">
  79. <span class="label">优惠码 {{ couponApplied.code }}</span>
  80. <span class="value discount">-¥{{ couponApplied.discountAmount }}</span>
  81. </div>
  82. <div class="checkout-order__row checkout-order__row--price">
  83. <span class="label">应付金额</span>
  84. <span class="value price">¥{{ payablePrice }}</span>
  85. </div>
  86. </div>
  87. <div class="checkout-section">
  88. <div class="checkout-section__title">优惠码(选填)</div>
  89. <div class="coupon-row">
  90. <a-input
  91. v-model="couponCode"
  92. placeholder="输入优惠码"
  93. allow-clear
  94. :max-length="32"
  95. @press-enter="applyCoupon"
  96. />
  97. <a-button type="outline" :loading="couponLoading" @click="applyCoupon">应用</a-button>
  98. </div>
  99. <p v-if="couponError" class="coupon-error">{{ couponError }}</p>
  100. <p v-else-if="couponApplied" class="coupon-success">
  101. 已应用:{{ couponApplied.displayDiscount }}
  102. </p>
  103. </div>
  104. <div class="checkout-section">
  105. <div class="checkout-section__title">选择支付方式</div>
  106. <PayMethodPicker v-model="payType" :methods="paymentMethods" :fallback-default="false" />
  107. <p v-if="paymentMethods.length === 0" class="payment-method-empty">
  108. 暂无可用支付方式,请稍后再试或联系管理员
  109. </p>
  110. </div>
  111. <template #footer>
  112. <a-button long size="large" @click="checkoutVisible = false">取消</a-button>
  113. <a-button
  114. type="primary"
  115. long
  116. size="large"
  117. :loading="submitting"
  118. class="submit-btn"
  119. @click="submitOrder"
  120. >
  121. 提交订单
  122. </a-button>
  123. </template>
  124. </a-drawer>
  125. </div>
  126. </template>
  127. <script setup>
  128. import { ref, computed } from 'vue'
  129. import { getGoods } from '@/api/goods'
  130. import { createOrder, getPaymentMethods } from '@/api/order'
  131. import { validateCoupon } from '@/api/coupon'
  132. import { useRoute, useRouter } from 'vue-router'
  133. import { Notification, Message } from '@arco-design/web-vue'
  134. import PayMethodPicker from '@/components/store/PayMethodPicker.vue'
  135. import GoodsEmoji from '@/components/store/GoodsEmoji.vue'
  136. import StoreDetailSkeleton from '@/components/skeleton/StoreDetailSkeleton.vue'
  137. import { stockLabel, parseFeatures, decodeGoodsContent } from '@/utils/storeFormat'
  138. import { submitPayment } from '@/utils/payment'
  139. const route = useRoute()
  140. const router = useRouter()
  141. const { id } = route.params
  142. const loading = ref(true)
  143. const loadError = ref(false)
  144. const data = ref({})
  145. const content = ref('')
  146. const checkoutVisible = ref(false)
  147. const payType = ref('')
  148. const submitting = ref(false)
  149. const couponCode = ref('')
  150. const couponApplied = ref(null)
  151. const couponLoading = ref(false)
  152. const couponError = ref('')
  153. const paymentMethods = ref([])
  154. const featureList = computed(() => parseFeatures(data.value?.features))
  155. const payablePrice = computed(() => {
  156. if (couponApplied.value?.finalPrice != null) return couponApplied.value.finalPrice
  157. return data.value?.price
  158. })
  159. const openCheckout = () => {
  160. payType.value = paymentMethods.value[0]?.type || ''
  161. couponCode.value = ''
  162. couponApplied.value = null
  163. couponError.value = ''
  164. checkoutVisible.value = true
  165. }
  166. const applyCoupon = async () => {
  167. const code = couponCode.value.trim()
  168. if (!code) {
  169. couponApplied.value = null
  170. couponError.value = ''
  171. return
  172. }
  173. try {
  174. couponLoading.value = true
  175. couponError.value = ''
  176. const res = await validateCoupon({ code, goods_id: id })
  177. if (!res || res.code !== 0) {
  178. couponApplied.value = null
  179. couponError.value = res?.msg ?? '优惠码无效'
  180. return
  181. }
  182. couponApplied.value = res.data
  183. couponCode.value = res.data.code
  184. Message.success('优惠码已应用')
  185. } catch (e) {
  186. couponApplied.value = null
  187. couponError.value = e.message || '校验失败'
  188. } finally {
  189. couponLoading.value = false
  190. }
  191. }
  192. const submitOrder = async () => {
  193. if (paymentMethods.value.length === 0) {
  194. Message.warning('暂无可用支付方式,请稍后再试')
  195. return
  196. }
  197. if (!payType.value) {
  198. Message.warning('请选择支付方式')
  199. return
  200. }
  201. if (!paymentMethods.value.some(method => method.type === payType.value)) {
  202. Message.warning('该支付方式当前不可用,请重新选择')
  203. payType.value = paymentMethods.value[0]?.type || ''
  204. return
  205. }
  206. const paymentWindow = window.open('', '_blank')
  207. if (!paymentWindow) {
  208. Message.warning('浏览器拦截了支付页弹窗,请允许弹窗后重试')
  209. return
  210. }
  211. try {
  212. submitting.value = true
  213. const payload = { goods_id: id, pay_type: payType.value }
  214. if (couponApplied.value?.code) payload.coupon_code = couponApplied.value.code
  215. const res = await createOrder(payload)
  216. if (!res || res.code !== 0) {
  217. if (paymentWindow && !paymentWindow.closed) paymentWindow.close()
  218. Notification.error({
  219. title: '创建订单失败',
  220. content: res?.msg ?? '请稍后再试'
  221. })
  222. return
  223. }
  224. checkoutVisible.value = false
  225. // 订单详情留在当前标签页,支付网关在新标签页完成,便于用户返回查看支付状态。
  226. await router.push(`/store/orderDetail/${res.id}`)
  227. if (submitPayment(res.pay, { targetWindow: paymentWindow })) return
  228. if (paymentWindow && !paymentWindow.closed) paymentWindow.close()
  229. // 兼容旧版本后端:支付参数暂不可用时仍可进入订单详情继续付款。
  230. Message.success('订单已创建,请在订单详情完成支付')
  231. } catch (error) {
  232. if (paymentWindow && !paymentWindow.closed) paymentWindow.close()
  233. Notification.error({
  234. title: '创建订单失败',
  235. content: error.message || '请稍后再试'
  236. })
  237. } finally {
  238. submitting.value = false
  239. }
  240. }
  241. const getGoodsDetail = async () => {
  242. try {
  243. loading.value = true
  244. loadError.value = false
  245. const res = await getGoods({ id })
  246. if (!res || res.code !== 0) {
  247. loadError.value = true
  248. Notification.error({
  249. title: '获取商品详情失败',
  250. content: res?.msg ?? '请稍后再试'
  251. })
  252. return
  253. }
  254. data.value = res.data ?? {}
  255. content.value = decodeGoodsContent(res.data?.content)
  256. } catch (error) {
  257. loadError.value = true
  258. Notification.error({
  259. title: '获取商品详情失败',
  260. content: error.message || '请稍后再试'
  261. })
  262. } finally {
  263. loading.value = false
  264. }
  265. }
  266. const loadPaymentMethods = async () => {
  267. try {
  268. const res = await getPaymentMethods()
  269. paymentMethods.value = Array.isArray(res?.data) ? res.data : []
  270. if (!paymentMethods.value.some(method => method.type === payType.value)) {
  271. payType.value = paymentMethods.value[0]?.type || ''
  272. }
  273. } catch (error) {
  274. paymentMethods.value = []
  275. }
  276. }
  277. getGoodsDetail()
  278. loadPaymentMethods()
  279. </script>
  280. <style lang="less" scoped>
  281. @import '@/styles/store-theme.less';
  282. .goods-detail-page {
  283. padding-bottom: 80px;
  284. @media (min-width: 768px) {
  285. padding-bottom: 32px;
  286. }
  287. }
  288. .load-error {
  289. padding: 48px 0;
  290. }
  291. .detail-layout {
  292. display: grid;
  293. grid-template-columns: 1fr;
  294. gap: 24px;
  295. width: 100%;
  296. @media (min-width: 900px) {
  297. grid-template-columns: 340px 1fr;
  298. align-items: start;
  299. }
  300. }
  301. .summary-card {
  302. background: @store-card-bg;
  303. border: 1px solid @store-card-border;
  304. border-radius: @store-radius;
  305. box-shadow: @store-shadow;
  306. padding: 24px;
  307. position: sticky;
  308. top: 16px;
  309. }
  310. .summary-visual {
  311. height: 160px;
  312. display: flex;
  313. align-items: center;
  314. justify-content: center;
  315. background: @store-cover-bg;
  316. border-radius: @store-radius-sm;
  317. margin-bottom: 20px;
  318. }
  319. .summary-title {
  320. margin: 0 0 12px;
  321. font-size: 1.35rem;
  322. font-weight: 600;
  323. color: @store-primary;
  324. display: flex;
  325. flex-wrap: wrap;
  326. align-items: center;
  327. gap: 8px;
  328. }
  329. .summary-desc {
  330. margin: 0 0 16px;
  331. color: @store-text-muted;
  332. font-size: 0.9rem;
  333. line-height: 1.6;
  334. }
  335. .summary-features {
  336. list-style: none;
  337. margin: 0 0 20px;
  338. padding: 0;
  339. li {
  340. display: flex;
  341. align-items: center;
  342. gap: 8px;
  343. font-size: 0.9rem;
  344. color: @store-text-muted;
  345. margin-bottom: 8px;
  346. svg {
  347. color: @store-accent;
  348. flex-shrink: 0;
  349. }
  350. }
  351. }
  352. .summary-meta {
  353. display: flex;
  354. align-items: center;
  355. justify-content: space-between;
  356. margin-bottom: 20px;
  357. .price {
  358. font-size: 2rem;
  359. font-weight: 700;
  360. color: @store-price;
  361. }
  362. }
  363. .buy-btn {
  364. border-radius: 999px;
  365. background: @store-primary !important;
  366. border-color: @store-primary !important;
  367. }
  368. .content-card {
  369. border-radius: @store-radius;
  370. border: 1px solid @store-card-border;
  371. box-shadow: @store-shadow;
  372. &__title {
  373. font-weight: 600;
  374. color: @store-primary;
  375. }
  376. }
  377. .rich-content {
  378. line-height: 1.75;
  379. color: @store-text-muted;
  380. :deep(img) {
  381. max-width: 100%;
  382. }
  383. }
  384. .mobile-buy-bar {
  385. display: flex;
  386. align-items: center;
  387. justify-content: space-between;
  388. gap: 16px;
  389. position: fixed;
  390. left: 0;
  391. right: 0;
  392. bottom: 0;
  393. padding: 12px 20px;
  394. background: @store-card-bg;
  395. border-top: 1px solid @store-card-border;
  396. box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
  397. z-index: 100;
  398. @media (min-width: 900px) {
  399. display: none;
  400. }
  401. .mobile-price {
  402. font-size: 1.5rem;
  403. font-weight: 700;
  404. color: @store-price;
  405. }
  406. }
  407. .checkout-order {
  408. background: @store-bg;
  409. border-radius: @store-radius-sm;
  410. padding: 16px;
  411. margin-bottom: 24px;
  412. &__row {
  413. display: flex;
  414. justify-content: space-between;
  415. gap: 12px;
  416. padding: 8px 0;
  417. font-size: 0.95rem;
  418. .label {
  419. color: @store-text-muted;
  420. }
  421. .value {
  422. font-weight: 500;
  423. color: @store-primary;
  424. text-align: right;
  425. }
  426. &--price .price {
  427. font-size: 1.25rem;
  428. font-weight: 700;
  429. color: @store-price;
  430. }
  431. }
  432. }
  433. .checkout-section {
  434. margin-bottom: 20px;
  435. }
  436. .checkout-section__title {
  437. font-weight: 600;
  438. margin-bottom: 12px;
  439. color: @store-primary;
  440. }
  441. .coupon-row {
  442. display: flex;
  443. gap: 8px;
  444. .arco-input-wrapper {
  445. flex: 1;
  446. }
  447. }
  448. .coupon-error {
  449. margin: 8px 0 0;
  450. font-size: 12px;
  451. color: rgb(var(--red-6));
  452. }
  453. .coupon-success {
  454. margin: 8px 0 0;
  455. font-size: 12px;
  456. color: @store-accent;
  457. }
  458. .payment-method-empty {
  459. margin: 8px 0 0;
  460. font-size: 12px;
  461. color: rgb(var(--orange-6));
  462. }
  463. .checkout-order__row--discount .discount {
  464. color: rgb(var(--green-6));
  465. font-weight: 600;
  466. }
  467. .submit-btn {
  468. background: @store-primary !important;
  469. border-color: @store-primary !important;
  470. }
  471. @media (max-width: 768px) {
  472. .coupon-row {
  473. flex-direction: column;
  474. }
  475. .coupon-row :deep(.arco-btn) {
  476. width: 100%;
  477. }
  478. :deep(.checkout-drawer.arco-drawer) {
  479. width: 100vw !important;
  480. max-width: 100vw !important;
  481. }
  482. }
  483. </style>
  484. <style lang="less">
  485. @import '@/styles/store-theme.less';
  486. /* drawer 挂载到 body,使用非 scoped 保证页脚按钮全宽居中 */
  487. .checkout-drawer .arco-drawer-footer {
  488. display: flex;
  489. flex-direction: column;
  490. align-items: stretch;
  491. justify-content: center;
  492. gap: 10px;
  493. padding: 12px 20px 16px;
  494. box-sizing: border-box;
  495. }
  496. .checkout-drawer .arco-drawer-footer .arco-btn {
  497. width: 100%;
  498. margin: 0;
  499. }
  500. .checkout-drawer .arco-drawer-footer .submit-btn {
  501. background: @store-primary !important;
  502. border-color: @store-primary !important;
  503. }
  504. </style>