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;