Jelajahi Sumber

style(client-cart): 对齐购物车及商品详情弹层视觉

将 sqxp-wx 客户端购物车页与商品详情弹层样式对齐 miniprogram,包括结算栏、空态按钮与弹层轮播左上角 SVG 返回图标区域,保持交互与视觉一致。

Made-with: Cursor
zlwu 6 bulan lalu
induk
melakukan
4c9b331564

+ 322 - 102
sqxp-wx/components/client/product-detail-overlay.vue

@@ -1,13 +1,25 @@
 <template>
   <!-- 遮罩层阻止穿透滚动;面板内由子级 scroll-view 负责滚动 -->
-  <view v-if="show" class="overlay-root">
+  <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="loading">加载中...</view>
+        <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" class="empty">商品不存在</view>
         <view v-else class="overlay-main">
           <view class="overlay-sticky-top">
@@ -21,8 +33,27 @@
                 :circular="bannerList.length > 1"
               >
                 <swiper-item v-for="(banner, index) in bannerList" :key="index">
-                  <image v-if="banner.mediaType === 'image'" :src="banner.fileUrl" mode="aspectFill" class="banner-image" />
-                  <video v-else :src="banner.fileUrl" controls class="banner-video" />
+                  <view class="banner-media-wrap">
+                    <image
+                      v-if="banner.mediaType === 'image'"
+                      :src="banner.fileUrl"
+                      mode="aspectFill"
+                      class="banner-image"
+                      @load="onBannerMediaLoaded(index)"
+                      @error="onBannerMediaLoaded(index)"
+                    />
+                    <video
+                      v-else
+                      :src="banner.fileUrl"
+                      controls
+                      class="banner-video"
+                      @loadedmetadata="onBannerMediaLoaded(index)"
+                      @error="onBannerMediaLoaded(index)"
+                    />
+                    <view v-if="isBannerMediaLoading(index)" class="media-loading">
+                      <view class="media-loading-spinner" />
+                    </view>
+                  </view>
                 </swiper-item>
               </swiper>
               <view v-else class="banner-empty">
@@ -30,7 +61,7 @@
               </view>
 
               <view class="banner-close" :style="{ top: `${safeTop + 8}px` }" @click="emitClose">
-                <text class="banner-close-icon">⌄</text>
+                <image class="banner-close-icon" :src="ICON_ARROW_BOTTOM" mode="aspectFit" />
               </view>
             </view>
 
@@ -42,7 +73,6 @@
                     <text class="unit">/{{ formatMeasureText(product.measureValue, product.unitName) }}</text>
                   </view>
                   <text class="name">{{ product.name }}</text>
-                  <text class="spec">{{ formatMeasureText(product.measureValue, product.unitName) }}</text>
                 </view>
 
                 <view v-if="!props.readonly" class="info-right" @click.stop>
@@ -86,8 +116,28 @@
                 <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">
-                    <image v-if="m.contentType === 'image'" :src="m.fileUrl" mode="widthFix" class="detail-media-img" />
-                    <video v-else :src="m.fileUrl" :poster="m.thumbnailUrl || ''" controls class="detail-media-video" />
+                    <view class="detail-media-wrap">
+                      <image
+                        v-if="m.contentType === 'image'"
+                        :src="m.fileUrl"
+                        mode="widthFix"
+                        class="detail-media-img"
+                        @load="onDetailMediaLoaded(idx)"
+                        @error="onDetailMediaLoaded(idx)"
+                      />
+                      <video
+                        v-else
+                        :src="m.fileUrl"
+                        :poster="m.thumbnailUrl || ''"
+                        controls
+                        class="detail-media-video"
+                        @loadedmetadata="onDetailMediaLoaded(idx)"
+                        @error="onDetailMediaLoaded(idx)"
+                      />
+                      <view v-if="isDetailMediaLoading(idx)" class="media-loading">
+                        <view class="media-loading-spinner" />
+                      </view>
+                    </view>
                     <text v-if="m.description" class="detail-media-desc">{{ m.description }}</text>
                   </view>
                 </view>
@@ -98,13 +148,18 @@
       </view>
 
       <view v-if="product && !props.readonly && props.bottomMode !== 'none'" class="bottom-bar">
-        <view class="action-left" @click="goToCart">
-          <text class="cart-icon">🛒</text>
-          <view v-if="cartCount > 0" class="badge">{{ cartCount > 99 ? '99+' : cartCount }}</view>
-        </view>
-        <view class="action-right">
-          <view class="checkout-btn" @click="goToCart">
-            {{ props.bottomMode === 'cart' ? '去购物车' : '去结算' }}
+        <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>
@@ -123,6 +178,7 @@ import ClientTabBar from '@/components/client/client-tab-bar.vue'
 import { request, parseResponseJson } from '@/utils/request'
 import { useCartStore } from '@/stores/cart'
 import { formatYuan } from '@/utils/format'
+import { ICON_ARROW_BOTTOM } from '@/utils/staticAsset'
 
 const props = defineProps({
   show: { type: Boolean, default: false },
@@ -143,6 +199,8 @@ const bannerList = ref([])
 const parameterValues = ref([])
 const detailContent = ref('')
 const detailMediaList = ref([])
+const bannerMediaLoadedMap = ref({})
+const detailMediaLoadedMap = ref({})
 
 const cartCount = ref(0)
 const localCartQty = ref(null)
@@ -152,6 +210,20 @@ 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
@@ -169,18 +241,31 @@ const currentCartQty = computed(() => {
   return 0
 })
 
-function formatMeasureText(measureValue, unitName) {
-  const unitText = (unitName || '').trim()
-  if (measureValue == null || measureValue === '') {
-    return unitText ? `- ${unitText}` : '-'
+function resetMediaLoadingState() {
+  bannerMediaLoadedMap.value = {}
+  detailMediaLoadedMap.value = {}
+}
+
+function onBannerMediaLoaded(index) {
+  bannerMediaLoadedMap.value = {
+    ...bannerMediaLoadedMap.value,
+    [index]: true
   }
-  const num = Number(measureValue)
-  if (!Number.isNaN(num)) {
-    const measureText = String(num).replace(/\.?0+$/, '')
-    return unitText ? `${measureText} ${unitText}` : measureText
+}
+
+function onDetailMediaLoaded(index) {
+  detailMediaLoadedMap.value = {
+    ...detailMediaLoadedMap.value,
+    [index]: true
   }
-  const measureText = String(measureValue).trim()
-  return unitText ? `${measureText} ${unitText}` : measureText || '-'
+}
+
+function isBannerMediaLoading(index) {
+  return !bannerMediaLoadedMap.value[index]
+}
+
+function isDetailMediaLoading(index) {
+  return !detailMediaLoadedMap.value[index]
 }
 
 function emitClose() {
@@ -244,6 +329,7 @@ async function updateQuantity(delta) {
 
 async function loadProductDetail(productId) {
   loading.value = true
+  resetMediaLoadingState()
   try {
     const res = await request({
       url: `/mini/client/products/${productId}`,
@@ -269,6 +355,9 @@ async function loadProductDetail(productId) {
           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]))
   } finally {
     loading.value = false
   }
@@ -279,7 +368,6 @@ function goToCart() {
   uni.switchTab({
     url: '/pages/client/cart/cart',
     fail: () => {
-      // H5/部分平台在非 tab 页调用 switchTab 可能失败,兜底用 reLaunch
       uni.reLaunch({ url: '/pages/client/cart/cart' })
     }
   })
@@ -337,9 +425,12 @@ watch(
   flex-direction: column;
 }
 
-/* 为底部自定义 Tab 留出空间,避免白面板盖住导航栏区域 */
 .overlay-panel--with-tab {
-  bottom: calc(100rpx + env(safe-area-inset-bottom));
+  bottom: calc(88rpx + env(safe-area-inset-bottom, 0px));
+}
+
+.overlay-panel--with-tab .overlay-body {
+  padding-bottom: 0;
 }
 
 .overlay-tab-bar-wrap {
@@ -378,11 +469,89 @@ watch(
   flex: 1;
   min-height: 0;
   height: 0;
-  background: #f5f5f5;
+  background: var(--c-bg);
+}
+
+.overlay-skeleton {
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+  background: var(--c-bg);
+}
+
+.skeleton-banner {
+  height: 560rpx;
+  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: 24rpx;
+  padding-bottom: 0;
 }
 
 .loading,
@@ -398,7 +567,7 @@ watch(
   border-top-left-radius: 24rpx;
   border-top-right-radius: 24rpx;
   overflow: hidden;
-  border-bottom: 12rpx solid #f5f5f5;
+  border-bottom: 12rpx solid var(--c-bg);
 }
 
 .banner-swiper {
@@ -413,6 +582,11 @@ watch(
   }
 }
 
+.banner-media-wrap,
+.detail-media-wrap {
+  position: relative;
+}
+
 .banner-empty {
   width: 100%;
   height: 560rpx;
@@ -431,29 +605,26 @@ watch(
   position: absolute;
   left: 20rpx;
   z-index: 999;
-  width: 72rpx;
-  height: 72rpx;
+  width: 60rpx;
+  height: 60rpx;
   border-radius: 50%;
-  background: rgba(255, 255, 255, 0.5);
+  background: rgba(239, 243, 246, 0.92);
   display: flex;
   align-items: center;
   justify-content: center;
-  backdrop-filter: blur(6px);
+  border: 1rpx solid rgba(221, 229, 235, 0.9);
 }
 
 .banner-close-icon {
-  font-size: 44rpx;
-  line-height: 1;
-  color: #000;
-  font-weight: 800;
-  transform: translateY(-1rpx);
+  width: 28rpx;
+  height: 28rpx;
 }
 
 .product-info {
   margin-top: 0;
   background: #fff;
   padding: 20rpx;
-  border-bottom: 12rpx solid #f5f5f5;
+  border-bottom: 12rpx solid var(--c-bg);
 
   .info-top-row {
     display: flex;
@@ -485,7 +656,7 @@ watch(
 
   .price {
     font-size: 44rpx;
-    color: #07c160;
+    color: var(--c-danger);
     font-weight: 700;
   }
 
@@ -497,26 +668,19 @@ watch(
   .name {
     display: block;
     font-size: 32rpx;
-    color: #303133;
+    color: var(--c-text);
     font-weight: 600;
     line-height: 1.45;
     white-space: normal;
     word-break: break-word;
     overflow-wrap: break-word;
   }
-
-  .spec {
-    display: block;
-    margin-top: 10rpx;
-    font-size: 26rpx;
-    color: #606266;
-  }
 }
 
 .add-btn {
   width: 54rpx;
   height: 54rpx;
-  background: #07c160;
+  background: var(--c-brand-green);
   border-radius: 50%;
   display: flex;
   align-items: center;
@@ -535,12 +699,12 @@ watch(
 .qty-btn {
   width: 48rpx;
   height: 48rpx;
-  border: 2rpx solid #07c160;
+  border: 2rpx solid var(--c-brand-green);
   border-radius: 50%;
   display: flex;
   align-items: center;
   justify-content: center;
-  color: #07c160;
+  color: var(--c-brand-green);
   font-size: 28rpx;
   font-weight: 700;
   background: #fff;
@@ -550,28 +714,28 @@ watch(
   min-width: 40rpx;
   text-align: center;
   font-size: 28rpx;
-  color: #303133;
+  color: var(--c-text);
 }
 
 .section-title {
   padding: 18rpx 20rpx;
   font-size: 28rpx;
-  color: #303133;
+  color: var(--c-text);
   font-weight: 600;
   background: #fff;
-  border-top: 1rpx solid #f5f5f5;
+  border-top: 1rpx solid var(--c-border);
 }
 
 .detail-section {
   margin-top: 0;
   background: #fff;
-  border-bottom: 12rpx solid #f5f5f5;
+  border-bottom: 12rpx solid var(--c-bg);
 
   .rich-content {
     padding: 0 20rpx 16rpx;
     font-size: 28rpx;
     line-height: 1.6;
-    color: #303133;
+    color: var(--c-text);
   }
 
   .detail-media-list {
@@ -603,10 +767,35 @@ watch(
   }
 }
 
+.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 #f5f5f5;
+  border-bottom: 12rpx solid var(--c-bg);
 
   .params-list {
     padding: 0 20rpx 16rpx;
@@ -631,74 +820,105 @@ watch(
   .param-value {
     flex: 1;
     font-size: 26rpx;
-    color: #303133;
+    color: var(--c-text);
   }
 }
 
+.overlay-scroll-inner > view:last-child {
+  border-bottom: 0;
+}
+
 .bottom-bar {
   position: absolute;
-  left: 0;
-  right: 0;
-  bottom: 0;
+  left: 50%;
+  transform: translateX(-50%);
   z-index: 4;
-  background: #fff;
-  border-top: 1rpx solid #ececec;
-  padding: 14rpx 20rpx calc(14rpx + env(safe-area-inset-bottom));
+  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;
-  gap: 16rpx;
+  justify-content: space-between;
+  padding: 12rpx;
+  box-shadow: 0 16rpx 36rpx rgba(10, 159, 109, 0.12);
+  box-sizing: border-box;
 }
 
-.action-left {
-  width: 92rpx;
-  height: 92rpx;
-  border-radius: 46rpx;
-  background: #f5f7fa;
+.cart-left {
   display: flex;
   align-items: center;
-  justify-content: center;
-  position: relative;
-  flex-shrink: 0;
+  gap: 20rpx;
+  padding-left: 8rpx;
+  flex: 1;
+  min-width: 0;
+}
 
-  .cart-icon {
-    font-size: 44rpx;
-  }
+.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;
 
-  .badge {
-    position: absolute;
-    top: -6rpx;
-    right: -6rpx;
-    min-width: 32rpx;
-    height: 32rpx;
-    padding: 0 6rpx;
-    background: #ee0a24;
+  text {
+    font-size: 24rpx;
+    font-weight: 700;
     color: #fff;
-    font-size: 20rpx;
-    border-radius: 16rpx;
-    display: flex;
-    align-items: center;
-    justify-content: center;
   }
 }
 
-.action-right {
-  flex: 1;
+.cart-text-col {
   display: flex;
-  justify-content: flex-end;
+  flex-direction: column;
+  gap: 2rpx;
+  min-width: 0;
 }
 
-.checkout-btn {
-  height: 80rpx;
-  min-width: 220rpx;
-  padding: 0 30rpx;
-  border-radius: 40rpx;
-  background: #07c160;
+.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: flex;
+  display: inline-flex;
   align-items: center;
   justify-content: center;
   font-size: 28rpx;
-  font-weight: 600;
+  font-weight: 700;
+  box-shadow: inset 0 -2rpx 0 rgba(0, 0, 0, 0.1);
+
+  text {
+    color: #fff;
+  }
 }
 </style>
 

+ 367 - 185
sqxp-wx/pages/client/cart/cart.vue

@@ -4,9 +4,12 @@ import { ref, computed } from 'vue'
 import { useUserStore } from '@/stores/user'
 import { useCartStore } from '@/stores/cart'
 import { formatYuan } from '@/utils/format'
-import { IMG_DEFAULT_PRODUCT, IMG_EMPTY } from '@/utils/staticAsset'
+import { IMG_DEFAULT_PRODUCT, IMG_EMPTY, staticAssetUrl } from '@/utils/staticAsset'
 import { request, parseResponseJson } from '@/utils/request'
 import ProductDetailOverlay from '@/components/client/product-detail-overlay.vue'
+import ClientTabBar from '@/components/client/client-tab-bar.vue'
+import ClientSkeleton from '@/components/client/client-skeleton.vue'
+import ClientImageLoader from '@/components/client/client-image-loader.vue'
 
 // Store
 const userStore = useUserStore()
@@ -62,6 +65,9 @@ const groupedCartLines = computed(() => {
   return groups
 })
 
+const ICON_COORDINATE = staticAssetUrl('coordinate.svg')
+const ICON_DELETE = staticAssetUrl('delete.svg')
+
 async function loadCartList(reset) {
   if (cartListLoading.value) return
   const page = reset ? 1 : nextCartPage.value
@@ -152,7 +158,7 @@ async function loadAddresses() {
     const res = await request({
       url: '/mini/client/addresses',
       method: 'GET',
-      data: { page: 1, pageSize: 500 }
+      data: { page: 1, pageSize: 100 }
     })
     const body = parseResponseJson(res)
     if (body.code !== 200) {
@@ -338,13 +344,18 @@ onShow(loadCart)
 </script>
 
 <template>
-  <view class="cart-root">
+  <view class="cart-root client-theme">
   <view class="cart-page">
+    <!-- 对齐 sanqiang-client-cart-ui-demo.html .top -->
+    <view class="cart-hero">
+      <text class="cart-hero-title">购物车</text>
+      <text class="cart-hero-sub">支持一键结算</text>
+    </view>
     <!-- 顶部固定:收货地址(不参与商品区滚动) -->
     <view class="cart-top">
       <view class="address-card">
         <view class="addr-left">
-          <text class="addr-icon">📍</text>
+          <image class="addr-icon" :src="ICON_COORDINATE" mode="aspectFit" />
         </view>
         <view class="addr-main">
           <view class="addr-top">
@@ -373,71 +384,100 @@ onShow(loadCart)
       :lower-threshold="80"
       @scrolltolower="onCartScrollToLower"
     >
-      <view v-if="!cartStore.isEmpty" class="cart-items-inner">
+      <view v-if="cartListLoading && cartLines.length === 0" class="cart-items-inner">
+        <ClientSkeleton variant="list" :rows="3" />
+      </view>
+      <view v-else-if="!cartStore.isEmpty" class="cart-items-inner">
         <view v-for="g in groupedCartLines" :key="g.key" class="category-group">
           <view class="category-header">
             <text class="category-name">{{ g.title }}</text>
           </view>
 
-          <view v-for="item in g.items" :key="item.id" class="cart-item">
+          <view
+            v-for="item in g.items"
+            :key="item.id"
+            class="cart-item"
+            hover-class="cart-item--pressed"
+            :hover-stay-time="100"
+          >
             <view class="item-check" @click.stop="toggleSelect(item.id)">
               <view class="circle" :class="{ checked: selectedIds.has(item.id) }" />
             </view>
-            <view class="item-image" @click="goProductView(item.productId, item.quantity)">
-              <image :src="item.imageUrl || IMG_DEFAULT_PRODUCT" mode="aspectFill" />
-            </view>
-
-            <view class="item-info" @click="goProductView(item.productId, item.quantity)">
-              <text class="item-name">{{ item.productName }}</text>
-              <text class="item-unit">{{ formatMeasureText(item.measureValue, item.unitName) }}</text>
-              <text class="item-price">¥{{ formatPrice(item.price) }}</text>
-            </view>
-
-            <view class="item-right-actions">
-              <view class="item-delete" @click.stop="removeItem(item.id)">
-                <text>🗑️</text>
+            <view class="item-card">
+              <view class="item-image sq-product-thumb sq-product-thumb--md" @click="goProductView(item.productId, item.quantity)">
+                <ClientImageLoader :src="item.imageUrl || IMG_DEFAULT_PRODUCT" mode="aspectFill" radius="20rpx" />
               </view>
-              <view class="quantity-control" @click.stop>
-                <view class="qty-btn" @click.stop="updateQuantity(item.id, -1)">
-                  <text>-</text>
+
+              <view class="item-body">
+                <view class="item-title-row">
+                  <text class="item-name" @click="goProductView(item.productId, item.quantity)">{{ item.productName }}</text>
+                  <view class="item-delete" @click.stop="removeItem(item.id)">
+                    <image class="item-delete-icon" :src="ICON_DELETE" mode="aspectFit" />
+                  </view>
                 </view>
-                <text class="qty-num">{{ item.quantity }}</text>
-                <view class="qty-btn" @click.stop="updateQuantity(item.id, 1)">
-                  <text>+</text>
+                <text class="item-unit" @click="goProductView(item.productId, item.quantity)">
+                  {{ formatMeasureText(item.measureValue, item.unitName) }}
+                </text>
+                <view class="item-line">
+                  <text class="item-price">¥{{ formatPrice(item.price) }}</text>
+                  <view class="line-actions">
+                    <view class="quantity-control" @click.stop>
+                      <view
+                        class="qty-btn"
+                        hover-class="qty-btn--pressed"
+                        :hover-stay-time="100"
+                        @click.stop="updateQuantity(item.id, -1)"
+                      >
+                        <text class="qty-symbol">-</text>
+                      </view>
+                      <text class="qty-num">{{ item.quantity }}</text>
+                      <view
+                        class="qty-btn"
+                        hover-class="qty-btn--pressed"
+                        :hover-stay-time="100"
+                        @click.stop="updateQuantity(item.id, 1)"
+                      >
+                        <text class="qty-symbol">+</text>
+                      </view>
+                    </view>
+                  </view>  
                 </view>
               </view>
             </view>
           </view>
         </view>
 
-        <view v-if="cartListLoading && cartLines.length === 0" class="list-footer-tip">加载中...</view>
-        <view v-else-if="cartListHasMore" class="list-footer-tip">上拉加载更多</view>
+        <view v-if="cartListHasMore" class="list-footer-tip">上拉加载更多</view>
         <view v-else-if="cartLines.length > 0" class="list-footer-tip muted">没有更多了</view>
       </view>
 
       <view v-else class="empty-state">
         <image class="empty-image" :src="IMG_EMPTY" mode="aspectFit" />
         <text class="empty-text">购物车是空的</text>
-        <button class="empty-btn" type="default" @click="goHome">去逛逛</button>
+        <van-button class="empty-btn" round type="primary" color="#0a9f6d" @click="goHome">去逛逛</van-button>
       </view>
     </scroll-view>
   </view>
 
     <!-- 结算栏移出 cart-page,避免 overflow:hidden 裁切;bottom 与 window-bottom 对齐 -->
     <view v-if="!cartStore.isEmpty" class="checkout-bar">
-      <view class="bar-left">
-        <view class="select-all" @click="toggleSelectAll">
-          <view class="circle" :class="{ checked: isAllSelected }" />
-          <text class="select-all-text">全选</text>
+      <view class="checkout-inner">
+        <view class="bar-left">
+          <view class="select-all" @click="toggleSelectAll">
+            <view class="circle" :class="{ checked: isAllSelected }" />
+            <text class="select-all-text">全选</text>
+          </view>
+          <view class="total-block">
+            <text class="total-muted">已选 {{ selectedCount }} 件商品</text>
+            <text class="total-strong">¥{{ formatPrice(selectedAmount) }}</text>
+          </view>
         </view>
-        <view class="total-info">
-        <text class="total-label">合计(元):</text>
-        <text class="total-price">¥{{ formatPrice(selectedAmount) }}</text>
-      </view>
-      </view>
 
-      <view class="checkout-actions">
-        <button class="checkout-btn" type="default" @click="goCheckout">结算({{ selectedCount }})</button>
+        <view class="checkout-actions">
+          <view class="cart-settle" @click="goCheckout">
+            <text>去结算</text>
+          </view>
+        </view>
       </view>
     </view>
 
@@ -447,6 +487,9 @@ onShow(loadCart)
       :initial-cart-qty="overlayInitialCartQty"
       @close="closeProductOverlay"
     />
+    <!-- #ifdef H5 -->
+    <ClientTabBar active="cart" />
+    <!-- #endif -->
   </view>
 </template>
 
@@ -454,7 +497,7 @@ onShow(loadCart)
 page {
   height: 100%;
   overflow: hidden;
-  background-color: #f5f5f5;
+  background-color: #f5f8f7;
 }
 </style>
 
@@ -477,7 +520,7 @@ page {
   z-index: 1;
   display: flex;
   flex-direction: column;
-  background: #f5f5f5;
+  background: var(--c-bg);
   overflow: hidden;
   box-sizing: border-box;
 }
@@ -490,16 +533,41 @@ page {
   min-height: 0;
   display: flex;
   flex-direction: column;
-  background: #f5f5f5;
+  background: var(--c-bg);
   overflow: hidden;
   box-sizing: border-box;
 }
 /* #endif */
 
-.cart-top {
+.cart-hero {
+  flex-shrink: 0;
+  background: linear-gradient(145deg, var(--c-brand-green), var(--c-brand-green-2));
+  color: #ffffff;
+  padding: calc(24rpx + env(safe-area-inset-top, 0px)) 32rpx 28rpx;
+  border-bottom-left-radius: 36rpx;
+  border-bottom-right-radius: 36rpx;
+  box-shadow: var(--c-shadow);
+}
+
+.cart-hero-title {
+  display: block;
+  font-size: 36rpx;
+  font-weight: 700;
+  line-height: 1.25;
+  margin-bottom: 12rpx;
+}
+
+.cart-hero-sub {
+  display: block;
+  font-size: 24rpx;
+  opacity: 0.9;
+  line-height: 1.35;
+}
+
+ .cart-top {
   flex-shrink: 0;
   flex-grow: 0;
-  padding: 20rpx 20rpx 0;
+  padding: 24rpx 32rpx 0;
   box-sizing: border-box;
   position: relative;
   z-index: 2;
@@ -517,7 +585,7 @@ page {
 }
 
 .cart-items-inner {
-  padding: 0 20rpx 20rpx;
+  padding: 0 32rpx 24rpx;
   padding-bottom: calc(24rpx + 120rpx + constant(safe-area-inset-bottom));
   padding-bottom: calc(24rpx + 120rpx + env(safe-area-inset-bottom));
   box-sizing: border-box;
@@ -536,11 +604,12 @@ page {
 
 .address-card {
   display: flex;
-  background: #ffffff;
-  border-radius: 12rpx;
+  background: var(--c-card);
+  border-radius: var(--c-radius-card);
   padding: 22rpx 20rpx;
   margin-bottom: 18rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+  border: 1rpx solid var(--c-border);
+  box-shadow: none;
   box-sizing: border-box;
 
   .addr-left {
@@ -549,7 +618,8 @@ page {
     justify-content: center;
     padding-top: 4rpx;
     .addr-icon {
-      font-size: 34rpx;
+      width: 34rpx;
+      height: 34rpx;
     }
   }
 
@@ -565,12 +635,12 @@ page {
     margin-bottom: 10rpx;
     .addr-title {
       font-size: 28rpx;
-      color: #303133;
-      font-weight: 600;
+      color: var(--c-text);
+      font-weight: 700;
     }
     .addr-edit {
       font-size: 24rpx;
-      color: #07c160;
+      color: var(--c-brand-green);
     }
   }
 
@@ -585,18 +655,18 @@ page {
 
     .addr-person-name {
       font-size: 26rpx;
-      color: #303133;
+      color: var(--c-text);
     }
 
     .addr-phone {
       font-size: 26rpx;
-      color: #303133;
+      color: var(--c-text);
     }
 
     .addr-detail {
       display: block;
       font-size: 24rpx;
-      color: #606266;
+      color: var(--c-muted);
       line-height: 1.4;
       word-break: break-all;
     }
@@ -606,7 +676,7 @@ page {
     padding: 12rpx 0 6rpx;
     .addr-empty-text {
       font-size: 26rpx;
-      color: #909399;
+      color: var(--c-muted);
     }
   }
 }
@@ -621,157 +691,245 @@ page {
   position: relative;
 
   &.checked {
-    border-color: #07c160;
-    background: #07c160;
+    border-color: var(--c-brand-green);
+    background: var(--c-brand-green);
   }
 
   &.checked::after {
     content: '';
     position: absolute;
-    left: 11rpx;
-    top: 6rpx;
+    left: 50%;
+    top: 50%;
     width: 8rpx;
     height: 16rpx;
     border-right: 4rpx solid #fff;
     border-bottom: 4rpx solid #fff;
-    transform: rotate(45deg);
+    transform: translate(-50%, -58%) rotate(45deg);
   }
 }
 
-// 分类分组
+// 分类分组(对齐 demo .store)
 .category-group {
-  margin-bottom: 24rpx;
-  background: #ffffff;
-  border-radius: 12rpx;
-  padding: 0 20rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+  margin-bottom: 20rpx;
+  background: var(--c-card);
+  border-radius: 28rpx;
+  padding: 0 24rpx;
+  border: 1rpx solid var(--c-border);
+  box-shadow: none;
   box-sizing: border-box;
 
   .category-header {
-    padding: 18rpx 0;
-    border-bottom: 1rpx solid #e4e7ed;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 20rpx 0 16rpx;
+    border-bottom: 1rpx solid #edf5f1;
 
     .category-name {
       font-size: 28rpx;
       font-weight: 600;
-      color: #303133;
+      color: var(--c-text);
     }
   }
 }
 
-// 购物车项
+// 购物车项(对齐 demo .item 网格 84px + 文案 + .line)
 .cart-item {
-  display: flex;
-  align-items: flex-start;
-  padding: 20rpx 0;
-  border-bottom: 1rpx solid #f0f2f5;
+  display: grid;
+  grid-template-columns: 48rpx minmax(0, 1fr);
+  align-items: center;
+  column-gap: 10rpx;
+  padding: 16rpx 0;
+  border-top: 1rpx solid #edf5f1;
 
-  &:last-child {
-    border-bottom: none;
+  &:first-of-type {
+    border-top: none;
   }
 
   .item-check {
-    width: 54rpx;
+    width: 48rpx;
     display: flex;
     align-items: center;
     justify-content: center;
-    margin-right: 10rpx;
+    align-self: center;
   }
 
-  .item-image {
-    width: 160rpx;
-    height: 160rpx;
-    border-radius: 8rpx;
+  .item-card {
+    display: grid;
+    grid-template-columns: 168rpx minmax(0, 1fr);
+    gap: min(16rpx, 2.5vw);
+    align-items: stretch;
+    min-height: 168rpx;
+    height: auto;
+    background: #fff;
+    border: 1rpx solid var(--c-border);
+    border-radius: var(--c-radius-md);
     overflow: hidden;
-    margin-right: 20rpx;
-    background: #f5f7fa;
+    box-sizing: border-box;
+  }
 
+  .item-image {
+    width: 168rpx;
+    height: 168rpx;
+    min-width: 168rpx;
+    min-height: 168rpx;
+    max-width: 168rpx;
+    max-height: 168rpx;
+    flex-shrink: 0;
+    align-self: center;
+    border-radius: 20rpx;
+    overflow: hidden;
+    background: linear-gradient(145deg, #f4fbf8, #e4f6ef);
     image {
+      display: block;
       width: 100%;
       height: 100%;
+      object-fit: cover;
+      border-radius: inherit;
     }
   }
 
-  .item-info {
-    flex: 1;
+  .item-body {
+    min-height: 0;
     min-width: 0;
     display: flex;
     flex-direction: column;
     justify-content: flex-start;
-
-    .item-name {
-      display: block;
-      font-size: 28rpx;
-      color: #303133;
-      line-height: 1.45;
-      word-break: break-word;
-      overflow-wrap: break-word;
-    }
-
-    .item-unit {
-      font-size: 24rpx;
-      color: #909399;
-      margin-top: 8rpx;
-    }
-
-    .item-price {
-      margin-top: 16rpx;
-      font-size: 32rpx;
-      color: #07c160;
-      font-weight: bold;
-    }
+    padding: 10rpx 12rpx 14rpx 0;
+    box-sizing: border-box;
   }
 
-  .item-right-actions {
+  .item-title-row {
     display: flex;
-    flex-direction: column;
-    align-items: flex-end;
+    align-items: flex-start;
     justify-content: space-between;
+    gap: 12rpx;
+  }
+
+  /* demo .name:13px */
+  .item-name {
+    flex: 1 1 auto;
+    min-width: 0;
+    max-width: calc(100% - 72rpx);
+    font-size: clamp(22rpx, 3.4vw, 26rpx);
+    color: var(--c-text);
+    line-height: 1.35;
+    min-height: calc(2 * 1.35em);
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: 2;
+    overflow: hidden;
+    word-break: break-word;
+  }
+
+  .item-delete {
     flex-shrink: 0;
-    align-self: stretch;
-    min-height: 160rpx;
-    padding-left: 12rpx;
+    min-width: 64rpx;
+    height: 44rpx;
+    padding: 10rpx 16rpx 0 6rpx;
+    display: inline-flex;
+    align-items: center;
+    justify-content: flex-end;
     box-sizing: border-box;
   }
 
-  .item-delete {
-    padding: 4rpx 0 0;
+  .item-delete-icon {
+    width: 36rpx;
+    height: 36rpx;
+  }
 
-    text {
-      font-size: 32rpx;
-    }
+  /* demo .spec:11px */
+  .item-unit {
+    display: block;
+    margin: 6rpx 0 12rpx;
+    font-size: clamp(20rpx, 3vw, 22rpx);
+    color: var(--c-muted);
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  /* demo .line:与首页一致,价格可省略 + 操作区固定列,避免窄屏重叠 */
+  .item-line {
+    margin-top: auto;
+    display: grid;
+    grid-template-columns: minmax(0, 1fr) auto;
+    align-items: center;
+    column-gap: 12rpx;
+    min-width: 0;
+    padding-bottom: 6rpx;
+  }
+
+  /* demo .price:16px danger */
+  .item-price {
+    font-size: clamp(24rpx, 3.8vw, 32rpx);
+    color: var(--c-danger);
+    font-weight: 700;
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .line-actions {
+    flex-shrink: 0;
+    padding-right: 12rpx;
   }
 
   .quantity-control {
-    display: flex;
+    display: inline-flex;
     align-items: center;
-    margin-top: auto;
+    gap: 10rpx;
+    min-width: 176rpx;
+    justify-content: space-between;
 
     .qty-btn {
-      width: 48rpx;
-      height: 48rpx;
-      border: 2rpx solid #e4e7ed;
-      border-radius: 8rpx;
+      width: clamp(32rpx, 6.5vw, 42rpx);
+      height: clamp(32rpx, 6.5vw, 42rpx);
+      border: 1rpx solid #b9decf;
+      border-radius: 999rpx;
       display: flex;
       align-items: center;
       justify-content: center;
-      font-size: 28rpx;
-      color: #303133;
+      color: var(--c-brand-green);
+      background: #ffffff;
+      box-sizing: border-box;
+      line-height: 1;
+      padding: 0;
 
       &:active {
-        background: #f5f5f5;
+        background: var(--c-brand-green-soft);
       }
     }
 
+    .qty-symbol {
+      line-height: 1;
+      font-size: clamp(18rpx, 3.2vw, 24rpx);
+      font-weight: 700;
+      display: block;
+      transform: translateY(-1rpx);
+    }
+
     .qty-num {
-      min-width: 60rpx;
+      min-width: 56rpx;
+      max-width: 72rpx;
       text-align: center;
-      font-size: 28rpx;
-      color: #303133;
+      font-size: clamp(18rpx, 3vw, 24rpx);
+      color: var(--c-text);
+      overflow: hidden;
+      text-overflow: ellipsis;
     }
   }
 }
 
+.cart-item--pressed {
+  opacity: 0.96;
+}
+
+.qty-btn--pressed {
+  opacity: 0.88;
+}
+
 // 空状态(在 scroll-view 内,避免整页滚动)
 .empty-state {
   display: flex;
@@ -805,102 +963,126 @@ page {
     justify-content: center;
     border: none !important;
     box-shadow: none !important;
-    background: #07c160 !important;
-    color: #ffffff !important;
-    line-height: 1;
   }
 
-  .empty-btn::after {
+  .empty-btn:deep(.van-button) {
     border: none !important;
+    box-shadow: none !important;
+    background: var(--c-brand-green) !important;
+    color: #ffffff !important;
+    border-color: var(--c-brand-green) !important;
   }
 }
 
-// 底部结算栏
+// 底部结算栏(对齐 demo .bar / .total / .btn)
 .checkout-bar {
   position: fixed;
   /* #ifdef H5 */
-  bottom: var(--window-bottom, calc(50px + env(safe-area-inset-bottom, 0px)));
+  bottom: calc(var(--window-bottom, calc(50px + env(safe-area-inset-bottom, 0px))) + 44rpx);
   z-index: 10010;
   /* #endif */
   /* #ifndef H5 */
-  bottom: 0;
+  bottom: calc(100rpx + constant(safe-area-inset-bottom));
+  bottom: calc(100rpx + env(safe-area-inset-bottom, 0px));
   z-index: 10010;
   /* #endif */
-  left: 0;
-  right: 0;
-  height: 100rpx;
+  left: 50%;
+  transform: translateX(-50%);
+  width: 100%;
+  max-width: 840rpx;
+  padding: 0 24rpx;
+  min-height: 96rpx;
   box-sizing: border-box;
-  background: #ffffff;
+  background: transparent;
   display: flex;
   align-items: center;
-  justify-content: space-between;
-  padding: 0 30rpx;
-  padding-bottom: constant(safe-area-inset-bottom);
-  padding-bottom: env(safe-area-inset-bottom);
-  box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
+  justify-content: center;
+  padding-bottom: calc(6rpx + constant(safe-area-inset-bottom));
+  padding-bottom: calc(6rpx + env(safe-area-inset-bottom, 0px));
   overflow: visible;
+}
+
+.checkout-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;
+  width: 100%;
+  padding: 12rpx;
+  box-shadow: 0 16rpx 36rpx rgba(10, 159, 109, 0.12);
+  box-sizing: border-box;
 
   .bar-left {
     display: flex;
     align-items: center;
-    gap: 20rpx;
+    gap: 16rpx;
     min-width: 0;
+    flex: 1;
   }
 
   .select-all {
     display: flex;
     align-items: center;
     gap: 12rpx;
-    padding: 10rpx 8rpx;
+    padding: 8rpx 4rpx;
+    flex-shrink: 0;
     .select-all-text {
-      font-size: 26rpx;
-      color: #606266;
+      font-size: 24rpx;
+      color: var(--c-muted);
       white-space: nowrap;
     }
   }
 
-  .total-info {
-    min-width: 0;
+  .total-block {
     display: flex;
-    align-items: center;
-    gap: 6rpx;
-    .total-label {
-      font-size: 26rpx;
-      color: #606266;
-      line-height: 1;
-    }
+    flex-direction: column;
+    align-items: flex-start;
+    min-width: 0;
+  }
 
-    .total-price {
-      font-size: 40rpx;
-      color: #07c160;
-      font-weight: bold;
-      line-height: 1;
-    }
+  .total-muted {
+    font-size: 24rpx;
+    color: var(--c-muted);
+    line-height: 1.3;
+  }
+
+  .total-strong {
+    display: block;
+    margin-top: 4rpx;
+    font-size: 40rpx;
+    color: var(--c-danger);
+    font-weight: 700;
+    line-height: 1.1;
   }
 
   .checkout-actions {
     display: flex;
     align-items: center;
+    flex-shrink: 0;
+  }
+}
 
-    .checkout-btn {
-      padding: 0 50rpx;
-      height: 72rpx;
-      border-radius: 36rpx;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      font-size: 28rpx;
-      font-weight: 500;
-      border: none !important;
-      box-shadow: none !important;
-      background: #07c160 !important;
-      color: #ffffff !important;
-      line-height: 1;
-    }
+/* 与首页底部去结算按钮保持一致 */
+.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);
 
-    .checkout-btn::after {
-      border: none !important;
-    }
+  text {
+    color: #fff;
   }
 }
 </style>

+ 1 - 0
sqxp-wx/utils/staticAsset.js

@@ -59,3 +59,4 @@ export const IMG_CUSTOMER_SERVICE = staticAssetUrl('images/客服.svg')
 /** 登录页输入框前图标(与静态资源目录 `images/用户名.svg`、`images/密码.svg` 对应) */
 export const IMG_LOGIN_USERNAME = staticAssetUrl('images/用户名.svg')
 export const IMG_LOGIN_PASSWORD = staticAssetUrl('images/密码.svg')
+export const ICON_ARROW_BOTTOM = staticAssetUrl('arrow-bottom.svg')