product-detail-overlay.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179
  1. <template>
  2. <!-- 遮罩层阻止穿透滚动;面板内由子级 scroll-view 负责滚动 -->
  3. <view v-if="show" class="overlay-root client-theme">
  4. <view class="overlay-mask" @click="emitClose" @touchmove.stop.prevent />
  5. <view class="overlay-panel" :class="{ 'overlay-panel--with-tab': !!tabBarActive }">
  6. <view
  7. class="overlay-body"
  8. :class="{ 'overlay-body--with-inner-bar': !props.readonly && props.bottomMode !== 'none' }"
  9. >
  10. <view v-if="loading" class="overlay-skeleton" aria-label="加载中">
  11. <view class="skeleton-banner shimmer" />
  12. <view class="skeleton-info">
  13. <view class="skeleton-line skeleton-line--lg shimmer" />
  14. <view class="skeleton-line skeleton-line--md shimmer" />
  15. </view>
  16. <view class="skeleton-section">
  17. <view class="skeleton-title shimmer" />
  18. <view class="skeleton-row shimmer" />
  19. <view class="skeleton-row shimmer" />
  20. <view class="skeleton-row skeleton-row--short shimmer" />
  21. </view>
  22. </view>
  23. <view v-else-if="!product && !hasSoldOutFallback" class="empty">商品不存在</view>
  24. <view v-else class="overlay-main">
  25. <view class="overlay-sticky-top">
  26. <view class="banner-wrap">
  27. <swiper
  28. v-if="bannerList.length > 0"
  29. class="banner-swiper"
  30. :indicator-dots="bannerList.length > 1"
  31. :autoplay="true"
  32. :interval="3000"
  33. :circular="bannerList.length > 1"
  34. >
  35. <swiper-item v-for="(banner, index) in bannerList" :key="index">
  36. <view class="banner-media-wrap">
  37. <image
  38. v-if="banner.mediaType === 'image'"
  39. :src="banner.fileUrl"
  40. mode="aspectFill"
  41. class="banner-image"
  42. @load="onBannerMediaLoaded(index)"
  43. @error="onBannerMediaLoaded(index)"
  44. />
  45. <video
  46. v-else
  47. :src="banner.fileUrl"
  48. controls
  49. class="banner-video"
  50. @loadedmetadata="onBannerMediaLoaded(index)"
  51. @error="onBannerMediaLoaded(index)"
  52. />
  53. <view v-if="isBannerMediaLoading(index)" class="media-loading">
  54. <view class="media-loading-spinner" />
  55. </view>
  56. </view>
  57. </swiper-item>
  58. </swiper>
  59. <view v-else class="banner-empty">
  60. <text class="banner-empty-text">暂无轮播图</text>
  61. </view>
  62. <view class="banner-close" :style="{ '--sq-safe-top': `${safeTop}px` }" @click="emitClose">
  63. <image class="banner-close-icon" :src="ICON_ARROW_BOTTOM" mode="aspectFit" />
  64. </view>
  65. </view>
  66. <view class="product-info">
  67. <view class="info-top-row">
  68. <view class="info-left">
  69. <text class="name">{{ displayProductName }}</text>
  70. <text v-if="showSoldOutHint" class="sold-out-hint">{{ soldOutHintText }}</text>
  71. <view v-if="showPriceRow" class="price-row">
  72. <text class="price">¥{{ formatPrice(displayPriceFen) }}</text>
  73. <text v-if="showUnitInPriceRow" class="unit">/{{ displayMeasureText }}</text>
  74. </view>
  75. </view>
  76. <view v-if="!props.readonly" class="info-right" @click.stop>
  77. <view v-if="currentCartQty === 0" class="add-btn" @click.stop="addOneToCart">
  78. <text class="add-btn-plus">+</text>
  79. </view>
  80. <view v-else class="quantity-control">
  81. <view class="qty-btn" @click.stop="updateQuantity(-1)">
  82. <text>-</text>
  83. </view>
  84. <text class="qty-num">{{ currentCartQty }}</text>
  85. <view class="qty-btn" @click.stop="updateQuantity(1)">
  86. <text>+</text>
  87. </view>
  88. </view>
  89. </view>
  90. </view>
  91. </view>
  92. </view>
  93. <scroll-view scroll-y class="overlay-scroll" :show-scrollbar="true">
  94. <view class="overlay-scroll-inner">
  95. <view v-if="purchaseInfo" class="purchase-section">
  96. <view class="section-title">采购信息</view>
  97. <view class="purchase-grid">
  98. <view class="purchase-item">
  99. <text class="purchase-label">规格</text>
  100. <text class="purchase-value">{{ purchaseInfo.measureText || '-' }}</text>
  101. </view>
  102. <view class="purchase-item">
  103. <text class="purchase-label">单价</text>
  104. <text class="purchase-value">¥{{ formatPrice(purchaseInfo.unitPrice) }}</text>
  105. </view>
  106. <view class="purchase-item">
  107. <text class="purchase-label">数量</text>
  108. <text class="purchase-value">{{ purchaseInfo.quantity == null ? '-' : purchaseInfo.quantity }}</text>
  109. </view>
  110. <view class="purchase-item">
  111. <text class="purchase-label">小计</text>
  112. <text class="purchase-value purchase-value--strong">¥{{ formatPrice(purchaseInfo.amount) }}</text>
  113. </view>
  114. </view>
  115. </view>
  116. <view class="detail-section" v-if="detailContent">
  117. <view class="section-title">商品详情</view>
  118. <view class="rich-content">
  119. <rich-text :nodes="detailContent"></rich-text>
  120. </view>
  121. </view>
  122. <view class="params-section" v-if="parameterValues.length > 0">
  123. <view class="section-title">商品参数</view>
  124. <view class="params-list">
  125. <view v-for="param in parameterValues" :key="param.parameterId" class="param-item">
  126. <text class="param-name">{{ param.parameterName }}</text>
  127. <text class="param-value">{{ param.paramValue }}</text>
  128. </view>
  129. </view>
  130. </view>
  131. <view class="detail-section" v-if="detailMediaList.length > 0">
  132. <view class="section-title">详情图片/视频</view>
  133. <view class="detail-media-list">
  134. <view v-for="(m, idx) in detailMediaList" :key="m.id || `dm-${idx}`" class="detail-media-item">
  135. <view class="detail-media-wrap">
  136. <image
  137. v-if="m.contentType === 'image'"
  138. :src="m.fileUrl"
  139. mode="widthFix"
  140. class="detail-media-img"
  141. @load="onDetailMediaLoaded(idx)"
  142. @error="onDetailMediaLoaded(idx)"
  143. />
  144. <video
  145. v-else
  146. :src="m.fileUrl"
  147. :poster="m.thumbnailUrl || ''"
  148. controls
  149. class="detail-media-video"
  150. @loadedmetadata="onDetailMediaLoaded(idx)"
  151. @error="onDetailMediaLoaded(idx)"
  152. />
  153. <view v-if="isDetailMediaLoading(idx)" class="media-loading">
  154. <view class="media-loading-spinner" />
  155. </view>
  156. </view>
  157. <text v-if="m.description" class="detail-media-desc">{{ m.description }}</text>
  158. </view>
  159. </view>
  160. </view>
  161. </view>
  162. </scroll-view>
  163. </view>
  164. </view>
  165. <view v-if="product && !props.readonly && props.bottomMode !== 'none'" class="bottom-bar">
  166. <view class="cart-inner" @click="goToCart">
  167. <view class="cart-left">
  168. <view class="cart-count">
  169. <text>{{ cartCount > 99 ? '99+' : cartCount }}</text>
  170. </view>
  171. <view class="cart-text-col">
  172. <text class="cart-items-label">已选商品</text>
  173. <text class="cart-total-money">¥{{ formatPrice(cartStore.totalAmount) }}</text>
  174. </view>
  175. </view>
  176. <view class="cart-settle">
  177. <text>{{ props.bottomMode === 'cart' ? '去购物车' : '去结算' }}</text>
  178. </view>
  179. </view>
  180. </view>
  181. </view>
  182. <!-- 与订单详情等非 tabBar 页一致:浮层内展示底部导航,避免全屏遮挡为空白 -->
  183. <view v-if="tabBarActive" class="overlay-tab-bar-wrap">
  184. <ClientTabBar :active="tabBarActive" />
  185. </view>
  186. </view>
  187. </template>
  188. <script setup>
  189. import { computed, ref, watch } from 'vue'
  190. import ClientTabBar from '@/components/client/client-tab-bar.vue'
  191. import { request, parseResponseJson } from '@/utils/request'
  192. import { useCartStore } from '@/stores/cart'
  193. import { formatYuan } from '@/utils/format'
  194. import { ICON_ARROW_BOTTOM } from '@/utils/staticAsset'
  195. const props = defineProps({
  196. show: { type: Boolean, default: false },
  197. productId: { type: String, default: '' },
  198. readonly: { type: Boolean, default: false },
  199. bottomMode: { type: String, default: 'checkout' },
  200. tabBarActive: { type: String, default: undefined },
  201. initialCartQty: { type: Number, default: undefined },
  202. fallbackProductName: { type: String, default: '' },
  203. fallbackMeasureText: { type: String, default: '' },
  204. fallbackPrice: { type: Number, default: null },
  205. fallbackImageUrl: { type: String, default: '' },
  206. fallbackQuantity: { type: Number, default: null },
  207. fallbackLineAmount: { type: Number, default: null },
  208. fallbackHintText: { type: String, default: '此商品目前已售罄' }
  209. })
  210. const emit = defineEmits(['close'])
  211. const cartStore = useCartStore()
  212. const loading = ref(false)
  213. const product = ref(null)
  214. const bannerList = ref([])
  215. const parameterValues = ref([])
  216. const detailContent = ref('')
  217. const detailMediaList = ref([])
  218. const bannerMediaLoadedMap = ref({})
  219. const detailMediaLoadedMap = ref({})
  220. const soldOutFallbackActive = ref(false)
  221. const cartCount = ref(0)
  222. const localCartQty = ref(null)
  223. const qtyUpdating = ref(false)
  224. const safeTop = ref(0)
  225. const formatPrice = (fen) => formatYuan(fen ?? 0)
  226. function formatMeasureText(measureValue, unitName) {
  227. const unitText = (unitName || '').trim()
  228. if (measureValue == null || measureValue === '') {
  229. return unitText ? `- ${unitText}` : '-'
  230. }
  231. const num = Number(measureValue)
  232. if (!Number.isNaN(num)) {
  233. const measureText = String(num).replace(/\.?0+$/, '')
  234. return unitText ? `${measureText} ${unitText}` : measureText
  235. }
  236. const measureText = String(measureValue).trim()
  237. return unitText ? `${measureText} ${unitText}` : measureText || '-'
  238. }
  239. const currentCartQty = computed(() => {
  240. if (localCartQty.value != null) return Math.max(0, Number(localCartQty.value) || 0)
  241. const pid = product.value?.id
  242. if (!pid) return 0
  243. const pidStr = String(pid)
  244. const item = cartStore.items.find((i) => String(i.productId) === pidStr)
  245. if (item) return Number(item.quantity) || 0
  246. if (
  247. props.initialCartQty != null &&
  248. props.productId &&
  249. String(props.productId) === pidStr
  250. ) {
  251. return Number(props.initialCartQty) || 0
  252. }
  253. return 0
  254. })
  255. const hasSoldOutFallback = computed(() => {
  256. if (!props.readonly || !props.fallbackProductName) return false
  257. return soldOutFallbackActive.value || !props.productId
  258. })
  259. const soldOutHintText = computed(() => (props.fallbackHintText || '此商品目前已售罄').trim())
  260. const unavailableInRegion = ref(false)
  261. const displayProductName = computed(() => {
  262. if (product.value?.name) return product.value.name
  263. if (hasSoldOutFallback.value) return props.fallbackProductName
  264. return ''
  265. })
  266. const displayMeasureText = computed(() => {
  267. if (hasSoldOutFallback.value) return props.fallbackMeasureText || ''
  268. if (product.value) return formatMeasureText(product.value.measureValue, product.value.unitName)
  269. return ''
  270. })
  271. const displayPriceFen = computed(() => {
  272. // 订单详情只读场景:优先展示历史成交价(fallbackPrice),不受当前价目表影响
  273. if (props.readonly && props.fallbackPrice != null) return Number(props.fallbackPrice) || 0
  274. if (product.value?.price != null) return Number(product.value.price) || 0
  275. if (hasSoldOutFallback.value && props.fallbackPrice != null) return Number(props.fallbackPrice) || 0
  276. return null
  277. })
  278. const showPriceRow = computed(() => displayPriceFen.value != null)
  279. const showUnitInPriceRow = computed(() => {
  280. if (!displayMeasureText.value) return false
  281. // 订单详情等只读弹窗:价格后计量单位需要展示(即使当前区域不出售)
  282. if (props.readonly) return true
  283. return !unavailableInRegion.value
  284. })
  285. const showSoldOutHint = computed(() => hasSoldOutFallback.value || unavailableInRegion.value)
  286. const purchaseInfo = computed(() => {
  287. // 仅在无法加载商品详情、降级到历史快照时展示“采购信息”
  288. if (!props.readonly || !soldOutFallbackActive.value) return null
  289. const qty = props.fallbackQuantity
  290. const unitPrice = props.fallbackPrice
  291. const amount = props.fallbackLineAmount
  292. const measureText = (props.fallbackMeasureText || '').trim()
  293. return {
  294. unitPrice: unitPrice == null ? null : Number(unitPrice) || 0,
  295. quantity: qty == null ? null : Number(qty) || 0,
  296. amount: amount == null ? null : Number(amount) || 0,
  297. measureText
  298. }
  299. })
  300. function resetMediaLoadingState() {
  301. bannerMediaLoadedMap.value = {}
  302. detailMediaLoadedMap.value = {}
  303. }
  304. function onBannerMediaLoaded(index) {
  305. bannerMediaLoadedMap.value = {
  306. ...bannerMediaLoadedMap.value,
  307. [index]: true
  308. }
  309. }
  310. function onDetailMediaLoaded(index) {
  311. detailMediaLoadedMap.value = {
  312. ...detailMediaLoadedMap.value,
  313. [index]: true
  314. }
  315. }
  316. function isBannerMediaLoading(index) {
  317. return !bannerMediaLoadedMap.value[index]
  318. }
  319. function isDetailMediaLoading(index) {
  320. return !detailMediaLoadedMap.value[index]
  321. }
  322. function emitClose() {
  323. emit('close')
  324. }
  325. async function addOneToCart() {
  326. if (!product.value?.id) return
  327. if (qtyUpdating.value) return
  328. qtyUpdating.value = true
  329. const prev = currentCartQty.value
  330. localCartQty.value = prev + 1
  331. try {
  332. const ok = await cartStore.addToCart(product.value, 1)
  333. if (!ok) {
  334. localCartQty.value = prev
  335. }
  336. cartCount.value = cartStore.totalCount
  337. } finally {
  338. qtyUpdating.value = false
  339. }
  340. }
  341. async function updateQuantity(delta) {
  342. const pid = product.value?.id
  343. if (!pid) return
  344. if (qtyUpdating.value) return
  345. const pidStr = String(pid)
  346. const cartItem = cartStore.items.find((item) => String(item.productId) === pidStr)
  347. const prev = currentCartQty.value
  348. const target = prev + delta
  349. if (target < 0) return
  350. qtyUpdating.value = true
  351. localCartQty.value = target
  352. if (cartItem) {
  353. if (target <= 0) {
  354. const ok = await cartStore.removeItem(cartItem.id)
  355. if (!ok) {
  356. localCartQty.value = prev
  357. }
  358. } else {
  359. const ok = await cartStore.updateQuantity(cartItem.id, target)
  360. if (!ok) {
  361. localCartQty.value = prev
  362. }
  363. }
  364. cartCount.value = cartStore.totalCount
  365. qtyUpdating.value = false
  366. return
  367. }
  368. if (delta > 0) {
  369. const ok = await cartStore.addToCart(product.value, delta)
  370. if (!ok) {
  371. localCartQty.value = prev
  372. }
  373. cartCount.value = cartStore.totalCount
  374. }
  375. qtyUpdating.value = false
  376. }
  377. async function loadProductDetail(productId) {
  378. loading.value = true
  379. soldOutFallbackActive.value = false
  380. unavailableInRegion.value = false
  381. resetMediaLoadingState()
  382. const applySoldOutFallback = () => {
  383. soldOutFallbackActive.value = true
  384. product.value = {
  385. id: productId || '',
  386. name: props.fallbackProductName || '',
  387. measureValue: '',
  388. unitName: '',
  389. price: props.fallbackPrice == null ? 0 : Number(props.fallbackPrice) || 0
  390. }
  391. bannerList.value = props.fallbackImageUrl
  392. ? [{ mediaType: 'image', fileUrl: props.fallbackImageUrl }]
  393. : []
  394. parameterValues.value = []
  395. detailContent.value = ''
  396. detailMediaList.value = []
  397. bannerMediaLoadedMap.value = Object.fromEntries(bannerList.value.map((_, i) => [i, false]))
  398. detailMediaLoadedMap.value = {}
  399. }
  400. try {
  401. // 只读(订单详情)优先尝试价目表接口:用于判断“该区域是否出售”
  402. let body = null
  403. if (props.readonly) {
  404. const resCheck = await request({ url: `/mini/client/products/${productId}`, method: 'GET' })
  405. const checkBody = parseResponseJson(resCheck) || {}
  406. const msg = String(checkBody.message || '')
  407. const notInPriceList = msg.includes('商品不存在或不在当前价目表')
  408. if (checkBody.code === 200 && checkBody.data) {
  409. body = checkBody
  410. } else if (notInPriceList) {
  411. unavailableInRegion.value = true
  412. }
  413. }
  414. // 非只读 / 或只读但价目表接口不可用:拉“只读详情”保证内容完整展示
  415. if (!body) {
  416. const res = await request({
  417. url: props.readonly ? `/mini/client/products-ro/${productId}` : `/mini/client/products/${productId}`,
  418. method: 'GET'
  419. })
  420. body = parseResponseJson(res) || {}
  421. }
  422. if (body.code !== 200 || !body.data) {
  423. product.value = null
  424. const msg = String(body.message || '')
  425. const unavailable = msg.includes('商品不存在或不在当前价目表')
  426. if (props.readonly && unavailable && props.fallbackProductName) {
  427. applySoldOutFallback()
  428. return
  429. }
  430. if (props.readonly && props.fallbackProductName) {
  431. applySoldOutFallback()
  432. return
  433. }
  434. uni.showToast({ title: body.message || '加载失败', icon: 'none' })
  435. return
  436. }
  437. product.value = body.data.product || body.data || null
  438. bannerList.value = Array.isArray(body.data.banners) ? body.data.banners : []
  439. parameterValues.value = Array.isArray(body.data.parameterValues) ? body.data.parameterValues : []
  440. detailContent.value = product.value?.detailContent || ''
  441. detailMediaList.value = Array.isArray(body.data.detailMedia)
  442. ? body.data.detailMedia.map((item) => ({
  443. id: item.id,
  444. contentType: item.contentType || item.content_type || 'image',
  445. fileUrl: item.fileUrl || item.file_url || '',
  446. thumbnailUrl: item.thumbnailUrl || item.thumbnail_url,
  447. description: item.description,
  448. sortOrder: item.sortOrder ?? item.sort_order ?? 0
  449. }))
  450. : []
  451. bannerMediaLoadedMap.value = Object.fromEntries(bannerList.value.map((_, i) => [i, false]))
  452. detailMediaLoadedMap.value = Object.fromEntries(detailMediaList.value.map((_, i) => [i, false]))
  453. } catch {
  454. if (props.readonly && props.fallbackProductName) {
  455. applySoldOutFallback()
  456. return
  457. }
  458. uni.showToast({ title: '加载失败', icon: 'none' })
  459. } finally {
  460. loading.value = false
  461. }
  462. }
  463. function goToCart() {
  464. emitClose()
  465. uni.switchTab({
  466. url: '/pages/client/cart/cart',
  467. fail: () => {
  468. uni.reLaunch({ url: '/pages/client/cart/cart' })
  469. }
  470. })
  471. }
  472. function ensureSafeTop() {
  473. const si = uni.getSystemInfoSync?.()
  474. let top = 0
  475. // H5 真机(浏览器/微信内置浏览器)下 safeAreaInsets.top 在部分机型会偏大,
  476. // 这里统一让 H5 走 CSS 的 env(safe-area-inset-top);小程序端再走系统值。
  477. // #ifdef MP-WEIXIN
  478. // 统一口径:让“关闭按钮”的顶部偏移 = 设备安全区顶部 - 当前页面 webview 顶部(windowTop)
  479. // - 自定义导航栏页:windowTop 通常为 0 => top ≈ statusBarHeight
  480. // - 原生导航栏页:windowTop 通常包含导航栏占位 => top 变为 0(避免叠加导致按钮偏下)
  481. const safeInsetTop = Number(si?.safeAreaInsets?.top ?? si?.statusBarHeight ?? 0) || 0
  482. const windowTop = Number(si?.windowTop ?? 0) || 0
  483. top = safeInsetTop - windowTop
  484. // #endif
  485. // #ifndef MP-WEIXIN
  486. top = 0
  487. // #endif
  488. // 保护:出现异常大值时,直接归零(避免按钮被推得很靠下)
  489. const n = Math.max(0, Number(top) || 0)
  490. safeTop.value = n > 60 ? 0 : n
  491. }
  492. watch(
  493. () => props.show,
  494. (v) => {
  495. if (v) {
  496. ensureSafeTop()
  497. cartCount.value = cartStore.totalCount
  498. localCartQty.value = null
  499. if (props.productId) void loadProductDetail(props.productId)
  500. } else {
  501. loading.value = false
  502. localCartQty.value = null
  503. qtyUpdating.value = false
  504. soldOutFallbackActive.value = false
  505. }
  506. },
  507. { immediate: true }
  508. )
  509. watch(
  510. () => props.productId,
  511. (id) => {
  512. if (props.show && id) void loadProductDetail(id)
  513. }
  514. )
  515. </script>
  516. <style lang="scss" scoped>
  517. .overlay-root {
  518. position: fixed;
  519. inset: 0;
  520. z-index: 99999;
  521. }
  522. .overlay-mask {
  523. position: absolute;
  524. inset: 0;
  525. background: rgba(0, 0, 0, 0.4);
  526. }
  527. .overlay-panel {
  528. position: absolute;
  529. inset: 0;
  530. background: #fff;
  531. display: flex;
  532. flex-direction: column;
  533. }
  534. /* 为底部自定义 Tab 留出空间,避免白面板盖住导航栏区域 */
  535. .overlay-panel--with-tab {
  536. bottom: calc(88rpx + env(safe-area-inset-bottom, 0px));
  537. }
  538. /* 有底部 Tab 时,面板已整体上移,body 不再额外加底部留白,避免出现透明空层 */
  539. .overlay-panel--with-tab .overlay-body {
  540. padding-bottom: 0;
  541. }
  542. .overlay-tab-bar-wrap {
  543. position: fixed;
  544. left: 0;
  545. right: 0;
  546. bottom: 0;
  547. z-index: 100001;
  548. }
  549. .overlay-body {
  550. flex: 1;
  551. min-height: 0;
  552. background: #ffffff;
  553. display: flex;
  554. flex-direction: column;
  555. padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
  556. }
  557. .overlay-body--with-inner-bar {
  558. padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
  559. }
  560. .overlay-main {
  561. flex: 1;
  562. min-height: 0;
  563. display: flex;
  564. flex-direction: column;
  565. }
  566. .overlay-sticky-top {
  567. flex-shrink: 0;
  568. }
  569. .overlay-scroll {
  570. flex: 1;
  571. min-height: 0;
  572. height: 0;
  573. background: var(--c-bg);
  574. }
  575. .overlay-skeleton {
  576. padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
  577. background: var(--c-bg);
  578. }
  579. .skeleton-banner {
  580. aspect-ratio: 4 / 3;
  581. height: auto;
  582. background: #e8f1ed;
  583. }
  584. .skeleton-info {
  585. background: #fff;
  586. padding: 20rpx;
  587. border-bottom: 12rpx solid var(--c-bg);
  588. }
  589. .skeleton-line {
  590. height: 28rpx;
  591. border-radius: 14rpx;
  592. background: #e8f1ed;
  593. margin-bottom: 14rpx;
  594. }
  595. .skeleton-line--lg {
  596. height: 40rpx;
  597. width: 72%;
  598. }
  599. .skeleton-line--md {
  600. width: 48%;
  601. margin-bottom: 0;
  602. }
  603. .skeleton-section {
  604. background: #fff;
  605. padding: 20rpx;
  606. }
  607. .skeleton-title {
  608. width: 180rpx;
  609. height: 32rpx;
  610. border-radius: 14rpx;
  611. background: #e8f1ed;
  612. margin-bottom: 18rpx;
  613. }
  614. .skeleton-row {
  615. height: 24rpx;
  616. border-radius: 12rpx;
  617. background: #e8f1ed;
  618. margin-bottom: 14rpx;
  619. }
  620. .skeleton-row--short {
  621. width: 68%;
  622. margin-bottom: 0;
  623. }
  624. .shimmer {
  625. position: relative;
  626. overflow: hidden;
  627. }
  628. .shimmer::after {
  629. content: '';
  630. position: absolute;
  631. inset: 0;
  632. transform: translateX(-100%);
  633. background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.52), rgba(255, 255, 255, 0));
  634. animation: shimmer-slide 1.2s infinite;
  635. }
  636. @keyframes shimmer-slide {
  637. 100% {
  638. transform: translateX(100%);
  639. }
  640. }
  641. .overlay-scroll-inner {
  642. padding-bottom: 0;
  643. }
  644. .loading,
  645. .empty {
  646. padding: 40rpx;
  647. text-align: center;
  648. color: #909399;
  649. font-size: 26rpx;
  650. }
  651. .banner-wrap {
  652. position: relative;
  653. border-top-left-radius: 24rpx;
  654. border-top-right-radius: 24rpx;
  655. overflow: hidden;
  656. border-bottom: 12rpx solid var(--c-bg);
  657. }
  658. .banner-swiper {
  659. width: 100%;
  660. aspect-ratio: 4 / 3;
  661. height: auto;
  662. background: #fff;
  663. .banner-image,
  664. .banner-video {
  665. width: 100%;
  666. height: 100%;
  667. }
  668. }
  669. .banner-media-wrap,
  670. .detail-media-wrap {
  671. position: relative;
  672. height: 100%;
  673. }
  674. .banner-empty {
  675. width: 100%;
  676. aspect-ratio: 4 / 3;
  677. height: auto;
  678. background: #f5f7fa;
  679. display: flex;
  680. align-items: center;
  681. justify-content: center;
  682. .banner-empty-text {
  683. font-size: 28rpx;
  684. color: #c0c4cc;
  685. }
  686. }
  687. /* 小程序端对 aspect-ratio 支持不稳定:给轮播图明确高度,避免 loading 遮罩飘到顶部 */
  688. /* #ifndef H5 */
  689. .banner-swiper,
  690. .banner-empty {
  691. height: 540rpx;
  692. }
  693. /* #endif */
  694. .banner-close {
  695. position: absolute;
  696. left: 20rpx;
  697. z-index: 999;
  698. width: 60rpx;
  699. height: 60rpx;
  700. border-radius: 50%;
  701. background: rgba(239, 243, 246, 0.92);
  702. display: flex;
  703. align-items: center;
  704. justify-content: center;
  705. border: 1rpx solid rgba(221, 229, 235, 0.9);
  706. }
  707. /* 小程序:只用系统状态栏高度(safeTop),避免与 env(...) 叠加导致 top 过大 */
  708. /* #ifdef MP-WEIXIN */
  709. .banner-close {
  710. top: calc(var(--sq-safe-top, 0px) + 8px);
  711. }
  712. /* #endif */
  713. /* H5:用 env(safe-area-inset-top) 适配刘海屏 */
  714. /* #ifndef MP-WEIXIN */
  715. .banner-close {
  716. top: calc(env(safe-area-inset-top, 0px) + 8px);
  717. }
  718. /* #endif */
  719. .banner-close-icon {
  720. width: 28rpx;
  721. height: 28rpx;
  722. }
  723. .product-info {
  724. margin-top: 0;
  725. background: #fff;
  726. padding: 20rpx;
  727. border-bottom: 12rpx solid var(--c-bg);
  728. .info-top-row {
  729. display: flex;
  730. align-items: flex-start;
  731. justify-content: space-between;
  732. gap: 18rpx;
  733. }
  734. .info-left {
  735. flex: 1;
  736. min-width: 0;
  737. }
  738. .info-right {
  739. flex-shrink: 0;
  740. display: flex;
  741. align-items: center;
  742. justify-content: flex-end;
  743. min-width: 180rpx;
  744. padding-top: 6rpx;
  745. }
  746. .price-row {
  747. display: flex;
  748. align-items: baseline;
  749. gap: 8rpx;
  750. margin-bottom: 12rpx;
  751. }
  752. .price {
  753. font-size: 44rpx;
  754. color: var(--c-danger);
  755. font-weight: 700;
  756. }
  757. .unit {
  758. font-size: 26rpx;
  759. color: #909399;
  760. }
  761. .name {
  762. display: block;
  763. font-size: 32rpx;
  764. color: var(--c-text);
  765. font-weight: 600;
  766. line-height: 1.45;
  767. white-space: normal;
  768. word-break: break-word;
  769. overflow-wrap: break-word;
  770. }
  771. .measure-text {
  772. display: block;
  773. margin-top: 10rpx;
  774. font-size: 24rpx;
  775. color: var(--c-muted);
  776. line-height: 1.4;
  777. }
  778. .sold-out-hint {
  779. display: block;
  780. margin-top: 8rpx;
  781. font-size: 24rpx;
  782. color: #d97706;
  783. line-height: 1.4;
  784. }
  785. }
  786. .add-btn {
  787. width: 54rpx;
  788. height: 54rpx;
  789. background: var(--c-brand-green);
  790. border-radius: 50%;
  791. display: flex;
  792. align-items: center;
  793. justify-content: center;
  794. color: #ffffff;
  795. font-size: 34rpx;
  796. font-weight: 700;
  797. box-sizing: border-box;
  798. padding: 0;
  799. line-height: 1;
  800. }
  801. /* 「+」在圆内几何居中:抵消 text 默认行高/基线导致的视觉偏移 */
  802. .add-btn-plus {
  803. line-height: 1;
  804. font-size: inherit;
  805. font-weight: inherit;
  806. color: inherit;
  807. text-align: center;
  808. display: block;
  809. transform: translateY(-2rpx);
  810. }
  811. .quantity-control {
  812. display: flex;
  813. align-items: center;
  814. gap: 10rpx;
  815. }
  816. .qty-btn {
  817. width: 48rpx;
  818. height: 48rpx;
  819. border: 2rpx solid var(--c-brand-green);
  820. border-radius: 50%;
  821. display: flex;
  822. align-items: center;
  823. justify-content: center;
  824. color: var(--c-brand-green);
  825. font-size: 28rpx;
  826. font-weight: 700;
  827. background: #fff;
  828. }
  829. .qty-num {
  830. min-width: 40rpx;
  831. text-align: center;
  832. font-size: 28rpx;
  833. color: var(--c-text);
  834. }
  835. .section-title {
  836. padding: 18rpx 20rpx;
  837. font-size: 28rpx;
  838. color: var(--c-text);
  839. font-weight: 600;
  840. background: #fff;
  841. border-top: 1rpx solid var(--c-border);
  842. }
  843. .detail-section {
  844. margin-top: 0;
  845. background: #fff;
  846. border-bottom: 12rpx solid var(--c-bg);
  847. .rich-content {
  848. padding: 0 20rpx 16rpx;
  849. font-size: 28rpx;
  850. line-height: 1.6;
  851. color: var(--c-text);
  852. }
  853. .detail-media-list {
  854. padding-bottom: 8rpx;
  855. }
  856. .detail-media-item {
  857. border-top: 1rpx solid #f5f7fa;
  858. }
  859. .detail-media-img {
  860. width: 100%;
  861. display: block;
  862. }
  863. .detail-media-video {
  864. width: 100%;
  865. height: 420rpx;
  866. display: block;
  867. background: #000;
  868. }
  869. .detail-media-desc {
  870. display: block;
  871. padding: 16rpx 24rpx 24rpx;
  872. font-size: 24rpx;
  873. color: #606266;
  874. line-height: 1.5;
  875. }
  876. }
  877. .media-loading {
  878. position: absolute;
  879. inset: 0;
  880. background: rgba(245, 248, 247, 0.85);
  881. display: flex;
  882. align-items: center;
  883. justify-content: center;
  884. z-index: 2;
  885. }
  886. .media-loading-spinner {
  887. width: 40rpx;
  888. height: 40rpx;
  889. border-radius: 50%;
  890. border: 4rpx solid rgba(10, 159, 109, 0.2);
  891. border-top-color: var(--c-brand-green);
  892. animation: spin 0.9s linear infinite;
  893. }
  894. @keyframes spin {
  895. to {
  896. transform: rotate(360deg);
  897. }
  898. }
  899. .params-section {
  900. margin-top: 0;
  901. background: #fff;
  902. border-bottom: 12rpx solid var(--c-bg);
  903. .params-list {
  904. padding: 0 20rpx 16rpx;
  905. }
  906. .param-item {
  907. display: flex;
  908. padding: 10rpx 0;
  909. border-bottom: 1rpx solid #f5f7fa;
  910. &:last-child {
  911. border-bottom: none;
  912. }
  913. }
  914. .param-name {
  915. width: 180rpx;
  916. font-size: 26rpx;
  917. color: #909399;
  918. }
  919. .param-value {
  920. flex: 1;
  921. font-size: 26rpx;
  922. color: var(--c-text);
  923. }
  924. }
  925. .purchase-section {
  926. margin-top: 0;
  927. background: #fff;
  928. border-bottom: 12rpx solid var(--c-bg);
  929. }
  930. .purchase-grid {
  931. padding: 6rpx 20rpx 18rpx;
  932. display: grid;
  933. grid-template-columns: repeat(2, minmax(0, 1fr));
  934. gap: 16rpx 18rpx;
  935. box-sizing: border-box;
  936. }
  937. .purchase-item {
  938. min-width: 0;
  939. padding: 14rpx 16rpx;
  940. border: 1rpx solid #eef3f0;
  941. border-radius: 18rpx;
  942. background: #fbfdfc;
  943. box-sizing: border-box;
  944. }
  945. .purchase-label {
  946. display: block;
  947. font-size: 22rpx;
  948. color: var(--c-muted);
  949. line-height: 1.2;
  950. }
  951. .purchase-value {
  952. display: block;
  953. margin-top: 10rpx;
  954. font-size: 28rpx;
  955. color: var(--c-text);
  956. font-weight: 600;
  957. line-height: 1.25;
  958. word-break: break-word;
  959. }
  960. .purchase-value--strong {
  961. color: var(--c-danger);
  962. }
  963. /* 最后一个内容块不再额外留底部分隔,避免尾部出现空白条 */
  964. .overlay-scroll-inner > view:last-child {
  965. border-bottom: 0;
  966. }
  967. .bottom-bar {
  968. position: absolute;
  969. left: 50%;
  970. transform: translateX(-50%);
  971. z-index: 4;
  972. width: 100%;
  973. max-width: 840rpx;
  974. bottom: constant(safe-area-inset-bottom);
  975. bottom: env(safe-area-inset-bottom, 0px);
  976. padding: 0 24rpx 14rpx;
  977. background: transparent;
  978. box-sizing: border-box;
  979. }
  980. .cart-inner {
  981. min-height: 96rpx;
  982. border-radius: 36rpx;
  983. background: linear-gradient(145deg, #ffffff, #f5fbf8);
  984. border: 1rpx solid #d9ebe3;
  985. color: var(--c-text);
  986. display: flex;
  987. align-items: center;
  988. justify-content: space-between;
  989. padding: 12rpx;
  990. box-shadow: 0 16rpx 36rpx rgba(10, 159, 109, 0.12);
  991. box-sizing: border-box;
  992. }
  993. .cart-left {
  994. display: flex;
  995. align-items: center;
  996. gap: 20rpx;
  997. padding-left: 8rpx;
  998. flex: 1;
  999. min-width: 0;
  1000. }
  1001. .cart-count {
  1002. min-width: 52rpx;
  1003. height: 52rpx;
  1004. padding: 0 8rpx;
  1005. border-radius: 26rpx;
  1006. background: linear-gradient(145deg, var(--c-brand-green), var(--c-brand-green-2));
  1007. display: inline-flex;
  1008. align-items: center;
  1009. justify-content: center;
  1010. box-sizing: border-box;
  1011. text {
  1012. font-size: 24rpx;
  1013. font-weight: 700;
  1014. color: #fff;
  1015. }
  1016. }
  1017. .cart-text-col {
  1018. display: flex;
  1019. flex-direction: column;
  1020. gap: 2rpx;
  1021. min-width: 0;
  1022. }
  1023. .cart-items-label {
  1024. font-size: 22rpx;
  1025. color: #6f8179;
  1026. }
  1027. .cart-total-money {
  1028. font-size: 32rpx;
  1029. font-weight: 700;
  1030. letter-spacing: 0.4rpx;
  1031. color: #1f2a26;
  1032. }
  1033. .cart-settle {
  1034. flex-shrink: 0;
  1035. min-height: 72rpx;
  1036. padding: 0 32rpx;
  1037. border-radius: 28rpx;
  1038. background: linear-gradient(145deg, var(--c-barley-yellow), #e4b458);
  1039. color: #fff;
  1040. display: inline-flex;
  1041. align-items: center;
  1042. justify-content: center;
  1043. font-size: 28rpx;
  1044. font-weight: 700;
  1045. box-shadow: inset 0 -2rpx 0 rgba(0, 0, 0, 0.1);
  1046. text {
  1047. color: #fff;
  1048. }
  1049. }
  1050. </style>