diff --git a/frontend/components/chat/MessageItem.vue b/frontend/components/chat/MessageItem.vue
index cab78d9..2a457ab 100644
--- a/frontend/components/chat/MessageItem.vue
+++ b/frontend/components/chat/MessageItem.vue
@@ -31,10 +31,14 @@
diff --git a/frontend/lib/chat/perf-logger.js b/frontend/lib/chat/perf-logger.js
new file mode 100644
index 0000000..2b8984b
--- /dev/null
+++ b/frontend/lib/chat/perf-logger.js
@@ -0,0 +1,76 @@
+const roundPerfMs = (value) => {
+ const numeric = Number(value)
+ if (!Number.isFinite(numeric)) return null
+ return Number(numeric.toFixed(1))
+}
+
+const isDesktopShell = () => {
+ if (typeof window === 'undefined') return false
+ return !!window.wechatDesktop?.__brand
+}
+
+export const nowPerfMs = () => {
+ if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
+ return performance.now()
+ }
+ return Date.now()
+}
+
+export const logPerfChannel = (channel, phase, details = {}) => {
+ const payload = { ...details }
+ if (isDesktopShell()) {
+ try {
+ window.wechatDesktop?.logDebug?.(channel, phase, payload)
+ } catch {}
+ }
+ try {
+ console.info(`[${channel}] ${phase}`, payload)
+ } catch {}
+}
+
+export const createPerfTrace = (channel, baseDetails = {}) => {
+ const traceId = `${channel}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+ const startedAt = nowPerfMs()
+ let lastAt = startedAt
+
+ return {
+ id: traceId,
+ log(phase, details = {}) {
+ const now = nowPerfMs()
+ const payload = {
+ ...baseDetails,
+ ...details,
+ traceId,
+ elapsedMs: roundPerfMs(now - startedAt),
+ deltaMs: roundPerfMs(now - lastAt)
+ }
+ lastAt = now
+ logPerfChannel(channel, phase, payload)
+ return payload
+ }
+ }
+}
+
+export const getLatestResourceTiming = (resourceUrl) => {
+ const url = String(resourceUrl || '').trim()
+ if (!url || typeof performance === 'undefined' || typeof performance.getEntriesByName !== 'function') {
+ return {}
+ }
+
+ try {
+ const entries = performance.getEntriesByName(url)
+ if (!entries?.length) return {}
+ const entry = entries[entries.length - 1]
+ return {
+ resourceDurationMs: roundPerfMs(entry.duration),
+ fetchStartMs: roundPerfMs(entry.fetchStart),
+ responseEndMs: roundPerfMs(entry.responseEnd),
+ transferSize: Number.isFinite(entry.transferSize) ? Number(entry.transferSize) : null,
+ encodedBodySize: Number.isFinite(entry.encodedBodySize) ? Number(entry.encodedBodySize) : null,
+ decodedBodySize: Number.isFinite(entry.decodedBodySize) ? Number(entry.decodedBodySize) : null,
+ initiatorType: String(entry.initiatorType || '').trim()
+ }
+ } catch {
+ return {}
+ }
+}
diff --git a/frontend/plugins/chat-media-perf.client.js b/frontend/plugins/chat-media-perf.client.js
new file mode 100644
index 0000000..5cbcb05
--- /dev/null
+++ b/frontend/plugins/chat-media-perf.client.js
@@ -0,0 +1,232 @@
+import { createPerfTrace, getLatestResourceTiming } from '~/lib/chat/perf-logger'
+
+const CHAT_LAZY_SRC_EVENT = 'chat-lazy-src:start'
+const CHAT_LAZY_ROOT_MARGIN = '240px 0px 520px 0px'
+
+const nextRenderTick = (callback) => {
+ if (typeof window === 'undefined') {
+ setTimeout(callback, 0)
+ return
+ }
+ if (typeof window.requestAnimationFrame !== 'function') {
+ window.setTimeout(callback, 0)
+ return
+ }
+ window.requestAnimationFrame(() => {
+ window.setTimeout(callback, 0)
+ })
+}
+
+const readImageSrc = (element) => {
+ return String(
+ element?.currentSrc
+ || element?.getAttribute?.('src')
+ || element?.src
+ || ''
+ ).trim()
+}
+
+const normalizeBindingValue = (value) => {
+ if (!value || typeof value !== 'object') {
+ return { kind: 'image', meta: {} }
+ }
+ return {
+ kind: String(value.kind || 'image').trim() || 'image',
+ meta: value.meta && typeof value.meta === 'object' ? { ...value.meta } : {}
+ }
+}
+
+const ensurePerfState = (element) => {
+ if (!element.__chatMediaPerfState) {
+ element.__chatMediaPerfState = {
+ src: '',
+ trace: null,
+ finalized: true,
+ onLoad: null,
+ onError: null,
+ onLazyStart: null
+ }
+ }
+ return element.__chatMediaPerfState
+}
+
+const normalizeLazySrc = (value) => {
+ if (value == null) return ''
+ if (typeof value === 'string') return value.trim()
+ if (typeof value === 'object') return String(value.src || '').trim()
+ return String(value || '').trim()
+}
+
+const ensureLazySrcState = (element) => {
+ if (!element.__chatLazySrcState) {
+ element.__chatLazySrcState = {
+ src: '',
+ loadedSrc: '',
+ observer: null,
+ timer: null
+ }
+ }
+ return element.__chatLazySrcState
+}
+
+const cleanupLazySrcObserver = (element) => {
+ const state = element?.__chatLazySrcState
+ if (!state) return
+ if (state.observer) {
+ try { state.observer.disconnect() } catch {}
+ state.observer = null
+ }
+ if (state.timer) {
+ try { clearTimeout(state.timer) } catch {}
+ state.timer = null
+ }
+}
+
+const applyLazySrc = (element, reason = '') => {
+ const state = element?.__chatLazySrcState
+ const src = String(state?.src || '').trim()
+ if (!element || !src) return
+ if (state.loadedSrc === src && readImageSrc(element) === src) return
+
+ state.loadedSrc = src
+ element.setAttribute('src', src)
+ try {
+ element.dispatchEvent(new CustomEvent(CHAT_LAZY_SRC_EVENT, {
+ detail: { src, reason }
+ }))
+ } catch {}
+}
+
+const updateLazySrc = (element, binding, reason = '') => {
+ const state = ensureLazySrcState(element)
+ const nextSrc = normalizeLazySrc(binding?.value)
+
+ cleanupLazySrcObserver(element)
+ state.src = nextSrc
+
+ if (!nextSrc) {
+ state.loadedSrc = ''
+ try { element.removeAttribute('src') } catch {}
+ return
+ }
+
+ if (state.loadedSrc !== nextSrc || readImageSrc(element) !== nextSrc) {
+ state.loadedSrc = ''
+ try { element.removeAttribute('src') } catch {}
+ try { element.setAttribute('data-chat-lazy-src', nextSrc) } catch {}
+ }
+
+ if (typeof window === 'undefined' || typeof window.IntersectionObserver !== 'function') {
+ state.timer = setTimeout(() => applyLazySrc(element, `${reason}:fallback`), 0)
+ return
+ }
+
+ state.observer = new window.IntersectionObserver((entries) => {
+ const entry = entries?.[0]
+ if (!entry?.isIntersecting) return
+ cleanupLazySrcObserver(element)
+ applyLazySrc(element, `${reason}:intersect`)
+ }, {
+ root: null,
+ rootMargin: CHAT_LAZY_ROOT_MARGIN,
+ threshold: 0.01
+ })
+ state.observer.observe(element)
+}
+
+const finalizeTracking = (element, status, reason = '') => {
+ const state = element?.__chatMediaPerfState
+ if (!state?.trace || state.finalized) return
+
+ const currentSrc = readImageSrc(element) || state.src
+ state.trace.log(status === 'load' ? 'resource:load' : 'resource:error', {
+ reason,
+ currentSrc,
+ complete: !!element?.complete,
+ naturalWidth: Number(element?.naturalWidth || 0),
+ naturalHeight: Number(element?.naturalHeight || 0),
+ ...getLatestResourceTiming(currentSrc)
+ })
+ state.finalized = true
+}
+
+const beginTracking = (element, binding, reason = '') => {
+ const state = ensurePerfState(element)
+ const src = readImageSrc(element)
+ if (!src) return
+ if (state.src === src && state.trace && !state.finalized) return
+
+ const { kind, meta } = normalizeBindingValue(binding?.value)
+ state.src = src
+ state.finalized = false
+ state.trace = createPerfTrace('chat-media-ui', {
+ kind,
+ src,
+ ...meta
+ })
+ state.trace.log('resource:start', {
+ reason,
+ complete: !!element?.complete,
+ loading: String(element?.getAttribute?.('loading') || '').trim(),
+ decoding: String(element?.getAttribute?.('decoding') || '').trim()
+ })
+
+ if (element?.complete) {
+ nextRenderTick(() => finalizeTracking(element, 'load', 'complete-sync'))
+ }
+}
+
+export default defineNuxtPlugin((nuxtApp) => {
+ nuxtApp.vueApp.directive('chat-media-perf', {
+ mounted(element, binding) {
+ const state = ensurePerfState(element)
+ state.onLoad = () => finalizeTracking(element, 'load', 'load-event')
+ state.onError = () => finalizeTracking(element, 'error', 'error-event')
+ state.onLazyStart = () => beginTracking(element, binding, 'lazy-src')
+ element.addEventListener('load', state.onLoad)
+ element.addEventListener('error', state.onError)
+ element.addEventListener(CHAT_LAZY_SRC_EVENT, state.onLazyStart)
+ beginTracking(element, binding, 'mounted')
+ },
+ updated(element, binding) {
+ const state = ensurePerfState(element)
+ const nextSrc = readImageSrc(element)
+ if (!nextSrc) return
+ if (nextSrc !== state.src) {
+ beginTracking(element, binding, 'updated-src')
+ return
+ }
+ if (element?.complete && !state.finalized) {
+ nextRenderTick(() => finalizeTracking(element, 'load', 'updated-complete'))
+ }
+ },
+ beforeUnmount(element) {
+ const state = element?.__chatMediaPerfState
+ if (state?.onLoad) element.removeEventListener('load', state.onLoad)
+ if (state?.onError) element.removeEventListener('error', state.onError)
+ if (state?.onLazyStart) element.removeEventListener(CHAT_LAZY_SRC_EVENT, state.onLazyStart)
+ if (state?.trace && !state.finalized) {
+ finalizeTracking(element, element?.complete ? 'load' : 'error', 'before-unmount')
+ }
+ delete element.__chatMediaPerfState
+ }
+ })
+
+ nuxtApp.vueApp.directive('chat-lazy-src', {
+ mounted(element, binding) {
+ updateLazySrc(element, binding, 'mounted')
+ },
+ updated(element, binding) {
+ const state = ensureLazySrcState(element)
+ const nextSrc = normalizeLazySrc(binding?.value)
+ if (nextSrc === state.src && (state.loadedSrc === nextSrc || !readImageSrc(element))) {
+ return
+ }
+ updateLazySrc(element, binding, 'updated')
+ },
+ beforeUnmount(element) {
+ cleanupLazySrcObserver(element)
+ delete element.__chatLazySrcState
+ }
+ })
+})