feat:修改星榜卡顿问题
This commit is contained in:
parent
2c8ce4d586
commit
3ec096ecd9
@ -78,6 +78,7 @@
|
|||||||
class="card-image"
|
class="card-image"
|
||||||
:src="item.cover_url || item.cover_image || ''"
|
:src="item.cover_url || item.cover_image || ''"
|
||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
|
lazy-load
|
||||||
/>
|
/>
|
||||||
<!-- 前 3 名专属:包裹整个卡片的边框图 -->
|
<!-- 前 3 名专属:包裹整个卡片的边框图 -->
|
||||||
<image
|
<image
|
||||||
@ -85,6 +86,7 @@
|
|||||||
class="frame-image"
|
class="frame-image"
|
||||||
:src="TOP_FRAME_MAP[index]"
|
:src="TOP_FRAME_MAP[index]"
|
||||||
mode="scaleToFill"
|
mode="scaleToFill"
|
||||||
|
lazy-load
|
||||||
/>
|
/>
|
||||||
<!-- 前 3 名专属:左上角奖牌装饰 -->
|
<!-- 前 3 名专属:左上角奖牌装饰 -->
|
||||||
<image
|
<image
|
||||||
@ -92,6 +94,7 @@
|
|||||||
class="card-medal"
|
class="card-medal"
|
||||||
:src="MEDAL_MAP[index]"
|
:src="MEDAL_MAP[index]"
|
||||||
mode="aspectFit"
|
mode="aspectFit"
|
||||||
|
lazy-load
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
@ -156,7 +159,10 @@
|
|||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||||
import { onShow } from "@dcloudio/uni-app";
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
import { getHotRankingApi } from "@/utils/api.js";
|
import { getHotRankingApi } from "@/utils/api.js";
|
||||||
import { getAssetCoverRealUrl } from "@/utils/assetImageHelper.js";
|
import {
|
||||||
|
getAssetCoverRealUrl,
|
||||||
|
getInstantAssetCoverUrl,
|
||||||
|
} from "@/utils/assetImageHelper.js";
|
||||||
|
|
||||||
// 把后端返回的 cover_url / cover_image 转成真实可访问的 URL
|
// 把后端返回的 cover_url / cover_image 转成真实可访问的 URL
|
||||||
// 处理 3 种形态:/static/... (本地)、相对路径 (需 presign)、完整 URL (可能过期)
|
// 处理 3 种形态:/static/... (本地)、相对路径 (需 presign)、完整 URL (可能过期)
|
||||||
@ -325,6 +331,12 @@ const resetAndLoad = () => {
|
|||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
// append=false:首页加载,覆盖 items;append=true:分页追加,拼接到 items 末尾。
|
// 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 loadData = async ({ append = false } = {}) => {
|
||||||
const tab = activeTab.value;
|
const tab = activeTab.value;
|
||||||
if (!tab || typeof tab.fetch !== "function") {
|
if (!tab || typeof tab.fetch !== "function") {
|
||||||
@ -341,30 +353,56 @@ const loadData = async ({ append = false } = {}) => {
|
|||||||
try {
|
try {
|
||||||
const res = await tab.fetch(currentPage.value);
|
const res = await tab.fetch(currentPage.value);
|
||||||
if (res && res.code === 200 && res.data?.items) {
|
if (res && res.code === 200 && res.data?.items) {
|
||||||
// 逐个把 cover_url 转换成真实可访问 URL(OSS 预签名前端实现)
|
// ① 立刻准备好「能马上渲染的」items —— 不等任何网络
|
||||||
const newItems = await Promise.all(
|
const rawItems = res.data.items.map((it) => {
|
||||||
res.data.items.map(async (item) => {
|
const id = it.id || it.asset_id;
|
||||||
return await resolveItemUrls({
|
const rawCover = it.cover_url || it.cover_image || "";
|
||||||
...item,
|
return {
|
||||||
id: item.id || item.asset_id,
|
...it,
|
||||||
});
|
id,
|
||||||
}),
|
_rawCover: rawCover, // 留一份原始 URL,后台异步任务会用它去预签名
|
||||||
);
|
cover_url: getInstantAssetCoverUrl(rawCover),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ② 立即上屏
|
||||||
|
const baseOffset = append ? items.value.length : 0;
|
||||||
if (append) {
|
if (append) {
|
||||||
items.value = [...items.value, ...newItems];
|
items.value = [...items.value, ...rawItems];
|
||||||
} else {
|
} else {
|
||||||
items.value = newItems;
|
items.value = rawItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判定是否还有下一页:
|
// ③ 后台并行解析预签名 URL,单张图回来就 patch 一张
|
||||||
// 1) 接口返回 total 时,按累计长度比对;
|
// 不 await —— 不阻塞任何渲染
|
||||||
// 2) 兜底:本次返回不足一页认为到底。
|
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);
|
const total = Number(res.data.total ?? 0);
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
hasMore.value = items.value.length < total;
|
hasMore.value = items.value.length < total;
|
||||||
} else {
|
} else {
|
||||||
hasMore.value = newItems.length >= PAGE_SIZE;
|
hasMore.value = rawItems.length >= PAGE_SIZE;
|
||||||
}
|
}
|
||||||
} else if (!append) {
|
} else if (!append) {
|
||||||
items.value = [];
|
items.value = [];
|
||||||
@ -379,6 +417,9 @@ const loadData = async ({ append = false } = {}) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 占位图常量(与 helper 内部 DEFAULT_IMAGE 一致)
|
||||||
|
const PLACEHOLDER_IMAGE = "/static/nft/collection.png";
|
||||||
|
|
||||||
// 滚动到底部触发加载下一页
|
// 滚动到底部触发加载下一页
|
||||||
const handleScrollToLower = () => {
|
const handleScrollToLower = () => {
|
||||||
if (loading.value || loadingMore.value || !hasMore.value) return;
|
if (loading.value || loadingMore.value || !hasMore.value) return;
|
||||||
@ -434,7 +475,7 @@ onUnmounted(() => {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background: linear-gradient(184deg, #ff5a5d -36.55%, #c2ebff 121.2%);
|
background: linear-gradient(184deg, #ff5a5d -36.55%, #c2ebff 121.2%);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
backdrop-filter: blur(5.85px);
|
// backdrop-filter: blur(5.85px);
|
||||||
border-top-left-radius: 14px;
|
border-top-left-radius: 14px;
|
||||||
border-top-right-radius: 13px;
|
border-top-right-radius: 13px;
|
||||||
border-bottom-right-radius: 8px;
|
border-bottom-right-radius: 8px;
|
||||||
@ -467,7 +508,7 @@ onUnmounted(() => {
|
|||||||
rgba(194, 235, 255, 0.98) 184.09%
|
rgba(194, 235, 255, 0.98) 184.09%
|
||||||
);
|
);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
backdrop-filter: blur(0.9px);
|
// backdrop-filter: blur(0.9px);
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -508,7 +549,7 @@ onUnmounted(() => {
|
|||||||
rgba(76, 237, 255, 0.2) 48.19%,
|
rgba(76, 237, 255, 0.2) 48.19%,
|
||||||
rgba(255, 122, 124, 0.2) 83.71%
|
rgba(255, 122, 124, 0.2) 83.71%
|
||||||
);
|
);
|
||||||
backdrop-filter: blur(9.300000190734863px);
|
// backdrop-filter: blur(9.300000190734863px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -566,7 +607,7 @@ onUnmounted(() => {
|
|||||||
rgba(76, 237, 255, 0.2) 48.19%,
|
rgba(76, 237, 255, 0.2) 48.19%,
|
||||||
rgba(255, 122, 124, 0.2) 83.71%
|
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-left-radius: 13px;
|
||||||
border-top-right-radius: 12px;
|
border-top-right-radius: 12px;
|
||||||
border-bottom-right-radius: 12px;
|
border-bottom-right-radius: 12px;
|
||||||
@ -626,14 +667,17 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.grid-card-top-1 {
|
.grid-card-top-1 {
|
||||||
background: url("/static/square/galaxy/TOP.png") no-repeat center;
|
background: url("/static/square/galaxy/TOP.png") no-repeat center;
|
||||||
|
background-size: 100% 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-card-top-2 {
|
.grid-card-top-2 {
|
||||||
background: url("/static/square/galaxy/TOP2.png") no-repeat center;
|
background: url("/static/square/galaxy/TOP2.png") no-repeat center;
|
||||||
|
background-size: 100% 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-card-top-3 {
|
.grid-card-top-3 {
|
||||||
background: url("/static/square/galaxy/TOP3.png") no-repeat center;
|
background: url("/static/square/galaxy/TOP3.png") no-repeat center;
|
||||||
|
background-size: 100% 100%;
|
||||||
}
|
}
|
||||||
.grid-card-top-4 {
|
.grid-card-top-4 {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@ -265,10 +265,10 @@ function handleClick(item) {
|
|||||||
|
|
||||||
|
|
||||||
/* 可访问性:减少动画 */
|
/* 可访问性:减少动画 */
|
||||||
/* @media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.ring-item,
|
.ring-item,
|
||||||
.crown {
|
.crown {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
} */
|
} */ */
|
||||||
</style> -->
|
</style> -->
|
||||||
|
|||||||
@ -1,5 +1,37 @@
|
|||||||
import { getOssPresignedUrlApi } from '@/utils/api.js'
|
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提取文件名
|
* 从URL提取文件名
|
||||||
* @param {String} url - 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
|
* 获取藏品封面的真实URL - 使用 OSS 预签名 URL
|
||||||
* 处理 3 种 coverUrl 形态:
|
* 处理 3 种 coverUrl 形态:
|
||||||
@ -124,6 +182,10 @@ export async function getAssetCoverRealUrl(coverUrl) {
|
|||||||
return coverUrl || DEFAULT_IMAGE;
|
return coverUrl || DEFAULT_IMAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 命中缓存:直接返回(已在 getCoverCache 内做过期检查)
|
||||||
|
const cached = getCoverCache(coverUrl)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
// 2. 完整 URL(带 ://)
|
// 2. 完整 URL(带 ://)
|
||||||
if (coverUrl.includes('://')) {
|
if (coverUrl.includes('://')) {
|
||||||
// 检查是否带 Expires 且已过期 → 提取对象路径重签
|
// 检查是否带 Expires 且已过期 → 提取对象路径重签
|
||||||
@ -132,6 +194,7 @@ export async function getAssetCoverRealUrl(coverUrl) {
|
|||||||
try {
|
try {
|
||||||
const res = await getOssPresignedUrlApi(objectPath, 3600, 'asset')
|
const res = await getOssPresignedUrlApi(objectPath, 3600, 'asset')
|
||||||
if (res && res.data && res.data.url) {
|
if (res && res.data && res.data.url) {
|
||||||
|
setCoverCache(coverUrl, res.data.url)
|
||||||
return res.data.url
|
return res.data.url
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -139,7 +202,8 @@ export async function getAssetCoverRealUrl(coverUrl) {
|
|||||||
}
|
}
|
||||||
return objectPath // 兜底:返回相对路径
|
return objectPath // 兜底:返回相对路径
|
||||||
}
|
}
|
||||||
// 未过期:原样返回
|
// 未过期:原样返回 + 顺手缓存
|
||||||
|
setCoverCache(coverUrl, coverUrl)
|
||||||
return coverUrl
|
return coverUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,6 +211,7 @@ export async function getAssetCoverRealUrl(coverUrl) {
|
|||||||
try {
|
try {
|
||||||
const res = await getOssPresignedUrlApi(coverUrl, 3600, 'asset');
|
const res = await getOssPresignedUrlApi(coverUrl, 3600, 'asset');
|
||||||
if (res && res.data && res.data.url) {
|
if (res && res.data && res.data.url) {
|
||||||
|
setCoverCache(coverUrl, res.data.url)
|
||||||
return res.data.url;
|
return res.data.url;
|
||||||
}
|
}
|
||||||
return coverUrl;
|
return coverUrl;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user