mirror of
https://github.com/HuLaSpark/HuLa.git
synced 2026-10-25 09:52:12 +08:00
fix(emoji): 🐛 fix emoji emoji list stuttering issue
This commit is contained in:
Vendored
+9
-5
@@ -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"
|
||||
}
|
||||
Vendored
+1
-1
@@ -111,7 +111,7 @@
|
||||
"file_scan_progress": "扫描文件中",
|
||||
"usage": "扫描文件占比",
|
||||
"used_space": "已用空间",
|
||||
"app_used_space": "Hula 数据",
|
||||
"app_used_space": "HuLa 数据",
|
||||
"free_space": "可用空间",
|
||||
"directory": "目录",
|
||||
"curr_dir": "当前目录",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user