| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179 |
- <template>
- <!-- 遮罩层阻止穿透滚动;面板内由子级 scroll-view 负责滚动 -->
- <view v-if="show" class="overlay-root client-theme">
- <view class="overlay-mask" @click="emitClose" @touchmove.stop.prevent />
- <view class="overlay-panel" :class="{ 'overlay-panel--with-tab': !!tabBarActive }">
- <view
- class="overlay-body"
- :class="{ 'overlay-body--with-inner-bar': !props.readonly && props.bottomMode !== 'none' }"
- >
- <view v-if="loading" class="overlay-skeleton" aria-label="加载中">
- <view class="skeleton-banner shimmer" />
- <view class="skeleton-info">
- <view class="skeleton-line skeleton-line--lg shimmer" />
- <view class="skeleton-line skeleton-line--md shimmer" />
- </view>
- <view class="skeleton-section">
- <view class="skeleton-title shimmer" />
- <view class="skeleton-row shimmer" />
- <view class="skeleton-row shimmer" />
- <view class="skeleton-row skeleton-row--short shimmer" />
- </view>
- </view>
- <view v-else-if="!product && !hasSoldOutFallback" class="empty">商品不存在</view>
- <view v-else class="overlay-main">
- <view class="overlay-sticky-top">
- <view class="banner-wrap">
- <swiper
- v-if="bannerList.length > 0"
- class="banner-swiper"
- :indicator-dots="bannerList.length > 1"
- :autoplay="true"
- :interval="3000"
- :circular="bannerList.length > 1"
- >
- <swiper-item v-for="(banner, index) in bannerList" :key="index">
- <view class="banner-media-wrap">
- <image
- v-if="banner.mediaType === 'image'"
- :src="banner.fileUrl"
- mode="aspectFill"
- class="banner-image"
- @load="onBannerMediaLoaded(index)"
- @error="onBannerMediaLoaded(index)"
- />
- <video
- v-else
- :src="banner.fileUrl"
- controls
- class="banner-video"
- @loadedmetadata="onBannerMediaLoaded(index)"
- @error="onBannerMediaLoaded(index)"
- />
- <view v-if="isBannerMediaLoading(index)" class="media-loading">
- <view class="media-loading-spinner" />
- </view>
- </view>
- </swiper-item>
- </swiper>
- <view v-else class="banner-empty">
- <text class="banner-empty-text">暂无轮播图</text>
- </view>
- <view class="banner-close" :style="{ '--sq-safe-top': `${safeTop}px` }" @click="emitClose">
- <image class="banner-close-icon" :src="ICON_ARROW_BOTTOM" mode="aspectFit" />
- </view>
- </view>
- <view class="product-info">
- <view class="info-top-row">
- <view class="info-left">
- <text class="name">{{ displayProductName }}</text>
- <text v-if="showSoldOutHint" class="sold-out-hint">{{ soldOutHintText }}</text>
- <view v-if="showPriceRow" class="price-row">
- <text class="price">¥{{ formatPrice(displayPriceFen) }}</text>
- <text v-if="showUnitInPriceRow" class="unit">/{{ displayMeasureText }}</text>
- </view>
- </view>
- <view v-if="!props.readonly" class="info-right" @click.stop>
- <view v-if="currentCartQty === 0" class="add-btn" @click.stop="addOneToCart">
- <text class="add-btn-plus">+</text>
- </view>
- <view v-else class="quantity-control">
- <view class="qty-btn" @click.stop="updateQuantity(-1)">
- <text>-</text>
- </view>
- <text class="qty-num">{{ currentCartQty }}</text>
- <view class="qty-btn" @click.stop="updateQuantity(1)">
- <text>+</text>
- </view>
- </view>
- </view>
- </view>
- </view>
- </view>
- <scroll-view scroll-y class="overlay-scroll" :show-scrollbar="true">
- <view class="overlay-scroll-inner">
- <view v-if="purchaseInfo" class="purchase-section">
- <view class="section-title">采购信息</view>
- <view class="purchase-grid">
- <view class="purchase-item">
- <text class="purchase-label">规格</text>
- <text class="purchase-value">{{ purchaseInfo.measureText || '-' }}</text>
- </view>
- <view class="purchase-item">
- <text class="purchase-label">单价</text>
- <text class="purchase-value">¥{{ formatPrice(purchaseInfo.unitPrice) }}</text>
- </view>
- <view class="purchase-item">
- <text class="purchase-label">数量</text>
- <text class="purchase-value">{{ purchaseInfo.quantity == null ? '-' : purchaseInfo.quantity }}</text>
- </view>
- <view class="purchase-item">
- <text class="purchase-label">小计</text>
- <text class="purchase-value purchase-value--strong">¥{{ formatPrice(purchaseInfo.amount) }}</text>
- </view>
- </view>
- </view>
- <view class="detail-section" v-if="detailContent">
- <view class="section-title">商品详情</view>
- <view class="rich-content">
- <rich-text :nodes="detailContent"></rich-text>
- </view>
- </view>
- <view class="params-section" v-if="parameterValues.length > 0">
- <view class="section-title">商品参数</view>
- <view class="params-list">
- <view v-for="param in parameterValues" :key="param.parameterId" class="param-item">
- <text class="param-name">{{ param.parameterName }}</text>
- <text class="param-value">{{ param.paramValue }}</text>
- </view>
- </view>
- </view>
- <view class="detail-section" v-if="detailMediaList.length > 0">
- <view class="section-title">详情图片/视频</view>
- <view class="detail-media-list">
- <view v-for="(m, idx) in detailMediaList" :key="m.id || `dm-${idx}`" class="detail-media-item">
- <view class="detail-media-wrap">
- <image
- v-if="m.contentType === 'image'"
- :src="m.fileUrl"
- mode="widthFix"
- class="detail-media-img"
- @load="onDetailMediaLoaded(idx)"
- @error="onDetailMediaLoaded(idx)"
- />
- <video
- v-else
- :src="m.fileUrl"
- :poster="m.thumbnailUrl || ''"
- controls
- class="detail-media-video"
- @loadedmetadata="onDetailMediaLoaded(idx)"
- @error="onDetailMediaLoaded(idx)"
- />
- <view v-if="isDetailMediaLoading(idx)" class="media-loading">
- <view class="media-loading-spinner" />
- </view>
- </view>
- <text v-if="m.description" class="detail-media-desc">{{ m.description }}</text>
- </view>
- </view>
- </view>
- </view>
- </scroll-view>
- </view>
- </view>
- <view v-if="product && !props.readonly && props.bottomMode !== 'none'" class="bottom-bar">
- <view class="cart-inner" @click="goToCart">
- <view class="cart-left">
- <view class="cart-count">
- <text>{{ cartCount > 99 ? '99+' : cartCount }}</text>
- </view>
- <view class="cart-text-col">
- <text class="cart-items-label">已选商品</text>
- <text class="cart-total-money">¥{{ formatPrice(cartStore.totalAmount) }}</text>
- </view>
- </view>
- <view class="cart-settle">
- <text>{{ props.bottomMode === 'cart' ? '去购物车' : '去结算' }}</text>
- </view>
- </view>
- </view>
- </view>
- <!-- 与订单详情等非 tabBar 页一致:浮层内展示底部导航,避免全屏遮挡为空白 -->
- <view v-if="tabBarActive" class="overlay-tab-bar-wrap">
- <ClientTabBar :active="tabBarActive" />
- </view>
- </view>
- </template>
- <script setup>
- import { computed, ref, watch } from 'vue'
- import ClientTabBar from '@/components/client/client-tab-bar.vue'
- import { request, parseResponseJson } from '@/utils/request'
- import { useCartStore } from '@/stores/cart'
- import { formatYuan } from '@/utils/format'
- import { ICON_ARROW_BOTTOM } from '@/utils/staticAsset'
- const props = defineProps({
- show: { type: Boolean, default: false },
- productId: { type: String, default: '' },
- readonly: { type: Boolean, default: false },
- bottomMode: { type: String, default: 'checkout' },
- tabBarActive: { type: String, default: undefined },
- initialCartQty: { type: Number, default: undefined },
- fallbackProductName: { type: String, default: '' },
- fallbackMeasureText: { type: String, default: '' },
- fallbackPrice: { type: Number, default: null },
- fallbackImageUrl: { type: String, default: '' },
- fallbackQuantity: { type: Number, default: null },
- fallbackLineAmount: { type: Number, default: null },
- fallbackHintText: { type: String, default: '此商品目前已售罄' }
- })
- const emit = defineEmits(['close'])
- const cartStore = useCartStore()
- const loading = ref(false)
- const product = ref(null)
- const bannerList = ref([])
- const parameterValues = ref([])
- const detailContent = ref('')
- const detailMediaList = ref([])
- const bannerMediaLoadedMap = ref({})
- const detailMediaLoadedMap = ref({})
- const soldOutFallbackActive = ref(false)
- const cartCount = ref(0)
- const localCartQty = ref(null)
- const qtyUpdating = ref(false)
- const safeTop = ref(0)
- const formatPrice = (fen) => formatYuan(fen ?? 0)
- function formatMeasureText(measureValue, unitName) {
- const unitText = (unitName || '').trim()
- if (measureValue == null || measureValue === '') {
- return unitText ? `- ${unitText}` : '-'
- }
- const num = Number(measureValue)
- if (!Number.isNaN(num)) {
- const measureText = String(num).replace(/\.?0+$/, '')
- return unitText ? `${measureText} ${unitText}` : measureText
- }
- const measureText = String(measureValue).trim()
- return unitText ? `${measureText} ${unitText}` : measureText || '-'
- }
- const currentCartQty = computed(() => {
- if (localCartQty.value != null) return Math.max(0, Number(localCartQty.value) || 0)
- const pid = product.value?.id
- if (!pid) return 0
- const pidStr = String(pid)
- const item = cartStore.items.find((i) => String(i.productId) === pidStr)
- if (item) return Number(item.quantity) || 0
- if (
- props.initialCartQty != null &&
- props.productId &&
- String(props.productId) === pidStr
- ) {
- return Number(props.initialCartQty) || 0
- }
- return 0
- })
- const hasSoldOutFallback = computed(() => {
- if (!props.readonly || !props.fallbackProductName) return false
- return soldOutFallbackActive.value || !props.productId
- })
- const soldOutHintText = computed(() => (props.fallbackHintText || '此商品目前已售罄').trim())
- const unavailableInRegion = ref(false)
- const displayProductName = computed(() => {
- if (product.value?.name) return product.value.name
- if (hasSoldOutFallback.value) return props.fallbackProductName
- return ''
- })
- const displayMeasureText = computed(() => {
- if (hasSoldOutFallback.value) return props.fallbackMeasureText || ''
- if (product.value) return formatMeasureText(product.value.measureValue, product.value.unitName)
- return ''
- })
- const displayPriceFen = computed(() => {
- // 订单详情只读场景:优先展示历史成交价(fallbackPrice),不受当前价目表影响
- if (props.readonly && props.fallbackPrice != null) return Number(props.fallbackPrice) || 0
- if (product.value?.price != null) return Number(product.value.price) || 0
- if (hasSoldOutFallback.value && props.fallbackPrice != null) return Number(props.fallbackPrice) || 0
- return null
- })
- const showPriceRow = computed(() => displayPriceFen.value != null)
- const showUnitInPriceRow = computed(() => {
- if (!displayMeasureText.value) return false
- // 订单详情等只读弹窗:价格后计量单位需要展示(即使当前区域不出售)
- if (props.readonly) return true
- return !unavailableInRegion.value
- })
- const showSoldOutHint = computed(() => hasSoldOutFallback.value || unavailableInRegion.value)
- const purchaseInfo = computed(() => {
- // 仅在无法加载商品详情、降级到历史快照时展示“采购信息”
- if (!props.readonly || !soldOutFallbackActive.value) return null
- const qty = props.fallbackQuantity
- const unitPrice = props.fallbackPrice
- const amount = props.fallbackLineAmount
- const measureText = (props.fallbackMeasureText || '').trim()
- return {
- unitPrice: unitPrice == null ? null : Number(unitPrice) || 0,
- quantity: qty == null ? null : Number(qty) || 0,
- amount: amount == null ? null : Number(amount) || 0,
- measureText
- }
- })
- function resetMediaLoadingState() {
- bannerMediaLoadedMap.value = {}
- detailMediaLoadedMap.value = {}
- }
- function onBannerMediaLoaded(index) {
- bannerMediaLoadedMap.value = {
- ...bannerMediaLoadedMap.value,
- [index]: true
- }
- }
- function onDetailMediaLoaded(index) {
- detailMediaLoadedMap.value = {
- ...detailMediaLoadedMap.value,
- [index]: true
- }
- }
- function isBannerMediaLoading(index) {
- return !bannerMediaLoadedMap.value[index]
- }
- function isDetailMediaLoading(index) {
- return !detailMediaLoadedMap.value[index]
- }
- function emitClose() {
- emit('close')
- }
- async function addOneToCart() {
- if (!product.value?.id) return
- if (qtyUpdating.value) return
- qtyUpdating.value = true
- const prev = currentCartQty.value
- localCartQty.value = prev + 1
- try {
- const ok = await cartStore.addToCart(product.value, 1)
- if (!ok) {
- localCartQty.value = prev
- }
- cartCount.value = cartStore.totalCount
- } finally {
- qtyUpdating.value = false
- }
- }
- async function updateQuantity(delta) {
- const pid = product.value?.id
- if (!pid) return
- if (qtyUpdating.value) return
- const pidStr = String(pid)
- const cartItem = cartStore.items.find((item) => String(item.productId) === pidStr)
- const prev = currentCartQty.value
- const target = prev + delta
- if (target < 0) return
- qtyUpdating.value = true
- localCartQty.value = target
- if (cartItem) {
- if (target <= 0) {
- const ok = await cartStore.removeItem(cartItem.id)
- if (!ok) {
- localCartQty.value = prev
- }
- } else {
- const ok = await cartStore.updateQuantity(cartItem.id, target)
- if (!ok) {
- localCartQty.value = prev
- }
- }
- cartCount.value = cartStore.totalCount
- qtyUpdating.value = false
- return
- }
- if (delta > 0) {
- const ok = await cartStore.addToCart(product.value, delta)
- if (!ok) {
- localCartQty.value = prev
- }
- cartCount.value = cartStore.totalCount
- }
- qtyUpdating.value = false
- }
- async function loadProductDetail(productId) {
- loading.value = true
- soldOutFallbackActive.value = false
- unavailableInRegion.value = false
- resetMediaLoadingState()
- const applySoldOutFallback = () => {
- soldOutFallbackActive.value = true
- product.value = {
- id: productId || '',
- name: props.fallbackProductName || '',
- measureValue: '',
- unitName: '',
- price: props.fallbackPrice == null ? 0 : Number(props.fallbackPrice) || 0
- }
- bannerList.value = props.fallbackImageUrl
- ? [{ mediaType: 'image', fileUrl: props.fallbackImageUrl }]
- : []
- parameterValues.value = []
- detailContent.value = ''
- detailMediaList.value = []
- bannerMediaLoadedMap.value = Object.fromEntries(bannerList.value.map((_, i) => [i, false]))
- detailMediaLoadedMap.value = {}
- }
- try {
- // 只读(订单详情)优先尝试价目表接口:用于判断“该区域是否出售”
- let body = null
- if (props.readonly) {
- const resCheck = await request({ url: `/mini/client/products/${productId}`, method: 'GET' })
- const checkBody = parseResponseJson(resCheck) || {}
- const msg = String(checkBody.message || '')
- const notInPriceList = msg.includes('商品不存在或不在当前价目表')
- if (checkBody.code === 200 && checkBody.data) {
- body = checkBody
- } else if (notInPriceList) {
- unavailableInRegion.value = true
- }
- }
- // 非只读 / 或只读但价目表接口不可用:拉“只读详情”保证内容完整展示
- if (!body) {
- const res = await request({
- url: props.readonly ? `/mini/client/products-ro/${productId}` : `/mini/client/products/${productId}`,
- method: 'GET'
- })
- body = parseResponseJson(res) || {}
- }
- if (body.code !== 200 || !body.data) {
- product.value = null
- const msg = String(body.message || '')
- const unavailable = msg.includes('商品不存在或不在当前价目表')
- if (props.readonly && unavailable && props.fallbackProductName) {
- applySoldOutFallback()
- return
- }
- if (props.readonly && props.fallbackProductName) {
- applySoldOutFallback()
- return
- }
- uni.showToast({ title: body.message || '加载失败', icon: 'none' })
- return
- }
- product.value = body.data.product || body.data || null
- bannerList.value = Array.isArray(body.data.banners) ? body.data.banners : []
- parameterValues.value = Array.isArray(body.data.parameterValues) ? body.data.parameterValues : []
- detailContent.value = product.value?.detailContent || ''
- detailMediaList.value = Array.isArray(body.data.detailMedia)
- ? body.data.detailMedia.map((item) => ({
- id: item.id,
- contentType: item.contentType || item.content_type || 'image',
- fileUrl: item.fileUrl || item.file_url || '',
- thumbnailUrl: item.thumbnailUrl || item.thumbnail_url,
- description: item.description,
- sortOrder: item.sortOrder ?? item.sort_order ?? 0
- }))
- : []
- bannerMediaLoadedMap.value = Object.fromEntries(bannerList.value.map((_, i) => [i, false]))
- detailMediaLoadedMap.value = Object.fromEntries(detailMediaList.value.map((_, i) => [i, false]))
- } catch {
- if (props.readonly && props.fallbackProductName) {
- applySoldOutFallback()
- return
- }
- uni.showToast({ title: '加载失败', icon: 'none' })
- } finally {
- loading.value = false
- }
- }
- function goToCart() {
- emitClose()
- uni.switchTab({
- url: '/pages/client/cart/cart',
- fail: () => {
- uni.reLaunch({ url: '/pages/client/cart/cart' })
- }
- })
- }
- function ensureSafeTop() {
- const si = uni.getSystemInfoSync?.()
- let top = 0
- // H5 真机(浏览器/微信内置浏览器)下 safeAreaInsets.top 在部分机型会偏大,
- // 这里统一让 H5 走 CSS 的 env(safe-area-inset-top);小程序端再走系统值。
- // #ifdef MP-WEIXIN
- // 统一口径:让“关闭按钮”的顶部偏移 = 设备安全区顶部 - 当前页面 webview 顶部(windowTop)
- // - 自定义导航栏页:windowTop 通常为 0 => top ≈ statusBarHeight
- // - 原生导航栏页:windowTop 通常包含导航栏占位 => top 变为 0(避免叠加导致按钮偏下)
- const safeInsetTop = Number(si?.safeAreaInsets?.top ?? si?.statusBarHeight ?? 0) || 0
- const windowTop = Number(si?.windowTop ?? 0) || 0
- top = safeInsetTop - windowTop
- // #endif
- // #ifndef MP-WEIXIN
- top = 0
- // #endif
- // 保护:出现异常大值时,直接归零(避免按钮被推得很靠下)
- const n = Math.max(0, Number(top) || 0)
- safeTop.value = n > 60 ? 0 : n
- }
- watch(
- () => props.show,
- (v) => {
- if (v) {
- ensureSafeTop()
- cartCount.value = cartStore.totalCount
- localCartQty.value = null
- if (props.productId) void loadProductDetail(props.productId)
- } else {
- loading.value = false
- localCartQty.value = null
- qtyUpdating.value = false
- soldOutFallbackActive.value = false
- }
- },
- { immediate: true }
- )
- watch(
- () => props.productId,
- (id) => {
- if (props.show && id) void loadProductDetail(id)
- }
- )
- </script>
- <style lang="scss" scoped>
- .overlay-root {
- position: fixed;
- inset: 0;
- z-index: 99999;
- }
- .overlay-mask {
- position: absolute;
- inset: 0;
- background: rgba(0, 0, 0, 0.4);
- }
- .overlay-panel {
- position: absolute;
- inset: 0;
- background: #fff;
- display: flex;
- flex-direction: column;
- }
- /* 为底部自定义 Tab 留出空间,避免白面板盖住导航栏区域 */
- .overlay-panel--with-tab {
- bottom: calc(88rpx + env(safe-area-inset-bottom, 0px));
- }
- /* 有底部 Tab 时,面板已整体上移,body 不再额外加底部留白,避免出现透明空层 */
- .overlay-panel--with-tab .overlay-body {
- padding-bottom: 0;
- }
- .overlay-tab-bar-wrap {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 100001;
- }
- .overlay-body {
- flex: 1;
- min-height: 0;
- background: #ffffff;
- display: flex;
- flex-direction: column;
- padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
- }
- .overlay-body--with-inner-bar {
- padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
- }
- .overlay-main {
- flex: 1;
- min-height: 0;
- display: flex;
- flex-direction: column;
- }
- .overlay-sticky-top {
- flex-shrink: 0;
- }
- .overlay-scroll {
- flex: 1;
- min-height: 0;
- height: 0;
- background: var(--c-bg);
- }
- .overlay-skeleton {
- padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
- background: var(--c-bg);
- }
- .skeleton-banner {
- aspect-ratio: 4 / 3;
- height: auto;
- background: #e8f1ed;
- }
- .skeleton-info {
- background: #fff;
- padding: 20rpx;
- border-bottom: 12rpx solid var(--c-bg);
- }
- .skeleton-line {
- height: 28rpx;
- border-radius: 14rpx;
- background: #e8f1ed;
- margin-bottom: 14rpx;
- }
- .skeleton-line--lg {
- height: 40rpx;
- width: 72%;
- }
- .skeleton-line--md {
- width: 48%;
- margin-bottom: 0;
- }
- .skeleton-section {
- background: #fff;
- padding: 20rpx;
- }
- .skeleton-title {
- width: 180rpx;
- height: 32rpx;
- border-radius: 14rpx;
- background: #e8f1ed;
- margin-bottom: 18rpx;
- }
- .skeleton-row {
- height: 24rpx;
- border-radius: 12rpx;
- background: #e8f1ed;
- margin-bottom: 14rpx;
- }
- .skeleton-row--short {
- width: 68%;
- margin-bottom: 0;
- }
- .shimmer {
- position: relative;
- overflow: hidden;
- }
- .shimmer::after {
- content: '';
- position: absolute;
- inset: 0;
- transform: translateX(-100%);
- background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.52), rgba(255, 255, 255, 0));
- animation: shimmer-slide 1.2s infinite;
- }
- @keyframes shimmer-slide {
- 100% {
- transform: translateX(100%);
- }
- }
- .overlay-scroll-inner {
- padding-bottom: 0;
- }
- .loading,
- .empty {
- padding: 40rpx;
- text-align: center;
- color: #909399;
- font-size: 26rpx;
- }
- .banner-wrap {
- position: relative;
- border-top-left-radius: 24rpx;
- border-top-right-radius: 24rpx;
- overflow: hidden;
- border-bottom: 12rpx solid var(--c-bg);
- }
- .banner-swiper {
- width: 100%;
- aspect-ratio: 4 / 3;
- height: auto;
- background: #fff;
- .banner-image,
- .banner-video {
- width: 100%;
- height: 100%;
- }
- }
- .banner-media-wrap,
- .detail-media-wrap {
- position: relative;
- height: 100%;
- }
- .banner-empty {
- width: 100%;
- aspect-ratio: 4 / 3;
- height: auto;
- background: #f5f7fa;
- display: flex;
- align-items: center;
- justify-content: center;
- .banner-empty-text {
- font-size: 28rpx;
- color: #c0c4cc;
- }
- }
- /* 小程序端对 aspect-ratio 支持不稳定:给轮播图明确高度,避免 loading 遮罩飘到顶部 */
- /* #ifndef H5 */
- .banner-swiper,
- .banner-empty {
- height: 540rpx;
- }
- /* #endif */
- .banner-close {
- position: absolute;
- left: 20rpx;
- z-index: 999;
- width: 60rpx;
- height: 60rpx;
- border-radius: 50%;
- background: rgba(239, 243, 246, 0.92);
- display: flex;
- align-items: center;
- justify-content: center;
- border: 1rpx solid rgba(221, 229, 235, 0.9);
- }
- /* 小程序:只用系统状态栏高度(safeTop),避免与 env(...) 叠加导致 top 过大 */
- /* #ifdef MP-WEIXIN */
- .banner-close {
- top: calc(var(--sq-safe-top, 0px) + 8px);
- }
- /* #endif */
- /* H5:用 env(safe-area-inset-top) 适配刘海屏 */
- /* #ifndef MP-WEIXIN */
- .banner-close {
- top: calc(env(safe-area-inset-top, 0px) + 8px);
- }
- /* #endif */
- .banner-close-icon {
- width: 28rpx;
- height: 28rpx;
- }
- .product-info {
- margin-top: 0;
- background: #fff;
- padding: 20rpx;
- border-bottom: 12rpx solid var(--c-bg);
- .info-top-row {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 18rpx;
- }
- .info-left {
- flex: 1;
- min-width: 0;
- }
- .info-right {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: flex-end;
- min-width: 180rpx;
- padding-top: 6rpx;
- }
- .price-row {
- display: flex;
- align-items: baseline;
- gap: 8rpx;
- margin-bottom: 12rpx;
- }
- .price {
- font-size: 44rpx;
- color: var(--c-danger);
- font-weight: 700;
- }
- .unit {
- font-size: 26rpx;
- color: #909399;
- }
- .name {
- display: block;
- font-size: 32rpx;
- color: var(--c-text);
- font-weight: 600;
- line-height: 1.45;
- white-space: normal;
- word-break: break-word;
- overflow-wrap: break-word;
- }
- .measure-text {
- display: block;
- margin-top: 10rpx;
- font-size: 24rpx;
- color: var(--c-muted);
- line-height: 1.4;
- }
- .sold-out-hint {
- display: block;
- margin-top: 8rpx;
- font-size: 24rpx;
- color: #d97706;
- line-height: 1.4;
- }
- }
- .add-btn {
- width: 54rpx;
- height: 54rpx;
- background: var(--c-brand-green);
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- color: #ffffff;
- font-size: 34rpx;
- font-weight: 700;
- box-sizing: border-box;
- padding: 0;
- line-height: 1;
- }
- /* 「+」在圆内几何居中:抵消 text 默认行高/基线导致的视觉偏移 */
- .add-btn-plus {
- line-height: 1;
- font-size: inherit;
- font-weight: inherit;
- color: inherit;
- text-align: center;
- display: block;
- transform: translateY(-2rpx);
- }
- .quantity-control {
- display: flex;
- align-items: center;
- gap: 10rpx;
- }
- .qty-btn {
- width: 48rpx;
- height: 48rpx;
- border: 2rpx solid var(--c-brand-green);
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--c-brand-green);
- font-size: 28rpx;
- font-weight: 700;
- background: #fff;
- }
- .qty-num {
- min-width: 40rpx;
- text-align: center;
- font-size: 28rpx;
- color: var(--c-text);
- }
- .section-title {
- padding: 18rpx 20rpx;
- font-size: 28rpx;
- color: var(--c-text);
- font-weight: 600;
- background: #fff;
- border-top: 1rpx solid var(--c-border);
- }
- .detail-section {
- margin-top: 0;
- background: #fff;
- border-bottom: 12rpx solid var(--c-bg);
- .rich-content {
- padding: 0 20rpx 16rpx;
- font-size: 28rpx;
- line-height: 1.6;
- color: var(--c-text);
- }
- .detail-media-list {
- padding-bottom: 8rpx;
- }
- .detail-media-item {
- border-top: 1rpx solid #f5f7fa;
- }
- .detail-media-img {
- width: 100%;
- display: block;
- }
- .detail-media-video {
- width: 100%;
- height: 420rpx;
- display: block;
- background: #000;
- }
- .detail-media-desc {
- display: block;
- padding: 16rpx 24rpx 24rpx;
- font-size: 24rpx;
- color: #606266;
- line-height: 1.5;
- }
- }
- .media-loading {
- position: absolute;
- inset: 0;
- background: rgba(245, 248, 247, 0.85);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 2;
- }
- .media-loading-spinner {
- width: 40rpx;
- height: 40rpx;
- border-radius: 50%;
- border: 4rpx solid rgba(10, 159, 109, 0.2);
- border-top-color: var(--c-brand-green);
- animation: spin 0.9s linear infinite;
- }
- @keyframes spin {
- to {
- transform: rotate(360deg);
- }
- }
- .params-section {
- margin-top: 0;
- background: #fff;
- border-bottom: 12rpx solid var(--c-bg);
- .params-list {
- padding: 0 20rpx 16rpx;
- }
- .param-item {
- display: flex;
- padding: 10rpx 0;
- border-bottom: 1rpx solid #f5f7fa;
- &:last-child {
- border-bottom: none;
- }
- }
- .param-name {
- width: 180rpx;
- font-size: 26rpx;
- color: #909399;
- }
- .param-value {
- flex: 1;
- font-size: 26rpx;
- color: var(--c-text);
- }
- }
- .purchase-section {
- margin-top: 0;
- background: #fff;
- border-bottom: 12rpx solid var(--c-bg);
- }
- .purchase-grid {
- padding: 6rpx 20rpx 18rpx;
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 16rpx 18rpx;
- box-sizing: border-box;
- }
- .purchase-item {
- min-width: 0;
- padding: 14rpx 16rpx;
- border: 1rpx solid #eef3f0;
- border-radius: 18rpx;
- background: #fbfdfc;
- box-sizing: border-box;
- }
- .purchase-label {
- display: block;
- font-size: 22rpx;
- color: var(--c-muted);
- line-height: 1.2;
- }
- .purchase-value {
- display: block;
- margin-top: 10rpx;
- font-size: 28rpx;
- color: var(--c-text);
- font-weight: 600;
- line-height: 1.25;
- word-break: break-word;
- }
- .purchase-value--strong {
- color: var(--c-danger);
- }
- /* 最后一个内容块不再额外留底部分隔,避免尾部出现空白条 */
- .overlay-scroll-inner > view:last-child {
- border-bottom: 0;
- }
- .bottom-bar {
- position: absolute;
- left: 50%;
- transform: translateX(-50%);
- z-index: 4;
- width: 100%;
- max-width: 840rpx;
- bottom: constant(safe-area-inset-bottom);
- bottom: env(safe-area-inset-bottom, 0px);
- padding: 0 24rpx 14rpx;
- background: transparent;
- box-sizing: border-box;
- }
- .cart-inner {
- min-height: 96rpx;
- border-radius: 36rpx;
- background: linear-gradient(145deg, #ffffff, #f5fbf8);
- border: 1rpx solid #d9ebe3;
- color: var(--c-text);
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12rpx;
- box-shadow: 0 16rpx 36rpx rgba(10, 159, 109, 0.12);
- box-sizing: border-box;
- }
- .cart-left {
- display: flex;
- align-items: center;
- gap: 20rpx;
- padding-left: 8rpx;
- flex: 1;
- min-width: 0;
- }
- .cart-count {
- min-width: 52rpx;
- height: 52rpx;
- padding: 0 8rpx;
- border-radius: 26rpx;
- background: linear-gradient(145deg, var(--c-brand-green), var(--c-brand-green-2));
- display: inline-flex;
- align-items: center;
- justify-content: center;
- box-sizing: border-box;
- text {
- font-size: 24rpx;
- font-weight: 700;
- color: #fff;
- }
- }
- .cart-text-col {
- display: flex;
- flex-direction: column;
- gap: 2rpx;
- min-width: 0;
- }
- .cart-items-label {
- font-size: 22rpx;
- color: #6f8179;
- }
- .cart-total-money {
- font-size: 32rpx;
- font-weight: 700;
- letter-spacing: 0.4rpx;
- color: #1f2a26;
- }
- .cart-settle {
- flex-shrink: 0;
- min-height: 72rpx;
- padding: 0 32rpx;
- border-radius: 28rpx;
- background: linear-gradient(145deg, var(--c-barley-yellow), #e4b458);
- color: #fff;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- font-size: 28rpx;
- font-weight: 700;
- box-shadow: inset 0 -2rpx 0 rgba(0, 0, 0, 0.1);
- text {
- color: #fff;
- }
- }
- </style>
|