zlwu 6 mesi fa
parent
commit
093ff55fc1

+ 6 - 0
sqxp-wx/.env.example

@@ -10,3 +10,9 @@
 # 生产环境(发版前替换)
 # VITE_STATIC_ASSETS_BASE_URL=https://REPLACE_PROD_STATIC_HOST/sqxp-static/
 # VUE_APP_STATIC_ASSETS_BASE_URL=https://REPLACE_PROD_STATIC_HOST/sqxp-static/
+
+# 静态资源/OSS 图片版本号(用于兜底绕过长缓存)
+# - 只要图片内容有更新但 URL 没变(同名覆盖),把这个值改一下重新打包即可强制刷新
+# - 不要用 Date.now() 每次变化,否则会导致完全无法命中缓存
+# VITE_STATIC_ASSETS_VERSION=20260423
+# VUE_APP_STATIC_ASSETS_VERSION=20260423

+ 5 - 2
sqxp-wx/components/client/client-image-loader.vue

@@ -1,6 +1,6 @@
 <template>
   <view class="img-wrap" :style="wrapStyle" @click="onClick">
-    <image class="img" :src="src" :mode="mode" @load="onLoad" @error="onError" />
+    <image class="img" :src="displaySrc" :mode="mode" @load="onLoad" @error="onError" />
     <view v-if="loading && src" class="img-mask">
       <view class="spinner" />
     </view>
@@ -9,6 +9,7 @@
 
 <script setup>
 import { computed, ref, watch, onBeforeUnmount } from 'vue';
+import { withOssVersion } from '@/utils/ossCache'
 
 const props = defineProps({
   src: {
@@ -30,6 +31,8 @@ const emit = defineEmits(['click', 'load', 'error']);
 const loading = ref(true);
 let loadingTimer = null;
 
+const displaySrc = computed(() => withOssVersion(props.src))
+
 function clearLoadingTimer() {
   if (loadingTimer) {
     clearTimeout(loadingTimer);
@@ -47,7 +50,7 @@ function startLoadingFallback() {
 }
 
 watch(
-  () => props.src,
+  () => displaySrc.value,
   () => {
     loading.value = !!props.src;
     if (loading.value) {

+ 5 - 3
sqxp-wx/components/client/product-detail-overlay.vue

@@ -36,7 +36,7 @@
                   <view class="banner-media-wrap">
                     <image
                       v-if="banner.mediaType === 'image'"
-                      :src="banner.fileUrl"
+                      :src="ossImg(banner.fileUrl)"
                       mode="aspectFill"
                       class="banner-image"
                       @load="onBannerMediaLoaded(index)"
@@ -142,7 +142,7 @@
                     <view class="detail-media-wrap">
                       <image
                         v-if="m.contentType === 'image'"
-                        :src="m.fileUrl"
+                        :src="ossImg(m.fileUrl)"
                         mode="widthFix"
                         class="detail-media-img"
                         @load="onDetailMediaLoaded(idx)"
@@ -151,7 +151,7 @@
                       <video
                         v-else
                         :src="m.fileUrl"
-                        :poster="m.thumbnailUrl || ''"
+                        :poster="ossImg(m.thumbnailUrl || '')"
                         controls
                         class="detail-media-video"
                         @loadedmetadata="onDetailMediaLoaded(idx)"
@@ -204,6 +204,7 @@ import { request, parseResponseJson } from '@/utils/request'
 import { useCartStore } from '@/stores/cart'
 import { formatYuan } from '@/utils/format'
 import { ICON_ARROW_BOTTOM } from '@/utils/staticAsset'
+import { withOssVersion } from '@/utils/ossCache'
 
 const props = defineProps({
   show: { type: Boolean, default: false },
@@ -224,6 +225,7 @@ const props = defineProps({
 const emit = defineEmits(['close'])
 
 const cartStore = useCartStore()
+const ossImg = withOssVersion
 
 const loading = ref(false)
 const product = ref(null)

+ 1 - 0
sqxp-wx/pages/client/order/order.vue

@@ -493,6 +493,7 @@ onShow(() => {
       display: inline-flex;
       align-items: center;
       align-self: center;
+      margin-left: 10rpx;
       background: transparent !important;
       border: none !important;
       box-shadow: none !important;

+ 3 - 1
sqxp-wx/pages/client/product/detail.vue

@@ -76,7 +76,7 @@
             mode="widthFix"
             class="detail-media-img"
           />
-          <video v-else :src="m.fileUrl" :poster="m.thumbnailUrl || ''" controls class="detail-media-video" />
+          <video v-else :src="m.fileUrl" :poster="ossImg(m.thumbnailUrl || '')" controls class="detail-media-video" />
           <text v-if="m.description" class="detail-media-desc">{{ m.description }}</text>
         </view>
       </view>
@@ -203,6 +203,7 @@ import { fetchProductReviews, fetchReviewStatistics } from '@/api/reviewApi'
 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'
+import { withOssVersion } from '@/utils/ossCache'
 
 const userStore = useUserStore()
 const cartStore = useCartStore()
@@ -226,6 +227,7 @@ const cartCount = ref(0)
 const tabBarActive = ref('home')
 
 const formatPrice = (yuan) => formatYuan(yuan ?? 0)
+const ossImg = withOssVersion
 
 function formatTime(time) {
   if (!time) return ''

+ 3 - 1
sqxp-wx/pages/client/product/view.vue

@@ -71,7 +71,7 @@
                   mode="widthFix"
                   class="detail-media-img"
                 />
-                <video v-else :src="m.fileUrl" :poster="m.thumbnailUrl || ''" controls class="detail-media-video" />
+                <video v-else :src="m.fileUrl" :poster="ossImg(m.thumbnailUrl || '')" controls class="detail-media-video" />
                 <text v-if="m.description" class="detail-media-desc">{{ m.description }}</text>
               </view>
             </view>
@@ -129,6 +129,7 @@ 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'
 import { IMG_DEFAULT_PRODUCT } from '@/utils/staticAsset'
+import { withOssVersion } from '@/utils/ossCache'
 
 const userStore = useUserStore()
 const cartStore = useCartStore()
@@ -148,6 +149,7 @@ const cartCount = ref(0)
 const tabBarActive = ref('home')
 
 const formatPrice = (fen) => formatYuan(fen ?? 0)
+const ossImg = withOssVersion
 
 function formatMeasureText(measureValue, unitName) {
   const unitText = (unitName || '').trim()

+ 3 - 1
sqxp-wx/pages/supplier/product/detail.vue

@@ -74,7 +74,7 @@
                   class="detail-media-img"
                   radius="20rpx"
                 />
-                <video v-else :src="m.fileUrl" :poster="m.thumbnailUrl || ''" controls class="detail-media-video" />
+                <video v-else :src="m.fileUrl" :poster="ossImg(m.thumbnailUrl || '')" controls class="detail-media-video" />
                 <text v-if="m.description" class="detail-media-desc">{{ m.description }}</text>
               </view>
             </view>
@@ -117,6 +117,7 @@ import { request } from '@/utils/request'
 import { useUserStore } from '@/stores/user'
 import ClientSkeleton from '@/components/client/client-skeleton.vue'
 import ClientImageLoader from '@/components/client/client-image-loader.vue'
+import { withOssVersion } from '@/utils/ossCache'
 
 const userStore = useUserStore()
 
@@ -139,6 +140,7 @@ const currentProductId = ref('')
 const bannerActiveIndex = ref(0)
 let openerEventChannel: any = null
 const TEMP_PRICE_STORAGE_KEY = 'supplier_inquiry_temp_prices'
+const ossImg = withOssVersion
 
 function formatMeasureText(measureValue?: number | null, unitName?: string | null) {
   const unitText = (unitName || '').trim()

+ 3 - 2
sqxp-wx/utils/avatar.js

@@ -1,5 +1,6 @@
 import { BASE_URL } from '@/config/api'
 import { DEFAULT_PLACEHOLDER_IMAGE_URL } from '@/utils/staticAsset'
+import { withOssVersion } from '@/utils/ossCache'
 
 /** 未设置头像时统一使用叁强 logo(远程静态 images/logo.png) */
 export const DEFAULT_USER_AVATAR_URL = DEFAULT_PLACEHOLDER_IMAGE_URL
@@ -17,10 +18,10 @@ export function defaultAvatarPathByRole(_role) {
 export function normalizeAvatarUrlForMini(avatarUrl) {
   const u = String(avatarUrl || '').trim()
   if (!u) return ''
-  if (/^https?:\/\//i.test(u) || u.startsWith('//')) return u
+  if (/^https?:\/\//i.test(u) || u.startsWith('//')) return withOssVersion(u)
   const origin = BASE_URL.replace(/\/$/, '')
   const path = u.startsWith('/') ? u : `/${u}`
-  return `${origin}${path}`
+  return withOssVersion(`${origin}${path}`)
 }
 
 /** 展示用头像:有自定义 URL 则转为可访问地址,否则用叁强 logo(本地路径) */

+ 99 - 0
sqxp-wx/utils/ossCache.js

@@ -0,0 +1,99 @@
+// 统一给 OSS/MinIO/静态域资源追加版本参数(用于绕过长缓存)。
+// 约束:不要用 Date.now() 常态化破缓存;默认用“可控版本号”,需要强刷时只改版本号即可。
+
+let _cachedVersion = null
+
+// 作为兜底版本号来源(构建期静态注入)
+// eslint-disable-next-line import/no-unresolved
+import manifest from '@/manifest.json'
+
+function pickEnv(name) {
+  try {
+    if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env[name]) {
+      return String(import.meta.env[name]).trim()
+    }
+  } catch {
+    // ignore
+  }
+  try {
+    if (typeof process !== 'undefined' && process.env && process.env[name]) {
+      return String(process.env[name]).trim()
+    }
+  } catch {
+    // ignore
+  }
+  return ''
+}
+
+function fallbackVersionFromManifest() {
+  try {
+    const vn = String(manifest?.versionName || '').trim()
+    const vc = String(manifest?.versionCode || '').trim()
+    return [vn, vc].filter(Boolean).join('.')
+  } catch {
+    // ignore
+  }
+  return ''
+}
+
+/**
+ * 统一版本号:
+ * - 优先:`VITE_STATIC_ASSETS_VERSION` / `VUE_APP_STATIC_ASSETS_VERSION` / `UNI_APP_STATIC_ASSETS_VERSION`
+ * - 兜底:manifest.versionName/versionCode
+ * - 最终兜底:固定日期(需要强刷时可直接改这个值或用环境变量覆盖)
+ */
+export function getStaticAssetsVersion() {
+  if (_cachedVersion != null) return _cachedVersion
+  const v =
+    pickEnv('VITE_STATIC_ASSETS_VERSION') ||
+    pickEnv('VUE_APP_STATIC_ASSETS_VERSION') ||
+    pickEnv('UNI_APP_STATIC_ASSETS_VERSION') ||
+    fallbackVersionFromManifest() ||
+    '20260423'
+  _cachedVersion = String(v || '').trim()
+  return _cachedVersion
+}
+
+function hasSignedQuery(u) {
+  // 预签名/鉴权 URL:追加参数可能导致签名失效,必须跳过
+  return /(?:[?&](?:X-Amz-|x-amz-|OSSAccessKeyId|Signature|x-oss-|Expires)=)/.test(u)
+}
+
+function hasVersionParam(u) {
+  return /(?:[?&]v=)/i.test(u)
+}
+
+function looksLikeOurObjectStorage(u) {
+  // 只对我们自己的 OSS/MinIO/静态域处理,避免误伤第三方资源
+  return (
+    u.includes('/sqxp-static/') ||
+    u.includes('/sanqiang/') ||
+    u.includes('/minio/') ||
+    /:\/\/(?:www\.)?chengyantek\.cn\//i.test(u) ||
+    /:\/\/\d{1,3}(?:\.\d{1,3}){3}:20004\//.test(u)
+  )
+}
+
+function appendQueryParam(url, key, value) {
+  const sep = url.includes('?') ? '&' : '?'
+  return `${url}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`
+}
+
+/**
+ * 给 OSS/MinIO/静态域 URL 追加 `v=<version>`,用于“内容更新但 URL 不变”时强制刷新。
+ *
+ * - 已带 `v=`:不重复追加
+ * - 预签名 URL:不追加(避免签名失效)
+ * - 非 http(s) / // / 非本站存储域:不处理
+ */
+export function withOssVersion(rawUrl, version = getStaticAssetsVersion()) {
+  const u = String(rawUrl || '').trim()
+  if (!u) return ''
+  if (!(u.startsWith('http://') || u.startsWith('https://') || u.startsWith('//'))) return u
+  if (!version) return u
+  if (!looksLikeOurObjectStorage(u)) return u
+  if (hasVersionParam(u)) return u
+  if (hasSignedQuery(u)) return u
+  return appendQueryParam(u, 'v', version)
+}
+

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

@@ -3,6 +3,7 @@ import {
   STATIC_ASSETS_BASE_URL_PRODUCTION,
   STATIC_ASSETS_BASE_URL_TEST
 } from '@/config/static-assets-defaults'
+import { getStaticAssetsVersion, withOssVersion } from '@/utils/ossCache'
 
 function normalizeBase(u) {
   const s = (u || '').trim() || STATIC_ASSETS_BASE_URL_DEFAULT
@@ -55,7 +56,9 @@ export function getStaticAssetsBaseUrl() {
 export function staticAssetUrl(relativePath) {
   const p = String(relativePath || '').replace(/^\/+/, '')
   const seg = p.split('/').filter(Boolean).map((s) => encodeURIComponent(s)).join('/')
-  return `${getStaticAssetsBaseUrl()}${seg}`
+  const url = `${getStaticAssetsBaseUrl()}${seg}`
+  // sqxp-static 默认在 nginx 侧设置了长缓存;这里统一追加 v=版本号,避免同名覆盖后不生效
+  return withOssVersion(url, getStaticAssetsVersion())
 }
 
 export const DEFAULT_PLACEHOLDER_IMAGE_URL = staticAssetUrl('images/logo.png')