From d6767c2c5cf5365ffb5f8c1c28ce98be4bc31eb8 Mon Sep 17 00:00:00 2001 From: whm <973418690@qq.com> Date: Fri, 20 Mar 2026 22:33:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E4=BA=A7=E5=93=81=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E9=9D=99=E6=80=81=E4=BC=98=E5=85=88=EF=BC=8C=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E6=97=B6=E5=9B=9E=E9=80=80=20promotion-media=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- web/src/data/promotionVideos.js | 75 +++++++++++++++++++++++++++++--- web/src/utils/promotionAssets.js | 4 +- web/src/views/Home.vue | 17 ++++++-- 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/web/src/data/promotionVideos.js b/web/src/data/promotionVideos.js index aa9bfc8..537f0aa 100644 --- a/web/src/data/promotionVideos.js +++ b/web/src/data/promotionVideos.js @@ -44,16 +44,79 @@ export const PROMOTION_VIDEOS_BASE = [ } ] -/** @param {string} siteId 空串时走本地/静态 /promotion/… */ -export function buildPromotionVideos(siteId) { +/** + * 检测同域静态 /promotion/ 文件是否可访问(优先 HEAD,405 时用 Range GET) + * @param {string} url + * @returns {Promise} + */ +export async function promotionStaticUrlExists(url) { + try { + const head = await fetch(url, { + method: 'HEAD', + mode: 'same-origin', + credentials: 'same-origin', + cache: 'default' + }) + if (head.ok) return true + if (head.status === 405) { + const r = await fetch(url, { + method: 'GET', + headers: { Range: 'bytes=0-0' }, + mode: 'same-origin', + credentials: 'same-origin', + cache: 'default' + }) + return r.ok || r.status === 206 + } + return false + } catch { + return false + } +} + +/** + * 有静态则静态,否则(需 siteId)走 promotion-media API + * @param {string} siteId + * @param {string} relPath promotion 下相对路径,如 social/xxx.mov + */ +export async function pickPromotionAssetUrl(siteId, relPath) { + const staticUrl = promotionUrl(relPath) + const hasStatic = await promotionStaticUrlExists(staticUrl) + if (hasStatic) return staticUrl + if (siteId) return promotionMediaApiUrl(siteId, relPath) + return staticUrl +} + +/** + * 同步列表:全部静态 URL(首屏占位、无 siteId 时与异步结果一致场景) + * @param {string} [_siteId] 保留兼容,当前忽略 + */ +export function buildPromotionVideos(_siteId) { return PROMOTION_VIDEOS_BASE.map((v) => ({ id: v.id, title: v.title, desc: v.desc, - cover: promotionMediaApiUrl(siteId, v.relCover), - src: promotionMediaApiUrl(siteId, v.relVideo) + cover: promotionUrl(v.relCover), + src: promotionUrl(v.relVideo) })) } -/** @deprecated 请用 buildPromotionVideos(siteId);保留兼容旧引用 */ -export const PROMOTION_VIDEOS = buildPromotionVideos('') +/** + * 静态优先:存在 /promotion/... 则用静态,否则用 uploads API(需 siteId) + * @param {string} siteId + */ +export async function buildPromotionVideosAsync(siteId) { + const id = siteId || '' + return Promise.all( + PROMOTION_VIDEOS_BASE.map(async (v) => { + const [cover, src] = await Promise.all([ + pickPromotionAssetUrl(id, v.relCover), + pickPromotionAssetUrl(id, v.relVideo) + ]) + return { id: v.id, title: v.title, desc: v.desc, cover, src } + }) + ) +} + +/** @deprecated 请用 buildPromotionVideos() 或 buildPromotionVideosAsync */ +export const PROMOTION_VIDEOS = buildPromotionVideos() diff --git a/web/src/utils/promotionAssets.js b/web/src/utils/promotionAssets.js index 4db7269..b586e47 100644 --- a/web/src/utils/promotionAssets.js +++ b/web/src/utils/promotionAssets.js @@ -1,8 +1,8 @@ import { apiBase } from '../config' /** - * 推广素材根路径。开发时由 Vite 插件映射到 web/promotion; - * 生产环境视频默认走后台上传:`/api/web/sites/{siteId}/promotion-media/...`(见 buildPromotionVideos)。 + * 推广素材根路径。开发时由 Vite 映射到 web/promotion; + * 首页产品视频见 `buildPromotionVideosAsync`:同域静态存在则 `/promotion/...`,否则 `promotionMediaApiUrl`。 */ export function promotionUrl(relativePath) { const parts = String(relativePath).split('/').filter(Boolean) diff --git a/web/src/views/Home.vue b/web/src/views/Home.vue index 292e70a..6db8f31 100644 --- a/web/src/views/Home.vue +++ b/web/src/views/Home.vue @@ -312,7 +312,7 @@ import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import { apiBase } from '../config' import BlockRenderer from '../components/blocks/BlockRenderer.vue' -import { buildPromotionVideos } from '../data/promotionVideos' +import { buildPromotionVideos, buildPromotionVideosAsync } from '../data/promotionVideos' import { PROMOTION_SOCIAL_FOLLOW } from '../data/promotionSocial' import { getCachedWebSiteId, fetchWebRoutes } from '../api/webPages' @@ -322,9 +322,18 @@ const starsEl = ref(null) let cometTimer = null const scrollY = ref(0) const openFaq = ref(0) -/** 官网 site_id(bootstrap 已拉路由时此处已有值;为空则产品视频走本地 /promotion) */ +/** 官网 site_id(bootstrap 已拉路由时此处已有值;产品视频:静态优先,否则 promotion-media API) */ const webSiteId = ref(getCachedWebSiteId() || '') -const promoVideos = computed(() => buildPromotionVideos(webSiteId.value)) +const promoVideos = ref(buildPromotionVideos(webSiteId.value)) +async function refreshPromoVideos() { + const id = webSiteId.value || '' + try { + promoVideos.value = await buildPromotionVideosAsync(id) + } catch { + promoVideos.value = buildPromotionVideos(id) + } +} +watch(webSiteId, refreshPromoVideos, { immediate: true }) const activeVideo = ref(null) watch( promoVideos, @@ -333,7 +342,7 @@ watch( const cur = activeVideo.value if (!cur || !list.some((v) => v.id === cur.id)) activeVideo.value = list[0] }, - { immediate: true } + { immediate: true, deep: true } ) const videoModalOpen = ref(false) const modalVideoSrc = ref('')