From 3ec096ecd91c3dd44abe576e071c78c459b5792b Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Thu, 11 Jun 2026 13:56:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BF=AE=E6=94=B9=E6=98=9F=E6=A6=9C?= =?UTF-8?q?=E5=8D=A1=E9=A1=BF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../square/components/HotCategoryBlock.vue | 84 ++++++++++++++----- .../components/StarGalaxy/ScatteredRanks.vue | 4 +- frontend/utils/assetImageHelper.js | 67 ++++++++++++++- 3 files changed, 132 insertions(+), 23 deletions(-) diff --git a/frontend/pages/square/components/HotCategoryBlock.vue b/frontend/pages/square/components/HotCategoryBlock.vue index 20e1bfe..fc35bdb 100644 --- a/frontend/pages/square/components/HotCategoryBlock.vue +++ b/frontend/pages/square/components/HotCategoryBlock.vue @@ -78,6 +78,7 @@ class="card-image" :src="item.cover_url || item.cover_image || ''" mode="aspectFill" + lazy-load /> { // 加载数据 // append=false:首页加载,覆盖 items;append=true:分页追加,拼接到 items 末尾。 +// +// 性能关键:不再 await 预签名!流程: +// 1) 接口返回后立即用 getInstantAssetCoverUrl 同步拿到「能马上渲染的 URL」(命中缓存/未过期URL/占位图) +// 2) 立刻 set items.value → 列表瞬时出现,骨架屏立即下线 +// 3) 后台并行跑 getAssetCoverRealUrl 拿到精确的预签名 URL,逐个 patch 回 items.value[i].cover_url +// (Vue 3 ref 数组里的对象是 reactive 代理,单个属性变更会触发该卡片重渲染) const loadData = async ({ append = false } = {}) => { const tab = activeTab.value; if (!tab || typeof tab.fetch !== "function") { @@ -341,30 +353,56 @@ const loadData = async ({ append = false } = {}) => { try { const res = await tab.fetch(currentPage.value); if (res && res.code === 200 && res.data?.items) { - // 逐个把 cover_url 转换成真实可访问 URL(OSS 预签名前端实现) - const newItems = await Promise.all( - res.data.items.map(async (item) => { - return await resolveItemUrls({ - ...item, - id: item.id || item.asset_id, - }); - }), - ); + // ① 立刻准备好「能马上渲染的」items —— 不等任何网络 + const rawItems = res.data.items.map((it) => { + const id = it.id || it.asset_id; + const rawCover = it.cover_url || it.cover_image || ""; + return { + ...it, + id, + _rawCover: rawCover, // 留一份原始 URL,后台异步任务会用它去预签名 + cover_url: getInstantAssetCoverUrl(rawCover), + }; + }); + // ② 立即上屏 + const baseOffset = append ? items.value.length : 0; if (append) { - items.value = [...items.value, ...newItems]; + items.value = [...items.value, ...rawItems]; } else { - items.value = newItems; + items.value = rawItems; } - // 判定是否还有下一页: - // 1) 接口返回 total 时,按累计长度比对; - // 2) 兜底:本次返回不足一页认为到底。 + // ③ 后台并行解析预签名 URL,单张图回来就 patch 一张 + // 不 await —— 不阻塞任何渲染 + rawItems.forEach((it, idx) => { + const raw = it._rawCover; + if (!raw) return; + // 同步路径下已经有精确 URL(命中缓存 / 完整未过期)则无需再请求 + const instant = it.cover_url; + if (instant && instant !== PLACEHOLDER_IMAGE && instant === getInstantAssetCoverUrl(raw)) { + // 已是精确 URL;仍然异步校准一次以处理过期(getAssetCoverRealUrl 内部命中缓存就是同步快速返回) + } + getAssetCoverRealUrl(raw) + .then((realUrl) => { + const targetIdx = baseOffset + idx; + const target = items.value[targetIdx]; + // 防御:用户已切 tab / 列表被清空时,跳过 patch + if (target && target.id === it.id && realUrl) { + target.cover_url = realUrl; + } + }) + .catch(() => { + /* 单张失败不影响其它图 */ + }); + }); + + // 判定是否还有下一页 const total = Number(res.data.total ?? 0); if (total > 0) { hasMore.value = items.value.length < total; } else { - hasMore.value = newItems.length >= PAGE_SIZE; + hasMore.value = rawItems.length >= PAGE_SIZE; } } else if (!append) { items.value = []; @@ -379,6 +417,9 @@ const loadData = async ({ append = false } = {}) => { } }; +// 占位图常量(与 helper 内部 DEFAULT_IMAGE 一致) +const PLACEHOLDER_IMAGE = "/static/nft/collection.png"; + // 滚动到底部触发加载下一页 const handleScrollToLower = () => { if (loading.value || loadingMore.value || !hasMore.value) return; @@ -434,7 +475,7 @@ onUnmounted(() => { inset: 0; background: linear-gradient(184deg, #ff5a5d -36.55%, #c2ebff 121.2%); opacity: 0.8; - backdrop-filter: blur(5.85px); + // backdrop-filter: blur(5.85px); border-top-left-radius: 14px; border-top-right-radius: 13px; border-bottom-right-radius: 8px; @@ -467,7 +508,7 @@ onUnmounted(() => { rgba(194, 235, 255, 0.98) 184.09% ); opacity: 0.5; - backdrop-filter: blur(0.9px); + // backdrop-filter: blur(0.9px); border-radius: 20rpx; } } @@ -508,7 +549,7 @@ onUnmounted(() => { rgba(76, 237, 255, 0.2) 48.19%, rgba(255, 122, 124, 0.2) 83.71% ); - backdrop-filter: blur(9.300000190734863px); + // backdrop-filter: blur(9.300000190734863px); overflow: hidden; } @@ -566,7 +607,7 @@ onUnmounted(() => { rgba(76, 237, 255, 0.2) 48.19%, rgba(255, 122, 124, 0.2) 83.71% ); - backdrop-filter: blur(4.65px); + // backdrop-filter: blur(4.65px); border-top-left-radius: 13px; border-top-right-radius: 12px; border-bottom-right-radius: 12px; @@ -626,14 +667,17 @@ onUnmounted(() => { .grid-card-top-1 { background: url("/static/square/galaxy/TOP.png") no-repeat center; + background-size: 100% 100%; } .grid-card-top-2 { background: url("/static/square/galaxy/TOP2.png") no-repeat center; + background-size: 100% 100%; } .grid-card-top-3 { background: url("/static/square/galaxy/TOP3.png") no-repeat center; + background-size: 100% 100%; } .grid-card-top-4 { position: relative; diff --git a/frontend/pages/square/components/StarGalaxy/ScatteredRanks.vue b/frontend/pages/square/components/StarGalaxy/ScatteredRanks.vue index d61a9e8..619317e 100644 --- a/frontend/pages/square/components/StarGalaxy/ScatteredRanks.vue +++ b/frontend/pages/square/components/StarGalaxy/ScatteredRanks.vue @@ -265,10 +265,10 @@ function handleClick(item) { /* 可访问性:减少动画 */ -/* @media (prefers-reduced-motion: reduce) { +@media (prefers-reduced-motion: reduce) { .ring-item, .crown { animation: none !important; } -} */ +} */ */ --> diff --git a/frontend/utils/assetImageHelper.js b/frontend/utils/assetImageHelper.js index 839b8db..1792ede 100644 --- a/frontend/utils/assetImageHelper.js +++ b/frontend/utils/assetImageHelper.js @@ -1,5 +1,37 @@ import { getOssPresignedUrlApi } from '@/utils/api.js' +/** + * 预签名 URL 内存缓存 + * key = 原始 cover_url(相对路径 / 完整带 Expires 的 URL) + * value = 已签名的完整 URL + * + * 命中规则: + * - 命中后再检查一次 isExpiredPresignedUrl,过期则丢弃重签 + * - 同一进程内(页面 reload 会清);上限 500 条,超过随机清一半 + */ +const coverUrlCache = new Map() +const COVER_CACHE_LIMIT = 500 + +function setCoverCache(rawUrl, signedUrl) { + if (!rawUrl || !signedUrl) return + if (coverUrlCache.size >= COVER_CACHE_LIMIT) { + // 简单容量控制:删一半旧的,避免无限增长 + const keys = Array.from(coverUrlCache.keys()).slice(0, COVER_CACHE_LIMIT / 2) + keys.forEach(k => coverUrlCache.delete(k)) + } + coverUrlCache.set(rawUrl, signedUrl) +} + +function getCoverCache(rawUrl) { + const v = coverUrlCache.get(rawUrl) + if (!v) return null + if (isExpiredPresignedUrl(v)) { + coverUrlCache.delete(rawUrl) + return null + } + return v +} + /** * 从URL提取文件名 * @param {String} url - URL字符串 @@ -106,6 +138,32 @@ function extractOssObjectPathFromUrl(url) { } } +/** + * 同步获取「立即可用的 cover URL」,绝不发起网络请求。 + * 用于:先用这个 URL 立即渲染列表,再异步调 getAssetCoverRealUrl 拿到精确的预签名 URL 并替换。 + * + * 优先级: + * 1) /static/... 或空 → 兜底占位图 + * 2) 命中缓存 → 缓存内的预签名 URL + * 3) 完整 URL 未过期 → 原样返回 + * 4) 其它(相对路径 / 过期 URL) → 占位图(等异步预签名补上) + * + * @param {String} coverUrl + * @param {String} [placeholder='/static/nft/collection.png'] + * @returns {String} + */ +export function getInstantAssetCoverUrl(coverUrl, placeholder = '/static/nft/collection.png') { + if (!coverUrl || coverUrl.startsWith('/static/')) { + return coverUrl || placeholder + } + const cached = getCoverCache(coverUrl) + if (cached) return cached + if (coverUrl.includes('://') && !isExpiredPresignedUrl(coverUrl)) { + return coverUrl + } + return placeholder +} + /** * 获取藏品封面的真实URL - 使用 OSS 预签名 URL * 处理 3 种 coverUrl 形态: @@ -124,6 +182,10 @@ export async function getAssetCoverRealUrl(coverUrl) { return coverUrl || DEFAULT_IMAGE; } + // 命中缓存:直接返回(已在 getCoverCache 内做过期检查) + const cached = getCoverCache(coverUrl) + if (cached) return cached + // 2. 完整 URL(带 ://) if (coverUrl.includes('://')) { // 检查是否带 Expires 且已过期 → 提取对象路径重签 @@ -132,6 +194,7 @@ export async function getAssetCoverRealUrl(coverUrl) { try { const res = await getOssPresignedUrlApi(objectPath, 3600, 'asset') if (res && res.data && res.data.url) { + setCoverCache(coverUrl, res.data.url) return res.data.url } } catch (e) { @@ -139,7 +202,8 @@ export async function getAssetCoverRealUrl(coverUrl) { } return objectPath // 兜底:返回相对路径 } - // 未过期:原样返回 + // 未过期:原样返回 + 顺手缓存 + setCoverCache(coverUrl, coverUrl) return coverUrl } @@ -147,6 +211,7 @@ export async function getAssetCoverRealUrl(coverUrl) { try { const res = await getOssPresignedUrlApi(coverUrl, 3600, 'asset'); if (res && res.data && res.data.url) { + setCoverCache(coverUrl, res.data.url) return res.data.url; } return coverUrl;