detail.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965
  1. <template>
  2. <view class="product-detail-page client-theme">
  3. <view v-if="pageLoading" class="page-loading-wrap">
  4. <ClientSkeleton variant="detail" :rows="3" />
  5. </view>
  6. <template v-else>
  7. <view class="detail-fixed-top">
  8. <!-- 轮播图 -->
  9. <swiper
  10. v-if="bannerList.length > 0"
  11. class="banner-swiper"
  12. :indicator-dots="bannerList.length > 1"
  13. :autoplay="bannerList.length > 0"
  14. :interval="3000"
  15. :circular="bannerList.length > 1"
  16. >
  17. <swiper-item v-for="(banner, index) in bannerList" :key="index">
  18. <ClientImageLoader
  19. v-if="banner.mediaType === 'image'"
  20. :src="banner.fileUrl"
  21. mode="aspectFill"
  22. class="banner-image"
  23. />
  24. <video v-else :src="banner.fileUrl" controls class="banner-video" />
  25. </swiper-item>
  26. </swiper>
  27. <view v-else class="banner-empty">
  28. <text class="banner-empty-text">暂无轮播图</text>
  29. </view>
  30. <!-- 商品基本信息 -->
  31. <view class="product-info">
  32. <view class="price-row">
  33. <text class="price">¥{{ formatPrice(product?.price) }}</text>
  34. <text class="unit">/{{ product?.unitName }}</text>
  35. </view>
  36. <view class="name-row">
  37. <text class="name">{{ product?.name }}</text>
  38. </view>
  39. <view class="sku-row" v-if="product?.skuCode">
  40. <text class="sku-label">SKU:</text>
  41. <text class="sku-code">{{ product.skuCode }}</text>
  42. </view>
  43. </view>
  44. </view>
  45. <scroll-view scroll-y class="detail-scroll-area" :show-scrollbar="true">
  46. <view class="detail-scroll-inner">
  47. <!-- 商品参数 -->
  48. <view class="params-section" v-if="parameterValues.length > 0">
  49. <view class="section-title">商品参数</view>
  50. <view class="params-list">
  51. <view v-for="param in parameterValues" :key="param.parameterId" class="param-item">
  52. <text class="param-name">{{ param.parameterName }}</text>
  53. <text class="param-value">{{ param.paramValue }}</text>
  54. </view>
  55. </view>
  56. </view>
  57. <!-- 商品详情(富文本) -->
  58. <view class="detail-section" v-if="detailContent">
  59. <view class="section-title">商品详情</view>
  60. <view class="rich-content">
  61. <rich-text :nodes="detailContent"></rich-text>
  62. </view>
  63. </view>
  64. <!-- 详情图片/视频(运营端配置的详情媒体,展示在富文本下方) -->
  65. <view class="detail-section" v-if="detailMediaList.length > 0">
  66. <view class="section-title">详情图片/视频</view>
  67. <view class="detail-media-list">
  68. <view v-for="(m, idx) in detailMediaList" :key="m.id || `dm-${idx}`" class="detail-media-item">
  69. <ClientImageLoader
  70. v-if="m.contentType === 'image'"
  71. :src="m.fileUrl"
  72. mode="widthFix"
  73. class="detail-media-img"
  74. />
  75. <video v-else :src="m.fileUrl" :poster="ossImg(m.thumbnailUrl || '')" controls class="detail-media-video" />
  76. <text v-if="m.description" class="detail-media-desc">{{ m.description }}</text>
  77. </view>
  78. </view>
  79. </view>
  80. <!-- 商品评价 -->
  81. <view class="review-section">
  82. <view class="section-title" @click="goToReviewList">
  83. <text>商品评价</text>
  84. <view class="more-btn">
  85. <text class="count">({{ reviewStats?.totalCount || 0 }})</text>
  86. <text class="arrow">›</text>
  87. </view>
  88. </view>
  89. <!-- 评分概览 -->
  90. <view class="review-overview" v-if="reviewStats">
  91. <view class="avg-rating">
  92. <text class="score">{{ reviewStats.avgRating?.toFixed(1) }}</text>
  93. <text class="label">分</text>
  94. </view>
  95. <view class="rating-distribution">
  96. <view v-for="i in 5" :key="i" class="rating-row">
  97. <text class="star">{{ 6 - i }}星</text>
  98. <view class="bar-wrapper">
  99. <view class="bar" :style="{ width: getBarWidth(6 - i) }"></view>
  100. </view>
  101. </view>
  102. </view>
  103. </view>
  104. <!-- 评价列表(最新3条) -->
  105. <view class="review-list" v-if="reviewList.length > 0">
  106. <view v-for="review in reviewList.slice(0, 3)" :key="review.id" class="review-item">
  107. <view class="review-header">
  108. <view class="user-info">
  109. <text class="username">{{ review.isAnonymous ? '匿名用户' : review.userName }}</text>
  110. <van-rate :value="review.rating" readonly size="12" />
  111. </view>
  112. </view>
  113. <view class="review-content">
  114. <text v-if="review.ratingTags" class="tags">{{ formatTags(review.ratingTags) }}</text>
  115. <text class="content">{{ review.content }}</text>
  116. </view>
  117. <view class="review-media" v-if="review.mediaList?.length">
  118. <ClientImageLoader
  119. v-for="media in review.mediaList.slice(0, 3)"
  120. :key="media.id"
  121. :src="media.fileUrl"
  122. mode="aspectFill"
  123. class="media-thumb"
  124. />
  125. </view>
  126. <view class="review-footer">
  127. <text class="time">{{ formatTime(review.createAt) }}</text>
  128. <view class="like-btn">
  129. <text>👍</text>
  130. <text>{{ review.likeCount || 0 }}</text>
  131. </view>
  132. </view>
  133. </view>
  134. </view>
  135. <view v-else class="no-review">
  136. <text>暂无评价</text>
  137. </view>
  138. </view>
  139. </view>
  140. </scroll-view>
  141. <!-- 底部操作栏 -->
  142. <view class="bottom-bar">
  143. <view class="action-icons">
  144. <view class="icon-item" hover-class="icon-item--pressed" :hover-stay-time="100" @click="goToHome">
  145. <text class="icon">🏠</text>
  146. <text class="icon-text">首页</text>
  147. </view>
  148. <view class="icon-item" hover-class="icon-item--pressed" :hover-stay-time="100" @click="goToCart">
  149. <text class="icon">🛒</text>
  150. <text class="icon-text">购物车</text>
  151. <view v-if="cartCount > 0" class="badge">{{ cartCount > 99 ? '99+' : cartCount }}</view>
  152. </view>
  153. </view>
  154. <view class="action-btns">
  155. <view class="add-cart-btn" hover-class="bar-btn--pressed" :hover-stay-time="100" @click="handleAddToCart">加入购物车</view>
  156. <view class="buy-btn" hover-class="bar-btn--pressed" :hover-stay-time="100" @click="handleBuyNow">立即购买</view>
  157. </view>
  158. </view>
  159. <!-- 数量选择弹窗 -->
  160. <ClientTabBar :active="tabBarActive" />
  161. <van-popup v-model="showQuantityPopup" position="bottom" round>
  162. <view class="quantity-popup">
  163. <view class="product-row">
  164. <ClientImageLoader :src="product?.imageUrl || ''" class="popup-image" />
  165. <view class="popup-info">
  166. <text class="popup-name">{{ product?.name }}</text>
  167. <text class="popup-price">¥{{ formatPrice(product?.price) }}</text>
  168. </view>
  169. </view>
  170. <view class="quantity-row">
  171. <text class="quantity-label">购买数量</text>
  172. <view class="quantity-control">
  173. <view class="qty-btn" @click="decreaseQty">-</view>
  174. <input v-model="quantity" type="number" class="qty-input" />
  175. <view class="qty-btn" @click="increaseQty">+</view>
  176. </view>
  177. </view>
  178. <view class="confirm-btn" @click="confirmAction">{{ actionType === 'cart' ? '加入购物车' : '立即购买' }}</view>
  179. </view>
  180. </van-popup>
  181. </template>
  182. </view>
  183. </template>
  184. <script setup>
  185. import { ref, onMounted } from 'vue'
  186. import { onLoad } from '@dcloudio/uni-app'
  187. import { useCartStore } from '@/stores/cart'
  188. import { useUserStore } from '@/stores/user'
  189. import { request, parseResponseJson } from '@/utils/request'
  190. import { formatYuan } from '@/utils/format'
  191. import { fetchProductReviews, fetchReviewStatistics } from '@/api/reviewApi'
  192. import ClientTabBar from '@/components/client/client-tab-bar.vue'
  193. import ClientSkeleton from '@/components/client/client-skeleton.vue'
  194. import ClientImageLoader from '@/components/client/client-image-loader.vue'
  195. import { withOssVersion } from '@/utils/ossCache'
  196. const userStore = useUserStore()
  197. const cartStore = useCartStore()
  198. let productId = ''
  199. const product = ref(null)
  200. const bannerList = ref([])
  201. const parameterValues = ref([])
  202. const detailContent = ref('')
  203. const detailMediaList = ref([])
  204. const reviewList = ref([])
  205. const reviewStats = ref(null)
  206. const pageLoading = ref(true)
  207. const showQuantityPopup = ref(false)
  208. const quantity = ref(1)
  209. const actionType = ref('cart')
  210. const cartCount = ref(0)
  211. const tabBarActive = ref('home')
  212. const formatPrice = (yuan) => formatYuan(yuan ?? 0)
  213. const ossImg = withOssVersion
  214. function formatTime(time) {
  215. if (!time) return ''
  216. return String(time).slice(0, 10)
  217. }
  218. function formatTags(tags) {
  219. if (!tags) return ''
  220. return String(tags)
  221. .split(',')
  222. .map((t) => `#${String(t).trim()}`)
  223. .join(' ')
  224. }
  225. function getBarWidth(star) {
  226. const total = reviewStats.value?.totalCount || 1
  227. const count = reviewStats.value?.ratingDistribution?.[String(star)] || 0
  228. return `${(count / total) * 100}%`
  229. }
  230. function goToHome() {
  231. uni.switchTab({ url: '/pages/client/home/home' })
  232. }
  233. function goToCart() {
  234. uni.switchTab({ url: '/pages/client/cart/cart' })
  235. }
  236. function goToReviewList() {
  237. if (!productId) return
  238. uni.navigateTo({
  239. url: `/pages/client/review/list?productId=${productId}&product=${encodeURIComponent(JSON.stringify(product.value || {}))}`
  240. })
  241. }
  242. function handleAddToCart() {
  243. actionType.value = 'cart'
  244. quantity.value = 1
  245. showQuantityPopup.value = true
  246. }
  247. function handleBuyNow() {
  248. actionType.value = 'buy'
  249. quantity.value = 1
  250. showQuantityPopup.value = true
  251. }
  252. function decreaseQty() {
  253. if (quantity.value > 1) quantity.value -= 1
  254. }
  255. function increaseQty() {
  256. quantity.value += 1
  257. }
  258. async function confirmAction() {
  259. const q = Math.max(1, Number(quantity.value) || 1)
  260. quantity.value = q
  261. if (!product.value?.id) return
  262. if (actionType.value === 'cart') {
  263. await cartStore.addToCart(product.value, q)
  264. cartCount.value = cartStore.totalCount
  265. uni.showToast({ title: '已加入购物车', icon: 'success' })
  266. } else {
  267. uni.navigateTo({
  268. url: `/pages/client/order/create/create?productId=${encodeURIComponent(productId)}&quantity=${q}`
  269. })
  270. }
  271. showQuantityPopup.value = false
  272. }
  273. async function loadProductDetail() {
  274. if (!productId) return
  275. const res = await request({ url: `/mini/client/products/${productId}`, method: 'GET' })
  276. const body = parseResponseJson(res)
  277. if (body.code !== 200 || !body.data) {
  278. uni.showToast({ title: body.message || '加载失败', icon: 'none' })
  279. return
  280. }
  281. const d = body.data || {}
  282. const p = d.product || {}
  283. product.value = { ...(product.value || {}), ...p }
  284. bannerList.value = Array.isArray(d.banners) ? d.banners : []
  285. parameterValues.value = Array.isArray(d.parameterValues) ? d.parameterValues : []
  286. detailContent.value = p.detailContent || ''
  287. detailMediaList.value = Array.isArray(d.detailMedia)
  288. ? d.detailMedia.map((item) => ({
  289. id: item.id,
  290. contentType: item.contentType || item.content_type || 'image',
  291. fileUrl: item.fileUrl || item.file_url || '',
  292. thumbnailUrl: item.thumbnailUrl || item.thumbnail_url,
  293. description: item.description,
  294. sortOrder: item.sortOrder ?? item.sort_order ?? 0
  295. }))
  296. : []
  297. }
  298. async function loadReviews() {
  299. if (!productId) return
  300. const data = await fetchProductReviews(productId, { page: 1, pageSize: 3 })
  301. reviewList.value = Array.isArray(data?.list) ? data.list : []
  302. }
  303. async function loadReviewStats() {
  304. if (!productId) return
  305. reviewStats.value = await fetchReviewStatistics(productId)
  306. }
  307. async function loadPageData() {
  308. try {
  309. await Promise.all([loadProductDetail(), loadReviews(), loadReviewStats()])
  310. cartCount.value = cartStore.totalCount
  311. } finally {
  312. pageLoading.value = false
  313. }
  314. }
  315. onLoad((options) => {
  316. pageLoading.value = true
  317. userStore.checkLoginStatus()
  318. if (!userStore.isLoggedIn) return uni.reLaunch({ url: '/pages/login/login' })
  319. if (!userStore.isClient) {
  320. uni.showToast({ title: '请使用客户账号登录', icon: 'none' })
  321. return setTimeout(() => userStore.navigateToHome(), 1200)
  322. }
  323. productId = options?.productId || ''
  324. if (!productId) {
  325. pageLoading.value = false
  326. uni.showToast({ title: '缺少商品ID', icon: 'none' })
  327. return
  328. }
  329. const ft = options?.fromTab
  330. if (ft === 'home' || ft === 'order' || ft === 'cart' || ft === 'profile') {
  331. tabBarActive.value = ft
  332. }
  333. void loadPageData()
  334. })
  335. onMounted(() => {
  336. cartCount.value = cartStore.totalCount
  337. })
  338. </script>
  339. <style lang="scss" scoped>
  340. .product-detail-page {
  341. height: 100vh;
  342. box-sizing: border-box;
  343. display: flex;
  344. flex-direction: column;
  345. background: var(--c-bg);
  346. padding-bottom: calc(280rpx + constant(safe-area-inset-bottom));
  347. padding-bottom: calc(280rpx + env(safe-area-inset-bottom, 0px));
  348. overflow: hidden;
  349. }
  350. .page-loading-wrap {
  351. flex: 1;
  352. min-height: 0;
  353. }
  354. .detail-fixed-top {
  355. flex-shrink: 0;
  356. }
  357. .detail-scroll-area {
  358. flex: 1;
  359. min-height: 0;
  360. height: 0;
  361. background: var(--c-bg);
  362. }
  363. .detail-scroll-inner {
  364. padding-bottom: 40rpx;
  365. }
  366. /* 对齐 sanqiang-client-product-detail-ui-demo.html:banner 区边距与圆角 */
  367. .banner-swiper {
  368. width: auto;
  369. aspect-ratio: 4 / 3;
  370. height: auto;
  371. margin: 24rpx 32rpx 0;
  372. border-radius: 28rpx;
  373. overflow: hidden;
  374. border: 1rpx solid var(--c-border);
  375. background: linear-gradient(145deg, #f4fbf8, #dff2e9);
  376. box-sizing: border-box;
  377. .banner-image, .banner-video {
  378. width: 100%;
  379. height: 100%;
  380. }
  381. }
  382. .banner-empty {
  383. width: auto;
  384. aspect-ratio: 4 / 3;
  385. height: auto;
  386. margin: 24rpx 32rpx 0;
  387. border-radius: 28rpx;
  388. border: 1rpx solid var(--c-border);
  389. background: linear-gradient(145deg, #f4fbf8, #dff2e9);
  390. display: flex;
  391. align-items: center;
  392. justify-content: center;
  393. box-sizing: border-box;
  394. .banner-empty-text {
  395. font-size: 26rpx;
  396. color: var(--c-muted);
  397. }
  398. }
  399. .product-info {
  400. padding: 24rpx;
  401. background: var(--c-card);
  402. border: 1rpx solid var(--c-border);
  403. border-radius: 28rpx;
  404. margin: 24rpx 32rpx;
  405. box-sizing: border-box;
  406. .price-row {
  407. display: flex;
  408. align-items: baseline;
  409. gap: 8rpx;
  410. margin-bottom: 16rpx;
  411. .price {
  412. font-size: 56rpx;
  413. color: var(--c-danger);
  414. font-weight: 700;
  415. line-height: 1.1;
  416. }
  417. .unit {
  418. font-size: 24rpx;
  419. color: var(--c-muted);
  420. }
  421. }
  422. .name-row {
  423. margin-bottom: 12rpx;
  424. .name {
  425. font-size: 32rpx;
  426. color: var(--c-text);
  427. font-weight: 600;
  428. line-height: 1.4;
  429. }
  430. }
  431. .sku-row {
  432. display: flex;
  433. align-items: center;
  434. .sku-label {
  435. font-size: 24rpx;
  436. color: var(--c-muted);
  437. margin-right: 8rpx;
  438. }
  439. .sku-code {
  440. font-size: 24rpx;
  441. color: var(--c-text);
  442. }
  443. }
  444. }
  445. .section-title {
  446. display: flex;
  447. justify-content: space-between;
  448. align-items: center;
  449. padding: 24rpx 24rpx 16rpx;
  450. font-size: 30rpx;
  451. color: var(--c-text);
  452. font-weight: 600;
  453. background: transparent;
  454. margin-bottom: 0;
  455. border-bottom: 1rpx solid #edf5f1;
  456. box-sizing: border-box;
  457. }
  458. .params-section {
  459. background: var(--c-card);
  460. margin: 0 32rpx 24rpx;
  461. border: 1rpx solid var(--c-border);
  462. border-radius: 28rpx;
  463. overflow: hidden;
  464. box-sizing: border-box;
  465. .params-list {
  466. padding: 8rpx 24rpx 24rpx;
  467. .param-item {
  468. display: grid;
  469. grid-template-columns: 184rpx 1fr;
  470. gap: 16rpx;
  471. align-items: start;
  472. padding: 16rpx 0;
  473. border-top: 1rpx solid #edf5f1;
  474. box-sizing: border-box;
  475. &:first-child {
  476. border-top: none;
  477. padding-top: 8rpx;
  478. }
  479. .param-name {
  480. width: auto;
  481. font-size: 26rpx;
  482. color: var(--c-muted);
  483. }
  484. .param-value {
  485. min-width: 0;
  486. font-size: 26rpx;
  487. color: var(--c-text);
  488. word-break: break-word;
  489. }
  490. }
  491. }
  492. }
  493. .detail-section {
  494. background: var(--c-card);
  495. margin: 0 32rpx 24rpx;
  496. border: 1rpx solid var(--c-border);
  497. border-radius: 28rpx;
  498. overflow: hidden;
  499. box-sizing: border-box;
  500. .rich-content {
  501. margin: 0 24rpx 24rpx;
  502. padding: 20rpx;
  503. font-size: 26rpx;
  504. line-height: 1.6;
  505. color: #33423c;
  506. background: #f8fcfa;
  507. border: 1rpx solid #e8f3ee;
  508. border-radius: 20rpx;
  509. box-sizing: border-box;
  510. }
  511. .detail-media-list {
  512. padding: 0 24rpx 16rpx;
  513. }
  514. .detail-media-item {
  515. margin-top: 16rpx;
  516. border-radius: 20rpx;
  517. overflow: hidden;
  518. border: 1rpx solid var(--c-border);
  519. background: #f8fcfa;
  520. }
  521. .detail-media-img {
  522. width: 100%;
  523. display: block;
  524. }
  525. .detail-media-video {
  526. width: 100%;
  527. height: 420rpx;
  528. display: block;
  529. background: #000;
  530. }
  531. .detail-media-desc {
  532. display: block;
  533. padding: 16rpx 20rpx 20rpx;
  534. font-size: 24rpx;
  535. color: var(--c-muted);
  536. line-height: 1.5;
  537. }
  538. }
  539. .review-section {
  540. background: var(--c-card);
  541. margin: 0 32rpx 24rpx;
  542. border: 1rpx solid var(--c-border);
  543. border-radius: 28rpx;
  544. overflow: hidden;
  545. box-sizing: border-box;
  546. .section-title {
  547. .more-btn {
  548. display: flex;
  549. align-items: center;
  550. font-size: 24rpx;
  551. color: var(--c-muted);
  552. font-weight: 400;
  553. .arrow {
  554. margin-left: 8rpx;
  555. font-size: 28rpx;
  556. }
  557. }
  558. }
  559. .review-overview {
  560. display: flex;
  561. padding: 0 24rpx 20rpx;
  562. .avg-rating {
  563. display: flex;
  564. align-items: baseline;
  565. padding-right: 24rpx;
  566. border-right: 1rpx solid #edf5f1;
  567. .score {
  568. font-size: 48rpx;
  569. color: var(--c-barley-yellow);
  570. font-weight: 700;
  571. }
  572. .label {
  573. font-size: 24rpx;
  574. color: var(--c-muted);
  575. margin-left: 8rpx;
  576. }
  577. }
  578. .rating-distribution {
  579. flex: 1;
  580. margin-left: 24rpx;
  581. .rating-row {
  582. display: flex;
  583. align-items: center;
  584. margin-bottom: 8rpx;
  585. .star {
  586. font-size: 22rpx;
  587. color: #606266;
  588. width: 60rpx;
  589. }
  590. .bar-wrapper {
  591. flex: 1;
  592. height: 12rpx;
  593. background: #ebeef5;
  594. border-radius: 6rpx;
  595. margin: 0 12rpx;
  596. overflow: hidden;
  597. .bar {
  598. height: 100%;
  599. background: var(--c-barley-yellow);
  600. border-radius: 6rpx;
  601. }
  602. }
  603. }
  604. }
  605. }
  606. .review-list {
  607. padding: 0 24rpx 20rpx;
  608. .review-item {
  609. padding: 16rpx 0;
  610. border-top: 1rpx solid #f5f7fa;
  611. .review-header {
  612. margin-bottom: 12rpx;
  613. .user-info {
  614. display: flex;
  615. align-items: center;
  616. gap: 16rpx;
  617. .username {
  618. font-size: 26rpx;
  619. color: var(--c-text);
  620. }
  621. }
  622. }
  623. .review-content {
  624. .tags {
  625. display: inline-block;
  626. font-size: 24rpx;
  627. color: var(--c-brand-green);
  628. background: var(--c-brand-green-soft);
  629. padding: 4rpx 12rpx;
  630. border-radius: 999rpx;
  631. margin-right: 12rpx;
  632. }
  633. .content {
  634. font-size: 26rpx;
  635. color: #606266;
  636. line-height: 1.5;
  637. }
  638. }
  639. .review-media {
  640. display: flex;
  641. gap: 12rpx;
  642. margin-top: 12rpx;
  643. .media-thumb {
  644. width: 128rpx;
  645. height: 128rpx;
  646. border-radius: 16rpx;
  647. border: 1rpx solid var(--c-border);
  648. }
  649. }
  650. .review-footer {
  651. display: flex;
  652. justify-content: space-between;
  653. align-items: center;
  654. margin-top: 12rpx;
  655. .time {
  656. font-size: 22rpx;
  657. color: #c0c4cc;
  658. }
  659. .like-btn {
  660. display: flex;
  661. align-items: center;
  662. gap: 6rpx;
  663. font-size: 22rpx;
  664. color: #909399;
  665. }
  666. }
  667. }
  668. }
  669. .no-review {
  670. padding: 40rpx;
  671. text-align: center;
  672. color: #909399;
  673. font-size: 26rpx;
  674. }
  675. }
  676. /* 对齐 demo .bar:顶部分割 + 图标区 + 双按钮 */
  677. .bottom-bar {
  678. position: fixed;
  679. bottom: calc(100rpx + env(safe-area-inset-bottom, 0px));
  680. left: 0;
  681. right: 0;
  682. z-index: 520;
  683. display: flex;
  684. align-items: center;
  685. min-height: 112rpx;
  686. padding: 20rpx 24rpx;
  687. padding-bottom: calc(20rpx + env(safe-area-inset-bottom, 0px));
  688. box-sizing: border-box;
  689. background: #fff;
  690. border-top: 1rpx solid var(--c-border);
  691. gap: 16rpx;
  692. .action-icons {
  693. display: flex;
  694. gap: 8rpx;
  695. flex-shrink: 0;
  696. .icon-item {
  697. position: relative;
  698. display: flex;
  699. flex-direction: column;
  700. align-items: center;
  701. justify-content: center;
  702. min-width: 80rpx;
  703. padding: 4rpx 8rpx;
  704. .icon {
  705. font-size: 36rpx;
  706. line-height: 1;
  707. }
  708. .icon-text {
  709. font-size: 22rpx;
  710. color: var(--c-muted);
  711. margin-top: 4rpx;
  712. }
  713. .badge {
  714. position: absolute;
  715. top: -4rpx;
  716. right: 4rpx;
  717. min-width: 28rpx;
  718. height: 28rpx;
  719. padding: 0 6rpx;
  720. background: var(--c-danger);
  721. color: #fff;
  722. font-size: 18rpx;
  723. border-radius: 14rpx;
  724. display: flex;
  725. align-items: center;
  726. justify-content: center;
  727. }
  728. }
  729. }
  730. .action-btns {
  731. flex: 1;
  732. display: flex;
  733. gap: 16rpx;
  734. min-width: 0;
  735. .add-cart-btn, .buy-btn {
  736. flex: 1;
  737. min-height: 84rpx;
  738. border-radius: 42rpx;
  739. display: flex;
  740. align-items: center;
  741. justify-content: center;
  742. font-size: 28rpx;
  743. font-weight: 600;
  744. }
  745. .add-cart-btn {
  746. background: var(--c-accent-orange);
  747. color: #fff;
  748. }
  749. .buy-btn {
  750. background: var(--c-brand-green);
  751. color: #fff;
  752. }
  753. }
  754. }
  755. .icon-item--pressed {
  756. opacity: 0.85;
  757. }
  758. .bar-btn--pressed {
  759. opacity: 0.92;
  760. }
  761. .quantity-popup {
  762. padding: 30rpx;
  763. .product-row {
  764. display: flex;
  765. margin-bottom: 30rpx;
  766. .popup-image {
  767. width: 160rpx;
  768. height: 160rpx;
  769. border-radius: 12rpx;
  770. background: #f5f7fa;
  771. }
  772. .popup-info {
  773. flex: 1;
  774. margin-left: 20rpx;
  775. display: flex;
  776. flex-direction: column;
  777. justify-content: center;
  778. .popup-name {
  779. font-size: 28rpx;
  780. color: var(--c-text);
  781. margin-bottom: 12rpx;
  782. }
  783. .popup-price {
  784. font-size: 36rpx;
  785. color: var(--c-danger);
  786. font-weight: bold;
  787. }
  788. }
  789. }
  790. .quantity-row {
  791. display: flex;
  792. justify-content: space-between;
  793. align-items: center;
  794. padding: 20rpx 0;
  795. border-top: 1rpx solid #ebeef5;
  796. .quantity-label {
  797. font-size: 28rpx;
  798. color: var(--c-text);
  799. }
  800. .quantity-control {
  801. display: flex;
  802. align-items: center;
  803. .qty-btn {
  804. width: 60rpx;
  805. height: 60rpx;
  806. background: #f5f7fa;
  807. border-radius: 8rpx;
  808. display: flex;
  809. align-items: center;
  810. justify-content: center;
  811. font-size: 32rpx;
  812. color: #606266;
  813. }
  814. .qty-input {
  815. width: 80rpx;
  816. height: 60rpx;
  817. text-align: center;
  818. font-size: 28rpx;
  819. background: #f5f7fa;
  820. margin: 0 12rpx;
  821. border-radius: 8rpx;
  822. }
  823. }
  824. }
  825. .confirm-btn {
  826. height: 88rpx;
  827. line-height: 88rpx;
  828. background: var(--c-brand-green);
  829. color: #fff;
  830. font-size: 32rpx;
  831. text-align: center;
  832. border-radius: 44rpx;
  833. margin-top: 30rpx;
  834. }
  835. }
  836. </style>
  837. <style lang="scss">
  838. page {
  839. height: 100%;
  840. overflow: hidden;
  841. background-color: #f5f8f7;
  842. }
  843. </style>