| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567 |
- <template>
- <div class="store-page goods-detail-page app-page--wide">
- <Breadcrumb />
- <a-skeleton animation :loading="loading" class="store-spin">
- <StoreDetailSkeleton variant="goods" />
- <template #content>
- <div v-if="loadError" class="load-error">
- <a-empty description="商品加载失败">
- <a-button type="primary" @click="getGoodsDetail">重新加载</a-button>
- </a-empty>
- </div>
- <div v-else-if="data?.id" class="detail-layout">
- <aside class="detail-summary">
- <div class="summary-card">
- <div class="summary-visual">
- <GoodsEmoji :icon="data?.icon" size="xl" :aria-label="data?.name" />
- </div>
- <h1 class="summary-title">
- {{ data?.name }}
- <a-tag v-if="data?.limit > 0" color="orange">限购 {{ data.limit }} 件</a-tag>
- <a-tag v-if="data?.isHot" color="orangered">热销</a-tag>
- </h1>
- <p v-if="data?.description" class="summary-desc">{{ data.description }}</p>
- <ul v-if="featureList.length" class="summary-features">
- <li v-for="(f, i) in featureList" :key="i">
- <icon-check-circle-fill /> {{ f }}
- </li>
- </ul>
- <div class="summary-meta">
- <span class="price">¥{{ data?.price }}</span>
- <a-tag :color="(data?.num ?? 0) > 0 ? 'green' : 'red'">
- 库存 {{ stockLabel(data?.num) }}
- </a-tag>
- </div>
- <a-button
- type="primary"
- size="large"
- long
- class="buy-btn"
- :disabled="!(data?.num > 0)"
- @click="openCheckout"
- >
- {{ data?.num > 0 ? '立即购买' : '暂时缺货' }}
- </a-button>
- </div>
- </aside>
- <main class="detail-content">
- <a-card :bordered="false" class="content-card">
- <template #title>
- <span class="content-card__title">商品详情</span>
- </template>
- <div class="rich-content" v-html="content" />
- </a-card>
- </main>
- </div>
- </template>
- </a-skeleton>
- <div v-if="!loading && data?.num > 0" class="mobile-buy-bar">
- <span class="mobile-price">¥{{ payablePrice }}</span>
- <a-button type="primary" size="large" @click="openCheckout">立即购买</a-button>
- </div>
- <a-drawer
- v-model:visible="checkoutVisible"
- title="确认订单"
- :width="420"
- unmount-on-close
- class="checkout-drawer"
- >
- <div class="checkout-order">
- <div class="checkout-order__row">
- <span class="label">商品</span>
- <span class="value">{{ data?.name }}</span>
- </div>
- <div class="checkout-order__row">
- <span class="label">商品原价</span>
- <span class="value">¥{{ data?.price }}</span>
- </div>
- <div v-if="couponApplied" class="checkout-order__row checkout-order__row--discount">
- <span class="label">优惠码 {{ couponApplied.code }}</span>
- <span class="value discount">-¥{{ couponApplied.discountAmount }}</span>
- </div>
- <div class="checkout-order__row checkout-order__row--price">
- <span class="label">应付金额</span>
- <span class="value price">¥{{ payablePrice }}</span>
- </div>
- </div>
- <div class="checkout-section">
- <div class="checkout-section__title">优惠码(选填)</div>
- <div class="coupon-row">
- <a-input
- v-model="couponCode"
- placeholder="输入优惠码"
- allow-clear
- :max-length="32"
- @press-enter="applyCoupon"
- />
- <a-button type="outline" :loading="couponLoading" @click="applyCoupon">应用</a-button>
- </div>
- <p v-if="couponError" class="coupon-error">{{ couponError }}</p>
- <p v-else-if="couponApplied" class="coupon-success">
- 已应用:{{ couponApplied.displayDiscount }}
- </p>
- </div>
- <div class="checkout-section">
- <div class="checkout-section__title">选择支付方式</div>
- <PayMethodPicker v-model="payType" :methods="paymentMethods" :fallback-default="false" />
- <p v-if="paymentMethods.length === 0" class="payment-method-empty">
- 暂无可用支付方式,请稍后再试或联系管理员
- </p>
- </div>
- <template #footer>
- <a-button long size="large" @click="checkoutVisible = false">取消</a-button>
- <a-button
- type="primary"
- long
- size="large"
- :loading="submitting"
- class="submit-btn"
- @click="submitOrder"
- >
- 提交订单
- </a-button>
- </template>
- </a-drawer>
- </div>
- </template>
- <script setup>
- import { ref, computed } from 'vue'
- import { getGoods } from '@/api/goods'
- import { createOrder, getPaymentMethods } from '@/api/order'
- import { validateCoupon } from '@/api/coupon'
- import { useRoute, useRouter } from 'vue-router'
- import { Notification, Message } from '@arco-design/web-vue'
- import PayMethodPicker from '@/components/store/PayMethodPicker.vue'
- import GoodsEmoji from '@/components/store/GoodsEmoji.vue'
- import StoreDetailSkeleton from '@/components/skeleton/StoreDetailSkeleton.vue'
- import { stockLabel, parseFeatures, decodeGoodsContent } from '@/utils/storeFormat'
- import { submitPayment } from '@/utils/payment'
- const route = useRoute()
- const router = useRouter()
- const { id } = route.params
- const loading = ref(true)
- const loadError = ref(false)
- const data = ref({})
- const content = ref('')
- const checkoutVisible = ref(false)
- const payType = ref('')
- const submitting = ref(false)
- const couponCode = ref('')
- const couponApplied = ref(null)
- const couponLoading = ref(false)
- const couponError = ref('')
- const paymentMethods = ref([])
- const featureList = computed(() => parseFeatures(data.value?.features))
- const payablePrice = computed(() => {
- if (couponApplied.value?.finalPrice != null) return couponApplied.value.finalPrice
- return data.value?.price
- })
- const openCheckout = () => {
- payType.value = paymentMethods.value[0]?.type || ''
- couponCode.value = ''
- couponApplied.value = null
- couponError.value = ''
- checkoutVisible.value = true
- }
- const applyCoupon = async () => {
- const code = couponCode.value.trim()
- if (!code) {
- couponApplied.value = null
- couponError.value = ''
- return
- }
- try {
- couponLoading.value = true
- couponError.value = ''
- const res = await validateCoupon({ code, goods_id: id })
- if (!res || res.code !== 0) {
- couponApplied.value = null
- couponError.value = res?.msg ?? '优惠码无效'
- return
- }
- couponApplied.value = res.data
- couponCode.value = res.data.code
- Message.success('优惠码已应用')
- } catch (e) {
- couponApplied.value = null
- couponError.value = e.message || '校验失败'
- } finally {
- couponLoading.value = false
- }
- }
- const submitOrder = async () => {
- if (paymentMethods.value.length === 0) {
- Message.warning('暂无可用支付方式,请稍后再试')
- return
- }
- if (!payType.value) {
- Message.warning('请选择支付方式')
- return
- }
- if (!paymentMethods.value.some(method => method.type === payType.value)) {
- Message.warning('该支付方式当前不可用,请重新选择')
- payType.value = paymentMethods.value[0]?.type || ''
- return
- }
- const paymentWindow = window.open('', '_blank')
- if (!paymentWindow) {
- Message.warning('浏览器拦截了支付页弹窗,请允许弹窗后重试')
- return
- }
- try {
- submitting.value = true
- const payload = { goods_id: id, pay_type: payType.value }
- if (couponApplied.value?.code) payload.coupon_code = couponApplied.value.code
- const res = await createOrder(payload)
- if (!res || res.code !== 0) {
- if (paymentWindow && !paymentWindow.closed) paymentWindow.close()
- Notification.error({
- title: '创建订单失败',
- content: res?.msg ?? '请稍后再试'
- })
- return
- }
- checkoutVisible.value = false
- // 订单详情留在当前标签页,支付网关在新标签页完成,便于用户返回查看支付状态。
- await router.push(`/store/orderDetail/${res.id}`)
- if (submitPayment(res.pay, { targetWindow: paymentWindow })) return
- if (paymentWindow && !paymentWindow.closed) paymentWindow.close()
- // 兼容旧版本后端:支付参数暂不可用时仍可进入订单详情继续付款。
- Message.success('订单已创建,请在订单详情完成支付')
- } catch (error) {
- if (paymentWindow && !paymentWindow.closed) paymentWindow.close()
- Notification.error({
- title: '创建订单失败',
- content: error.message || '请稍后再试'
- })
- } finally {
- submitting.value = false
- }
- }
- const getGoodsDetail = async () => {
- try {
- loading.value = true
- loadError.value = false
- const res = await getGoods({ id })
- if (!res || res.code !== 0) {
- loadError.value = true
- Notification.error({
- title: '获取商品详情失败',
- content: res?.msg ?? '请稍后再试'
- })
- return
- }
- data.value = res.data ?? {}
- content.value = decodeGoodsContent(res.data?.content)
- } catch (error) {
- loadError.value = true
- Notification.error({
- title: '获取商品详情失败',
- content: error.message || '请稍后再试'
- })
- } finally {
- loading.value = false
- }
- }
- const loadPaymentMethods = async () => {
- try {
- const res = await getPaymentMethods()
- paymentMethods.value = Array.isArray(res?.data) ? res.data : []
- if (!paymentMethods.value.some(method => method.type === payType.value)) {
- payType.value = paymentMethods.value[0]?.type || ''
- }
- } catch (error) {
- paymentMethods.value = []
- }
- }
- getGoodsDetail()
- loadPaymentMethods()
- </script>
- <style lang="less" scoped>
- @import '@/styles/store-theme.less';
- .goods-detail-page {
- padding-bottom: 80px;
- @media (min-width: 768px) {
- padding-bottom: 32px;
- }
- }
- .load-error {
- padding: 48px 0;
- }
- .detail-layout {
- display: grid;
- grid-template-columns: 1fr;
- gap: 24px;
- width: 100%;
- @media (min-width: 900px) {
- grid-template-columns: 340px 1fr;
- align-items: start;
- }
- }
- .summary-card {
- background: @store-card-bg;
- border: 1px solid @store-card-border;
- border-radius: @store-radius;
- box-shadow: @store-shadow;
- padding: 24px;
- position: sticky;
- top: 16px;
- }
- .summary-visual {
- height: 160px;
- display: flex;
- align-items: center;
- justify-content: center;
- background: @store-cover-bg;
- border-radius: @store-radius-sm;
- margin-bottom: 20px;
- }
- .summary-title {
- margin: 0 0 12px;
- font-size: 1.35rem;
- font-weight: 600;
- color: @store-primary;
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 8px;
- }
- .summary-desc {
- margin: 0 0 16px;
- color: @store-text-muted;
- font-size: 0.9rem;
- line-height: 1.6;
- }
- .summary-features {
- list-style: none;
- margin: 0 0 20px;
- padding: 0;
- li {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 0.9rem;
- color: @store-text-muted;
- margin-bottom: 8px;
- svg {
- color: @store-accent;
- flex-shrink: 0;
- }
- }
- }
- .summary-meta {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 20px;
- .price {
- font-size: 2rem;
- font-weight: 700;
- color: @store-price;
- }
- }
- .buy-btn {
- border-radius: 999px;
- background: @store-primary !important;
- border-color: @store-primary !important;
- }
- .content-card {
- border-radius: @store-radius;
- border: 1px solid @store-card-border;
- box-shadow: @store-shadow;
- &__title {
- font-weight: 600;
- color: @store-primary;
- }
- }
- .rich-content {
- line-height: 1.75;
- color: @store-text-muted;
- :deep(img) {
- max-width: 100%;
- }
- }
- .mobile-buy-bar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- padding: 12px 20px;
- background: @store-card-bg;
- border-top: 1px solid @store-card-border;
- box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
- z-index: 100;
- @media (min-width: 900px) {
- display: none;
- }
- .mobile-price {
- font-size: 1.5rem;
- font-weight: 700;
- color: @store-price;
- }
- }
- .checkout-order {
- background: @store-bg;
- border-radius: @store-radius-sm;
- padding: 16px;
- margin-bottom: 24px;
- &__row {
- display: flex;
- justify-content: space-between;
- gap: 12px;
- padding: 8px 0;
- font-size: 0.95rem;
- .label {
- color: @store-text-muted;
- }
- .value {
- font-weight: 500;
- color: @store-primary;
- text-align: right;
- }
- &--price .price {
- font-size: 1.25rem;
- font-weight: 700;
- color: @store-price;
- }
- }
- }
- .checkout-section {
- margin-bottom: 20px;
- }
- .checkout-section__title {
- font-weight: 600;
- margin-bottom: 12px;
- color: @store-primary;
- }
- .coupon-row {
- display: flex;
- gap: 8px;
- .arco-input-wrapper {
- flex: 1;
- }
- }
- .coupon-error {
- margin: 8px 0 0;
- font-size: 12px;
- color: rgb(var(--red-6));
- }
- .coupon-success {
- margin: 8px 0 0;
- font-size: 12px;
- color: @store-accent;
- }
- .payment-method-empty {
- margin: 8px 0 0;
- font-size: 12px;
- color: rgb(var(--orange-6));
- }
- .checkout-order__row--discount .discount {
- color: rgb(var(--green-6));
- font-weight: 600;
- }
- .submit-btn {
- background: @store-primary !important;
- border-color: @store-primary !important;
- }
- @media (max-width: 768px) {
- .coupon-row {
- flex-direction: column;
- }
- .coupon-row :deep(.arco-btn) {
- width: 100%;
- }
- :deep(.checkout-drawer.arco-drawer) {
- width: 100vw !important;
- max-width: 100vw !important;
- }
- }
- </style>
- <style lang="less">
- @import '@/styles/store-theme.less';
- /* drawer 挂载到 body,使用非 scoped 保证页脚按钮全宽居中 */
- .checkout-drawer .arco-drawer-footer {
- display: flex;
- flex-direction: column;
- align-items: stretch;
- justify-content: center;
- gap: 10px;
- padding: 12px 20px 16px;
- box-sizing: border-box;
- }
- .checkout-drawer .arco-drawer-footer .arco-btn {
- width: 100%;
- margin: 0;
- }
- .checkout-drawer .arco-drawer-footer .submit-btn {
- background: @store-primary !important;
- border-color: @store-primary !important;
- }
- </style>
|