| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181 |
- <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="ossImg(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="ossImg(m.fileUrl)"
- mode="widthFix"
- class="detail-media-img"
- @load="onDetailMediaLoaded(idx)"
- @error="onDetailMediaLoaded(idx)"
- />
- <video
- v-else
- :src="m.fileUrl"
- :poster="ossImg(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'
- import { withOssVersion } from '@/utils/ossCache'
- 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 ossImg = withOssVersion
- 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>
|