fix(emoji): 🐛 fix emoji emoji list stuttering issue

This commit is contained in:
Dawn
2025-12-17 19:49:34 +08:00
parent 15ca9a2c7c
commit 1c8ebfdf8a
9 changed files with 229 additions and 80 deletions
+9 -5
View File
@@ -1,10 +1,14 @@
{
"css.customData": [".vscode/tailwindcss.json"],
"css.customData": [
".vscode/tailwindcss.json"
],
"java.configuration.updateBuildConfiguration": "disabled",
"typescript.tsdk": "node_modules/typescript/lib",
"i18n-ally.autoDetection": false,
"i18n-ally.displayLanguage": "en",
"i18n-ally.displayLanguage": "zh-CN",
"i18n-ally.namespace": true,
"i18n-ally.localesPaths": [ "locales" ]
}
"i18n-ally.localesPaths": [
"locales"
],
"i18n-ally.keystyle": "nested"
}
+1 -1
View File
@@ -111,7 +111,7 @@
"file_scan_progress": "扫描文件中",
"usage": "扫描文件占比",
"used_space": "已用空间",
"app_used_space": "Hula 数据",
"app_used_space": "HuLa 数据",
"free_space": "可用空间",
"directory": "目录",
"curr_dir": "当前目录",
+180 -61
View File
@@ -1,9 +1,10 @@
<template>
<n-scrollbar
ref="panelScrollbarRef"
style="max-height: 290px"
:class="[isMobile() ? 'h-15rem w-auto' : 'h-290px w-460px']"
class="p-[14px_14px_0_14px] box-border select-none"
@scroll="activeMenuId = ''">
@scroll="handlePanelScroll">
<transition name="fade" mode="out-in">
<div :key="activeIndex" class="emoji-content">
<!-- 最近使用 -->
@@ -45,31 +46,40 @@
</div>
<!-- 表情包系列 -->
<div v-else-if="currentSeries">
<div v-else-if="currentSeries" class="series-virtual-wrapper">
<span class="text-12px text-[--text-color]">{{ currentSeries.name }}</span>
<n-flex align="center" :class="isMobile() ? 'emoji-pack-grid-mobile mx-6px my-12px' : 'mx-6px my-12px'">
<n-flex
align="center"
justify="center"
class="emoji-item"
v-for="(item, index) in currentSeries.emojis"
:key="index"
@click.stop="
chooseEmoji(
{
renderUrl: item.url,
serverUrl: item.url
},
'url'
)
">
<n-image
:title="item.name"
preview-disabled
:src="item.url"
class="size-full object-contain rounded-8px transition duration-300 ease-in-out transform-gpu" />
</n-flex>
</n-flex>
<div class="mt-12px flex flex-col gap-2">
<div
class="emoji-pack-row"
v-for="row in displaySeriesRows"
:key="row.key"
:style="{
gridTemplateColumns: `repeat(${packColumns}, 1fr)`,
gap: isMobile() ? '8px' : '12px'
}">
<div
class="emoji-item emoji-item--image"
v-for="(emojiItem, index) in row.emojis"
:key="index"
@click.stop="
chooseEmoji(
{
renderUrl: emojiItem.url,
serverUrl: emojiItem.url
},
'url'
)
">
<img
:alt="emojiItem.name"
:title="emojiItem.name"
:src="emojiItem.url"
loading="lazy"
decoding="async"
class="emoji-image size-full object-contain rounded-8px transition duration-300 ease-in-out transform-gpu" />
</div>
</div>
</div>
</div>
<!-- 我的喜欢页面 -->
@@ -80,8 +90,8 @@
<n-flex
align="center"
justify="center"
class="emoji-item py-4px"
v-for="(item, index) in reversedEmojiList"
class="emoji-item emoji-item--image py-4px"
v-for="(item, index) in displayFavoriteEmojis"
:key="index"
@click.stop="
chooseEmoji(
@@ -110,7 +120,7 @@
preview-disabled
:src="getEmojiRenderUrl(item)"
@contextmenu.prevent="handleContextMenu($event, item)"
class="size-full object-contain rounded-8px transition duration-300 ease-in-out transform-gpu" />
class="emoji-image size-full object-contain rounded-8px transition duration-300 ease-in-out transform-gpu" />
</div>
</template>
<n-button quaternary size="tiny" @click.stop="deleteMyEmoji(item.id)">
@@ -165,6 +175,7 @@ import { convertFileSrc } from '@tauri-apps/api/core'
import { appDataDir, join, resourceDir } from '@tauri-apps/api/path'
import { BaseDirectory, exists, writeFile } from '@tauri-apps/plugin-fs'
import HulaEmojis from 'hula-emojis'
import type { ScrollbarInst } from 'naive-ui'
import pLimit from 'p-limit'
import type { EmojiItem as EmojiListItem } from '@/services/types'
import { useIntersectionTaskQueue } from '@/hooks/useIntersectionTaskQueue'
@@ -214,7 +225,8 @@ const { t } = useI18n()
/** 获取米游社的表情包 */
const emojisBbs = HulaEmojis.MihoyoBbs
const activeIndex = ref(lastEmojiTabIndex)
const currentSeriesIndex = ref(0)
const isFavoritesView = computed(() => activeIndex.value === -1)
const panelScrollbarRef = ref<ScrollbarInst | null>(null)
// 设置当前右键点击的表情项ID
const activeMenuId = ref('')
const emojiLocalPathMap = ref<Record<string, string>>({})
@@ -267,12 +279,78 @@ const tabList = computed<TabItem[]>(() => {
})
const currentSeries = computed(() => (activeIndex.value > 0 ? emojisBbs.series[activeIndex.value - 1] : null))
const packColumns = computed(() => (isMobile() ? 4 : 6))
const SERIES_PAGE_SIZE = 30
const favoritesPage = ref(1)
const seriesPage = ref(1)
const favoritesPageSize = computed(() => (isMobile() ? 20 : 25))
// 将"我的表情包"列表倒序显示
const reversedEmojiList = computed(() => {
return [...emojiStore.emojiList].reverse()
})
const displayFavoriteEmojis = computed(() => {
const size = favoritesPage.value * favoritesPageSize.value
return reversedEmojiList.value.slice(0, size)
})
const displaySeriesRows = computed(() => {
if (!currentSeries.value) return []
const cols = packColumns.value
const size = seriesPage.value * SERIES_PAGE_SIZE
const visibleEmojiCount = Math.min(currentSeries.value.emojis.length, size)
const rows: { key: number; emojis: (typeof currentSeries.value.emojis)[number][] }[] = []
for (let i = 0; i < visibleEmojiCount; i += cols) {
rows.push({ key: i, emojis: currentSeries.value.emojis.slice(i, i + cols) })
}
return rows
})
const SCROLL_LOAD_MORE_THRESHOLD = 32
const isNearBottom = (target: HTMLElement) =>
target.scrollTop + target.clientHeight >= target.scrollHeight - SCROLL_LOAD_MORE_THRESHOLD
const favoritesLoadMoreLock = ref(false)
const seriesLoadMoreLock = ref(false)
const loadMoreFavorites = async () => {
if (favoritesLoadMoreLock.value) return
if (displayFavoriteEmojis.value.length >= reversedEmojiList.value.length) return
favoritesLoadMoreLock.value = true
favoritesPage.value += 1
await nextTick()
favoritesLoadMoreLock.value = false
}
const loadMoreSeries = async () => {
if (seriesLoadMoreLock.value) return
if (!currentSeries.value) return
if (seriesPage.value * SERIES_PAGE_SIZE >= currentSeries.value.emojis.length) return
seriesLoadMoreLock.value = true
seriesPage.value += 1
await nextTick()
seriesLoadMoreLock.value = false
}
const handlePanelScroll = (event: Event) => {
activeMenuId.value = ''
const target =
(panelScrollbarRef.value as any)?.containerRef ||
(event.target as HTMLElement | null) ||
(event.currentTarget as HTMLElement | null)
if (!target) return
if (isFavoritesView.value) {
if (isNearBottom(target)) {
void loadMoreFavorites()
}
return
}
if (currentSeries.value && isNearBottom(target)) {
void loadMoreSeries()
}
}
const res = getAllTypeEmojis()
const emojiObj = ref<EmojiType>({
@@ -303,6 +381,9 @@ const emojiRef = reactive<{
// 只在支持 window/Worker 的环境下按需创建 emoji 缓存 Worker,并在全局复用
const getEmojiWorker = () => {
if (!isFavoritesView.value) {
return null
}
if (typeof window === 'undefined') {
return null
}
@@ -338,15 +419,21 @@ const resolveEmojiExtension = async (url: string) => {
if (emojiExtCache.has(url)) {
return emojiExtCache.get(url)!
}
const inferred = inferExtFromUrl(url)
if (inferred) {
const ext = inferred.toLowerCase()
emojiExtCache.set(url, ext)
return ext
}
let ext = ''
try {
const info = await detectRemoteFileType({ url, fileSize: 1 })
const info = await detectRemoteFileType({ url, fileSize: null })
ext = info?.ext || ''
} catch (error) {
console.warn('识别表情类型失败:', error)
}
if (!ext) {
ext = inferExtFromUrl(url) || 'png'
ext = 'png'
}
emojiExtCache.set(url, ext)
return ext
@@ -374,6 +461,7 @@ const setEmojiLocalPath = (id: string, absolutePath: string, expressionUrl?: str
}
const ensureEmojiCacheEnvironment = async () => {
if (!isFavoritesView.value) return null
const uid = userStore.userInfo?.uid
if (!uid) {
return null
@@ -482,6 +570,18 @@ const cleanupEmojiObservers = (validIds: string[]) => {
})
}
const cleanupAllEmojiCaches = () => {
emojiLocalPathMap.value = {}
emojiExtCache.clear()
localUrlCache.clear()
emojiUrlToLocalMap.clear()
emojiVisibilityTargetMap.forEach((el) => unobserveEmojiVisibility(el))
emojiVisibilityTargetMap.clear()
cachingEmojiIds.clear()
downloadingUrls.clear()
emojiCacheEnv.value = null
}
// 只有当收藏项真正出现在视口内时才执行缓存下载
const handleEmojiVisibility = async (emojiItem: EmojiListItem) => {
const id = emojiItem.id
@@ -537,6 +637,7 @@ const ensureEmojiCached = async (
// 将 store 中已有表情与本地缓存对齐,优先使用本地链接渲染
const hydrateEmojiLocalCache = async () => {
if (!isFavoritesView.value) return
const env = await ensureEmojiCacheEnvironment()
if (!env) return
const downloadTasks: Promise<unknown>[] = []
@@ -580,6 +681,7 @@ const hydrateEmojiLocalCache = async () => {
watch(
() => emojiStore.emojiList.map((item) => ({ id: item.id, url: item.expressionUrl })),
(list) => {
if (!isFavoritesView.value) return
const ids = list.map((item) => item.id)
cleanupLocalEmojiMap(ids)
cleanupEmojiObservers(ids)
@@ -592,16 +694,10 @@ watch(
watch(
() => userStore.userInfo?.uid,
() => {
emojiLocalPathMap.value = {}
emojiCacheEnv.value = null
cachingEmojiIds.clear()
emojiVisibilityTargetMap.forEach((el) => {
unobserveEmojiVisibility(el)
})
emojiVisibilityTargetMap.clear()
cleanupAllEmojiCaches()
disconnectEmojiObserver()
// 切换账号后如已有列表,立即尝试用本地缓存替换链接
if (emojiStore.emojiList.length > 0 && userStore.userInfo?.uid) {
// 切换账号后如已有列表,且当前在我的喜欢视图时,再尝试用本地缓存替换链接
if (emojiStore.emojiList.length > 0 && userStore.userInfo?.uid && isFavoritesView.value) {
void hydrateEmojiLocalCache()
}
},
@@ -681,6 +777,10 @@ const chooseEmoji = async (item: any, type: 'emoji' | 'url' = 'emoji') => {
serverUrl: item.serverUrl || item.expressionUrl || ''
}
: { renderUrl: typeof item === 'string' ? item : '', serverUrl: typeof item === 'string' ? item : '' }
if (!isFavoritesView.value) {
emit('emojiHandle', payload, 'emoji-url')
return payload
}
try {
const local = await ensureLocalByServerUrl(payload.serverUrl || payload.renderUrl, payload.id)
if (local) {
@@ -711,9 +811,10 @@ const getEmojiRenderUrl = (item: EmojiListItem) => {
}
// 确保某个服务端 URL 有本地副本,并返回可用于渲染的本地链接
const ensureLocalByServerUrl = async (serverUrl: string, id?: string) => {
const ensureLocalByServerUrl = async (serverUrl: string, id?: string, options?: { skipCache?: boolean }) => {
try {
if (!serverUrl) return null
if (options?.skipCache) return null
if (emojiUrlToLocalMap.has(serverUrl)) return emojiUrlToLocalMap.get(serverUrl)!
const env = await ensureEmojiCacheEnvironment()
if (!env) return null
@@ -768,8 +869,26 @@ const ensureLocalByServerUrl = async (serverUrl: string, id?: string) => {
*/
const handleTabChange = (index: number) => {
activeIndex.value = index
if (index === 1) {
currentSeriesIndex.value = 0
if (index === -1) {
favoritesPage.value = 1
}
if (index > 0) {
seriesPage.value = 1
}
void nextTick().then(() => {
panelScrollbarRef.value?.scrollTo({ top: 0 })
})
// 切换到非“我的喜欢”视图时,立即清理缓存相关状态
if (index !== -1) {
cleanupAllEmojiCaches()
disconnectEmojiObserver()
if (emojiCacheWorker) {
emojiCacheWorker.terminate()
emojiCacheWorker = null
}
} else {
// 切回“我的喜欢”时尝试同步本地缓存
void hydrateEmojiLocalCache()
}
setLastEmojiTabIndex(index)
}
@@ -778,19 +897,15 @@ const handleTabChange = (index: number) => {
* 选择表情包系列
*/
const selectSeries = (index: number) => {
currentSeriesIndex.value = index
activeIndex.value = index + 1
setLastEmojiTabIndex(index + 1)
handleTabChange(index + 1)
}
onMounted(async () => {
// 获取我的表情包列表
await emojiStore.getEmojiList()
// 表情列表加载后立即尝试使用本地缓存
await hydrateEmojiLocalCache()
// 如果上次选择的是表情包系列,设置正确的currentSeriesIndex
if (activeIndex.value > 0) {
currentSeriesIndex.value = activeIndex.value - 1
// 仅在“我的喜欢”视图时才尝试使用本地缓存,避免系列表情也走缓存逻辑
if (isFavoritesView.value) {
await hydrateEmojiLocalCache()
}
})
@@ -800,6 +915,7 @@ onBeforeUnmount(() => {
emojiCacheWorker.terminate()
emojiCacheWorker = null
}
cleanupAllEmojiCaches()
})
</script>
@@ -817,18 +933,14 @@ onBeforeUnmount(() => {
.emoji-item {
@apply cursor-pointer;
@apply size-36px text-26px hover:bg-[--emoji-hover] rounded-8px;
}
// 默认表情的样式
&:not(:has(.n-image)) {
@apply size-36px text-26px hover:bg-[--emoji-hover] rounded-8px;
}
.emoji-item--image {
@apply size-60px hover:bg-transparent;
// 米游社表情包的样式
&:has(.n-image) {
@apply size-60px;
&:hover .n-image {
@apply hover:scale-116 bg-[--emoji-hover] rounded-8px;
}
&:hover .emoji-image {
@apply scale-116 bg-[--emoji-hover] rounded-8px;
}
}
@@ -881,6 +993,13 @@ onBeforeUnmount(() => {
width: 100%;
}
.emoji-pack-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
padding: 6px;
}
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
+2 -2
View File
@@ -27,7 +27,7 @@
<div class="cursor-pointer" @click="showAddFeedModal = true">
<n-popover trigger="hover">
<template #trigger>
<svg class="size-22px color-white text-white">
<svg class="size-23px color-white text-white">
<use href="#plus"></use>
</svg>
</template>
@@ -38,7 +38,7 @@
<div class="cursor-pointer" @click="handleRefresh">
<n-popover trigger="hover">
<template #trigger>
<svg class="size-16px color-white"><use href="#refresh"></use></svg>
<svg class="size-17px color-white"><use href="#refresh"></use></svg>
</template>
<span>{{ t('dynamic.list.actions.refresh') }}</span>
</n-popover>
+6 -3
View File
@@ -85,14 +85,17 @@ export const useEmojiStore = defineStore(StoresEnum.EMOJI, () => {
}
const resolveEmojiExt = async (url: string) => {
const match = url.match(/\\.([a-zA-Z0-9]+)(?:\\?|$)/)
if (match?.[1]) {
return match[1].toLowerCase()
}
try {
const info = await detectRemoteFileType({ url, fileSize: 1 })
const info = await detectRemoteFileType({ url, fileSize: null })
if (info?.ext) return info.ext
} catch (error) {
console.warn('[emoji] 识别表情类型失败:', error)
}
const match = url.match(/\\.([a-zA-Z0-9]+)(?:\\?|$)/)
return match?.[1] || 'png'
return 'png'
}
const buildFileName = async (url: string) => {
+11 -2
View File
@@ -4,6 +4,7 @@ import { appCacheDir } from '@tauri-apps/api/path'
import { defineStore } from 'pinia'
import { StoresEnum } from '@/enums'
import { ErrorType, invokeSilently, invokeWithErrorHandler } from '@/utils/TauriInvokeHandler.ts'
import { getUserDataRootAbsoluteDir } from '@/utils/PathUtil'
type DirectoryScanProgress = {
current_path: string
@@ -100,8 +101,7 @@ export const useScannerStore = defineStore(StoresEnum.SCANNER, () => {
try {
// 获取默认目录
const cacheDir = await appCacheDir()
defaultDirectory.value = cacheDir
await setDefaultDirectory()
// 设置事件监听器
await setupEventListeners()
@@ -118,6 +118,15 @@ export const useScannerStore = defineStore(StoresEnum.SCANNER, () => {
}
}
const setDefaultDirectory = async () => {
try {
defaultDirectory.value = await getUserDataRootAbsoluteDir()
} catch (error) {
console.error('获取 userData 根目录失败,回退到 appCacheDir:', error)
defaultDirectory.value = await appCacheDir()
}
}
const setPathType = (type: 'default' | 'custom') => {
pathType.value = type
}
+5 -1
View File
@@ -73,7 +73,11 @@ export const useThumbnailCacheStore = defineStore(
}
const decideExt = async (url: string) => {
const info = await detectRemoteFileType({ url, fileSize: 1 })
const match = url.match(/\\.([a-zA-Z0-9]+)(?:\\?|$)/)
if (match?.[1]) {
return match[1].toLowerCase()
}
const info = await detectRemoteFileType({ url, fileSize: null })
return info?.ext ? info.ext : 'jpg'
}
+14 -4
View File
@@ -14,6 +14,13 @@ const EMOJIS_DIR = 'emojis'
// AI 生成资源目录名
const AI_DIR = 'ai'
// 获取 userData 根目录
export const getUserDataRootAbsoluteDir = async (): Promise<string> => {
await ensureUserDataRoot()
const baseDirPath = isMobile() ? await appDataDir() : await resourceDir()
return await join(baseDirPath, USER_DATA)
}
// 远程文件类型探测结果与进行中的 Promise 缓存,避免重复请求同一资源
const remoteFileTypeResultCache = new Map<string, FileTypeResult | undefined>()
const remoteFileTypePromiseCache = new Map<string, Promise<FileTypeResult | undefined>>()
@@ -229,7 +236,7 @@ export const ensureModelFile = async (fileName: string, remoteUrl: string): Prom
*/
export async function detectRemoteFileType(options: {
url: string
fileSize: number
fileSize?: number | null
byteLength?: number
}): Promise<FileTypeResult | undefined> {
const { url } = options
@@ -246,7 +253,7 @@ export async function detectRemoteFileType(options: {
const task = (async () => {
try {
const { url, byteLength = 4100, fileSize } = options
const { url, byteLength = 4100 } = options
// 1. 先发送 HEAD 请求,检查文件是否存在及大小
const headResponse = await fetch(url, { method: 'HEAD' })
@@ -256,8 +263,11 @@ export async function detectRemoteFileType(options: {
throw new Error(`文件不存在, 状态: ${headResponse.status}`)
}
const contentLengthHeader = headResponse.headers.get('content-length')
const resolvedFileSize = options.fileSize ?? (contentLengthHeader ? Number(contentLengthHeader) : null)
// 2. 如果是空文件,直接返回 undefined
if (fileSize === 0) {
if (resolvedFileSize === 0) {
console.log('文件大小为 0 字节,尝试使用后缀名检测')
try {
const result = await invoke<FilesMeta>('get_files_meta', { filesPath: [url] })
@@ -274,7 +284,7 @@ export async function detectRemoteFileType(options: {
}
// 3. 如果文件大小 < byteLength,直接 GET 整个文件,避免 Range 错误
const shouldUseRange = fileSize === null || fileSize >= byteLength
const shouldUseRange = resolvedFileSize === null || resolvedFileSize >= byteLength
const rangeEnd = shouldUseRange ? byteLength - 1 : void 0
const response = await fetch(url, shouldUseRange ? { headers: { Range: `bytes=0-${rangeEnd}` } } : void 0)
@@ -126,7 +126,7 @@
<n-flex align="center" justify="space-between">
<span>{{ t('setting.storage.curr_dir') }}</span>
<n-flex vertical align="end" :size="8">
<span class="text-(12px #666) max-w-300px truncate">
<span :title="currentDirectory" class="text-(12px #666) max-w-300px truncate cursor-default">
{{ currentDirectory || t('setting.storage.fetching_directory') }}
</span>
<n-flex :size="8">