diff --git a/package.json b/package.json index ac7da892..2954e90b 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,8 @@ "vue-cropper": "1.1.4", "vue-demi": "0.14.10", "vue-router": "^4.6.3", - "vue-virtual-scroller": "2.0.0-beta.8" + "vue-virtual-scroller": "2.0.0-beta.8", + "web-vitals": "^5.1.0" }, "devDependencies": { "@biomejs/biome": "^2.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1878b38..9e84c55d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: vue-virtual-scroller: specifier: 2.0.0-beta.8 version: 2.0.0-beta.8(vue@3.5.22(typescript@5.9.3)) + web-vitals: + specifier: ^5.1.0 + version: 5.1.0 devDependencies: '@biomejs/biome': specifier: ^2.2.7 @@ -4678,6 +4681,9 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-vitals@5.1.0: + resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -9146,6 +9152,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-vitals@5.1.0: {} + webpack-virtual-modules@0.6.2: {} whatwg-mimetype@3.0.0: {} diff --git a/src-tauri/src/command/message_command.rs b/src-tauri/src/command/message_command.rs index c97bc16d..6b6322eb 100644 --- a/src-tauri/src/command/message_command.rs +++ b/src-tauri/src/command/message_command.rs @@ -113,12 +113,16 @@ pub async fn page_msg( .map_err(|e| e.to_string())?; // 转换数据库模型为响应模型 - let message_resps: Vec = db_result - .list - .unwrap_or_default() + let mut raw_list = db_result.list.unwrap_or_default(); + raw_list.sort_by(|a, b| { + let a_time = a.send_time.unwrap_or(0); + let b_time = b.send_time.unwrap_or(0); + a_time.cmp(&b_time) + }); + + let message_resps: Vec = raw_list .into_iter() .map(|msg| convert_message_to_resp(msg, None)) - .rev() .collect(); Ok(CursorPageResp { diff --git a/src/components/rightBox/chatBox/ChatMain.vue b/src/components/rightBox/chatBox/ChatMain.vue index 11dfc19e..15e8beda 100644 --- a/src/components/rightBox/chatBox/ChatMain.vue +++ b/src/components/rightBox/chatBox/ChatMain.vue @@ -636,7 +636,6 @@ const handleChatAreaClick = (event: Event): void => { // 防冲突的加载更多处理 const handleLoadMore = async (): Promise => { // 如果正在加载、已经触发了加载、或已到达最后一页,则不重复触发 - console.log(chatStore.currentMessageOptions) if (chatStore.currentMessageOptions?.isLoading || isLoadingMore.value || chatStore.currentMessageOptions?.isLast) return diff --git a/src/components/rightBox/renderMessage/Image.vue b/src/components/rightBox/renderMessage/Image.vue index 555b1795..b3fd290a 100644 --- a/src/components/rightBox/renderMessage/Image.vue +++ b/src/components/rightBox/renderMessage/Image.vue @@ -11,8 +11,10 @@ object-fit="cover" show-toolbar-tooltip preview-disabled + lazy + :intersection-observer-options="{ rootMargin: '256px' }" style="border-radius: 8px; cursor: pointer !important" - :src="body?.url" + :src="displayImageSrc" @dblclick="handleOpenImageViewer" @click="handleOpenImage" @error="handleImageError"> @@ -54,6 +56,7 @@ import { MsgEnum } from '@/enums' import { useImageViewer } from '@/hooks/useImageViewer' import type { ImageBody, MsgType } from '@/services/types' import { isMobile } from '@/utils/PlatformConstants' +import { buildQiniuThumbnailUrl, getPreferredQiniuFormat } from '@/utils/QiniuImageUtils' const ImagePreview = isMobile() ? defineAsyncComponent(() => import('@/mobile/components/ImagePreview.vue')) : void 0 @@ -67,6 +70,7 @@ const MAX_WIDTH = isMobile() ? 240 : 320 const MAX_HEIGHT = 240 const MIN_WIDTH = 60 const MIN_HEIGHT = 60 +const THUMB_QUALITY = 60 // 错误状态控制 const isError = ref(false) // 使用图片查看器hook @@ -107,6 +111,23 @@ const handleOpenImageViewer = () => { /** * 计算图片样式 */ +const displayImageSrc = computed(() => { + const originalUrl = props.body?.url + if (!originalUrl) return '' + + const deviceRatio = typeof window !== 'undefined' ? Math.max(window.devicePixelRatio || 1, 1) : 1 + const thumbnailWidth = Math.ceil(MAX_WIDTH * Math.min(deviceRatio, 2)) + const format = getPreferredQiniuFormat() + + return ( + buildQiniuThumbnailUrl(originalUrl, { + width: thumbnailWidth, + quality: THUMB_QUALITY, + format + }) ?? originalUrl + ) +}) + const imageStyle = computed(() => { // 如果有原始尺寸,使用原始尺寸计算 let width = props.body?.width diff --git a/src/main.ts b/src/main.ts index 31250cab..2a27103c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,11 +7,13 @@ import vSlide from '@/directives/v-slide.ts' import router from '@/router' import { pinia } from '@/stores' import { initializePlatform } from '@/utils/PlatformConstants' +import { startWebVitalObserver } from '@/utils/WebVitalsObserver' import { invoke } from '@tauri-apps/api/core' import { isMobile } from '@/utils/PlatformConstants' import App from '@/App.vue' initializePlatform() +startWebVitalObserver() import('@/services/webSocketAdapter') if (process.env.NODE_ENV === 'development') { diff --git a/src/stores/group.ts b/src/stores/group.ts index c0b379a5..7a1fd331 100644 --- a/src/stores/group.ts +++ b/src/stores/group.ts @@ -400,6 +400,14 @@ export const useGroupStore = defineStore( } } + // 校验合法的群聊/频道才会触发成员刷新 + const isValidGroupRoom = (roomId?: string | null): roomId is string => { + if (!roomId) return false + if (roomId === '1') return true + const session = chatStore.getSession(roomId) + return session?.type === RoomTypeEnum.GROUP + } + /** * 获取群成员列表 * @param roomId 群聊房间ID @@ -407,6 +415,12 @@ export const useGroupStore = defineStore( */ const getGroupUserList = async (roomId: string, forceRefresh = false, keyword = '') => { const trimmedKeyword = keyword.trim() + + if (!isValidGroupRoom(roomId)) { + console.warn('[group] skip member refresh, invalid room id:', roomId) + return [] + } + const cachedList = userListMap[roomId] if (!trimmedKeyword && !forceRefresh && Array.isArray(cachedList) && cachedList.length > 0) { @@ -474,12 +488,13 @@ export const useGroupStore = defineStore( if (roomId === 'all') { getRoomIdsByUid(uid).forEach((room) => { - if (room) refreshTargets.add(room) + if (isValidGroupRoom(room)) { + refreshTargets.add(room) + } }) } else { const targetRoomId = roomId || globalStore.currentSession?.roomId - if (!targetRoomId) { - console.warn('[updateUserItem] cannot determine target room id') + if (!isValidGroupRoom(targetRoomId)) { return false } refreshTargets.add(targetRoomId) @@ -489,8 +504,8 @@ export const useGroupStore = defineStore( return false } - refreshTargets.forEach((roomId) => { - getGroupUserList(roomId, true).catch((error) => { + refreshTargets.forEach((validRoomId) => { + getGroupUserList(validRoomId, true).catch((error) => { console.error('[group] refresh members failed:', error) }) }) @@ -512,7 +527,7 @@ export const useGroupStore = defineStore( } const targetRoomId = roomId || globalStore.currentSession?.roomId - if (!targetRoomId) { + if (!isValidGroupRoom(targetRoomId)) { console.warn('[addUserItem] cannot determine target room id') return false } @@ -537,7 +552,7 @@ export const useGroupStore = defineStore( } const targetRoomId = roomId || globalStore.currentSession?.roomId - if (!targetRoomId) { + if (!isValidGroupRoom(targetRoomId)) { console.warn('[removeUserItem] cannot determine target room id') return false } @@ -701,7 +716,7 @@ export const useGroupStore = defineStore( } // 检查当前房间是否包含指定的用户 const hasUser = userList.some((user) => user.uid === uid) - if (hasUser) { + if (hasUser && isValidGroupRoom(roomId)) { roomIds.push(roomId) } }) diff --git a/src/utils/QiniuImageUtils.ts b/src/utils/QiniuImageUtils.ts new file mode 100644 index 00000000..f9cfc094 --- /dev/null +++ b/src/utils/QiniuImageUtils.ts @@ -0,0 +1,90 @@ +const QINIU_HOST_KEYWORDS = ['cdn.hulaspark.com'] + +const isQiniuHost = (hostname: string) => { + const lowerHost = hostname.toLowerCase() + return QINIU_HOST_KEYWORDS.some((keyword) => lowerHost.includes(keyword)) +} + +export type QiniuImageFormat = 'webp' | 'avif' + +export type QiniuThumbOptions = { + width?: number + height?: number + quality?: number + format?: QiniuImageFormat +} + +export const buildQiniuThumbnailUrl = (url?: string | null, options: QiniuThumbOptions = {}): string | undefined => { + if (!url) return url ?? undefined + + try { + const parsedUrl = new URL(url) + if (!isQiniuHost(parsedUrl.hostname)) { + return url + } + + // 如果已经带有 imageView/imageMogr 等后处理参数,直接返回原链接 + const existingQuery = parsedUrl.search.toLowerCase() + if ( + existingQuery.includes('imageview') || + existingQuery.includes('imagemogr') || + existingQuery.includes('imageview2') + ) { + return url + } + + const { width, height, quality = 75 } = options + const segments = ['imageView2', '2'] + if (width) segments.push('w', Math.round(width).toString()) + if (height) segments.push('h', Math.round(height).toString()) + if (quality) segments.push('q', Math.round(quality).toString()) + if (options.format) segments.push('format', options.format) + + const directive = segments.join('/') + const hasQuery = parsedUrl.search.length > 0 + parsedUrl.search += `${hasQuery ? '&' : '?'}${directive}` + + return parsedUrl.toString() + } catch { + return url + } +} + +let preferredFormat: QiniuImageFormat | null | undefined + +const canUseDataUrl = (type: string) => { + if (typeof document === 'undefined') return false + const canvas = document.createElement('canvas') + if (!canvas.getContext) return false + try { + return canvas.toDataURL(type).startsWith(`data:${type}`) + } catch { + return false + } +} + +const detectPreferredFormat = (): QiniuImageFormat | undefined => { + try { + if (canUseDataUrl('image/avif')) { + return 'avif' + } + if (canUseDataUrl('image/webp')) { + return 'webp' + } + } catch { + // ignore detection errors + } + return void 0 +} + +export const getPreferredQiniuFormat = (): QiniuImageFormat | undefined => { + if (preferredFormat === null) { + return undefined + } + + if (preferredFormat === undefined) { + preferredFormat = detectPreferredFormat() ?? null + } + + return preferredFormat === null ? undefined : preferredFormat +} diff --git a/src/utils/WebVitalsObserver.ts b/src/utils/WebVitalsObserver.ts new file mode 100644 index 00000000..fb5207b3 --- /dev/null +++ b/src/utils/WebVitalsObserver.ts @@ -0,0 +1,63 @@ +import type { Metric } from 'web-vitals' +import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals' + +type WebVitalMetric = + | (Metric & { type: 'web-vital' }) + | { + type: 'longtask' + name: string + startTime: number + duration: number + attribution?: Record + } + +type Reporter = (metric: WebVitalMetric) => void + +const defaultReporter: Reporter = (metric) => { + const label = metric.type === 'web-vital' ? metric.name : 'longtask' + console.info('[performance]', label, metric) +} + +let hasStarted = false + +export const startWebVitalObserver = (reporter: Reporter = defaultReporter) => { + if (hasStarted || typeof window === 'undefined') return + hasStarted = true + + const report = (metric: Metric) => { + reporter({ + ...metric, + type: 'web-vital' + }) + } + + onCLS(report, { reportAllChanges: true }) + onFCP(report) + onINP(report, { reportAllChanges: true }) + onLCP(report, { reportAllChanges: true }) + onTTFB(report) + + if ( + 'PerformanceObserver' in window && + Array.isArray((PerformanceObserver as any).supportedEntryTypes) && + (PerformanceObserver as any).supportedEntryTypes.includes('longtask') + ) { + const observer = new PerformanceObserver((entryList) => { + for (const entry of entryList.getEntries()) { + reporter({ + type: 'longtask', + name: entry.name || 'longtask', + startTime: entry.startTime, + duration: entry.duration, + attribution: (entry as any).attribution + }) + } + }) + + try { + observer.observe({ type: 'longtask', buffered: true }) + } catch (error) { + console.warn('[performance] longtask observer failed:', error) + } + } +}