/** * 圖片優化管理器 * 整合所有圖片相關功能:優化、載入、重試、快取、縮圖轉換 * * 功能包括: * - 智慧載入(重試、超時、備援) * - 圖片快取 * - 縮圖 URL 轉換 * - 響應式圖片 * - DOM 自動監聽 * - 載入動畫 */ class UltimateImageOptimizer { constructor(options = {}) { // 基礎設定 this.preloadedImages = new Map(); this.loadingImages = new Set(); this.observers = new Map(); this.loadingAttempts = new Map(); // 重試和超時設定 this.maxRetries = options.maxRetries || 2; this.retryDelay = options.retryDelay || 500; this.timeout = options.timeout || 10000; this.defaultImage = options.defaultImage || '/static/img/dog1.png'; // 縮圖 API 端點 this.thumbnailEndpoint = options.thumbnailEndpoint || '/thumbnail/'; // 預設尺寸映射 this.defaultSizeMap = { '.pet-thumbnail': { width: 150 }, '.pet-main-image': { width: 450 }, '.random-pet-image': { width: 400 }, '.more-pet-card-image': { width: 300 }, '.dog-card img': { width: 400 }, '.card-img-top': { width: 400 }, 'img[data-thumbnail]': { width: 300 } }; this.init(); } init() { this.addGlobalStyles(); this.observeDOMChanges(); // 頁面載入完成後自動處理 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { this.autoConvertToThumbnails(); }); } else { this.autoConvertToThumbnails(); } } /** * 添加全域 CSS 樣式 */ addGlobalStyles() { if (document.getElementById('image-optimizer-styles')) { return; } const style = document.createElement('style'); style.id = 'image-optimizer-styles'; style.textContent = ` /* 圖片容器載入樣式 */ .img-loading-container { position: relative; background: linear-gradient(135deg, #f5f5f5 0%, #e0e0e0 100%); overflow: hidden; } .img-loading-container::after { content: ''; position: absolute; top: 50%; left: 50%; width: 40px; height: 40px; margin: -20px 0 0 -20px; border: 4px solid rgba(0, 71, 103, 0.2); border-top-color: #004767; border-radius: 50%; animation: img-spin 1s linear infinite; z-index: 1; pointer-events: none; } .img-loading-container.img-loaded::after { display: none; } @keyframes img-spin { to { transform: rotate(360deg); } } /* 圖片載入狀態 */ .img-fade-in, img.loading { opacity: 0.5; transition: opacity 0.3s ease; } .img-fade-in.img-loaded, img.loaded { opacity: 1; animation: fadeIn 0.3s ease-in; } img.error { opacity: 0.7; filter: grayscale(30%); } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } /* 脈動動畫 */ img.loading { animation: pulse 1.5s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 0.8; } } /* 骨架屏載入動畫 */ .img-skeleton { background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: img-skeleton-loading 1.5s ease-in-out infinite; } @keyframes img-skeleton-loading { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } `; document.head.appendChild(style); } /** * 將圖片 URL 轉換為縮圖 URL * @param {string} imageUrl - 原始圖片 URL * @param {Object} options - 選項 {width, height, quality} * @returns {string} 縮圖 URL */ getThumbnailUrl(imageUrl, options = {}) { if (!imageUrl) { return this.defaultImage; } // 如果是預設圖片,直接返回 if (imageUrl.includes('/static/img/') && !imageUrl.includes('/uploads/')) { return imageUrl; } // 如果已經是縮圖 URL,直接返回 if (imageUrl.includes(this.thumbnailEndpoint)) { return imageUrl; } const { width = 200, height = null, quality = 85 } = options; // 提取檔案路徑 let filePath = imageUrl; // 移除 domain if (filePath.startsWith('http://') || filePath.startsWith('https://')) { const url = new URL(filePath); filePath = url.pathname; } // 移除前導的 /static/ if (filePath.startsWith('/static/')) { filePath = filePath.substring('/static/'.length); } else if (filePath.startsWith('/')) { filePath = filePath.substring(1); } // 建構縮圖 URL let thumbnailUrl = `${this.thumbnailEndpoint}${filePath}?w=${width}`; if (height) { thumbnailUrl += `&h=${height}`; } if (quality !== 85) { thumbnailUrl += `&q=${quality}`; } return thumbnailUrl; } /** * 根據容器大小自動決定縮圖尺寸(響應式) * @param {HTMLImageElement} img - 圖片元素 * @param {string} imageUrl - 原始圖片 URL * @returns {string} 縮圖 URL */ getResponsiveThumbnailUrl(img, imageUrl) { if (!img || !imageUrl) { return imageUrl; } const container = img.parentElement; const containerWidth = container ? container.offsetWidth : img.offsetWidth; // 考慮裝置像素比(Retina 螢幕) const dpr = window.devicePixelRatio || 1; const targetWidth = Math.ceil(containerWidth * dpr); // 限制最大寬度 const width = Math.min(targetWidth, 2000); return this.getThumbnailUrl(imageUrl, { width }); } /** * 智慧載入圖片(支援重試、備援、快取) * @param {HTMLImageElement} imgElement - 圖片元素 * @param {string} primaryUrl - 主要圖片 URL * @param {string} fallbackUrl - 備援 URL(選填) * @param {Object} options - 選項 * @returns {Promise} */ async loadImage(imgElement, primaryUrl, fallbackUrl = null, options = {}) { const { onSuccess = null, onError = null, useCache = true, showLoading = true, fadeIn = true, useThumbnail = false, thumbnailWidth = 300 } = options; // 如果啟用縮圖轉換 if (useThumbnail) { primaryUrl = this.getThumbnailUrl(primaryUrl, { width: thumbnailWidth }); } // 檢查快取 if (useCache && this.preloadedImages.has(primaryUrl)) { imgElement.src = primaryUrl; imgElement.classList.add('loaded'); if (onSuccess) onSuccess(imgElement, primaryUrl); return Promise.resolve(primaryUrl); } const attemptKey = imgElement.id || Math.random().toString(); if (!this.loadingAttempts.has(attemptKey)) { this.loadingAttempts.set(attemptKey, { primaryRetries: 0, fallbackRetries: 0, usedFallback: false }); } const attempts = this.loadingAttempts.get(attemptKey); // 添加載入狀態 if (showLoading) { imgElement.classList.add('loading'); const container = imgElement.parentElement; if (container) { container.classList.add('img-loading-container'); } } if (fadeIn) { imgElement.classList.add('img-fade-in'); } // 載入主要圖片 return this._tryLoadImage(imgElement, primaryUrl, attempts.primaryRetries) .then((url) => { imgElement.classList.remove('loading'); imgElement.classList.add('img-loaded', 'loaded'); const container = imgElement.parentElement; if (container) container.classList.add('img-loaded'); if (useCache) { this.preloadedImages.set(primaryUrl, imgElement); } this.loadingAttempts.delete(attemptKey); if (onSuccess) onSuccess(imgElement, url); return url; }) .catch((error) => { console.warn(`主要圖片載入失敗 (嘗試 ${attempts.primaryRetries + 1}/${this.maxRetries + 1}): ${primaryUrl}`); if (attempts.primaryRetries < this.maxRetries) { attempts.primaryRetries++; return new Promise((resolve, reject) => { setTimeout(() => { this.loadImage(imgElement, primaryUrl, fallbackUrl, options) .then(resolve) .catch(reject); }, this.retryDelay * attempts.primaryRetries); }); } else if (fallbackUrl && !attempts.usedFallback) { console.log(`嘗試載入備援圖片: ${fallbackUrl}`); attempts.usedFallback = true; return this._tryLoadImage(imgElement, fallbackUrl, 0) .then((url) => { imgElement.classList.remove('loading'); imgElement.classList.add('img-loaded', 'loaded', 'fallback'); const container = imgElement.parentElement; if (container) container.classList.add('img-loaded'); this.loadingAttempts.delete(attemptKey); if (onSuccess) onSuccess(imgElement, url); return url; }) .catch(() => { this._loadDefaultImage(imgElement); if (onError) onError(imgElement, primaryUrl); return this.defaultImage; }); } else { this._loadDefaultImage(imgElement); if (onError) onError(imgElement, primaryUrl); return this.defaultImage; } }); } /** * 嘗試載入單張圖片(帶超時控制) * @private */ _tryLoadImage(imgElement, url, retryCount) { return new Promise((resolve, reject) => { const img = new Image(); let timeoutId; let loaded = false; img.onload = () => { if (!loaded) { loaded = true; clearTimeout(timeoutId); imgElement.src = url; resolve(url); } }; img.onerror = () => { if (!loaded) { loaded = true; clearTimeout(timeoutId); reject(new Error('Image load failed')); } }; timeoutId = setTimeout(() => { if (!loaded) { loaded = true; img.src = ''; reject(new Error('Image load timeout')); } }, this.timeout); img.src = url; this.loadingImages.add(url); }); } /** * 載入預設圖片 * @private */ _loadDefaultImage(imgElement) { imgElement.src = this.defaultImage; imgElement.classList.remove('loading'); imgElement.classList.add('img-loaded', 'loaded', 'error'); const container = imgElement.parentElement; if (container) container.classList.add('img-loaded'); console.warn(`圖片載入失敗,使用預設圖片: ${imgElement.alt || 'unknown'}`); } /** * 自動轉換頁面中所有圖片為縮圖 * @param {HTMLElement} container - 容器元素 * @param {Object} sizeMap - 自訂尺寸映射 */ autoConvertToThumbnails(container = document, sizeMap = {}) { const fullSizeMap = { ...this.defaultSizeMap, ...sizeMap }; Object.entries(fullSizeMap).forEach(([selector, options]) => { const images = container.querySelectorAll(selector); images.forEach(img => { if (img.dataset.thumbnailProcessed === 'true') { return; } const originalSrc = img.src || img.dataset.src; if (originalSrc && originalSrc !== location.href) { const thumbnailSrc = this.getThumbnailUrl(originalSrc, options); if (img.dataset.src) { img.dataset.src = thumbnailSrc; } else { img.src = thumbnailSrc; } img.dataset.thumbnailProcessed = 'true'; } }); }); } /** * 優化單張圖片元素 * @param {HTMLImageElement} imgElement - 圖片元素 * @param {Object} options - 選項 */ optimizeImage(imgElement, options = {}) { const { lazy = true, fadeIn = true, showLoading = true } = options; if (imgElement.dataset.optimized === 'true') { return; } const container = imgElement.parentElement; if (showLoading && container) { container.classList.add('img-loading-container'); } if (fadeIn) { imgElement.classList.add('img-fade-in'); } if (lazy) { imgElement.loading = 'lazy'; } else { imgElement.loading = 'eager'; } const onLoad = () => { if (container) container.classList.add('img-loaded'); imgElement.classList.add('img-loaded'); imgElement.dataset.optimized = 'true'; }; const onError = () => { if (container) container.classList.add('img-loaded'); imgElement.src = this.defaultImage; imgElement.dataset.optimized = 'true'; }; if (imgElement.complete) { onLoad(); } else { imgElement.addEventListener('load', onLoad, { once: true }); imgElement.addEventListener('error', onError, { once: true }); } imgElement.dataset.optimized = 'true'; } /** * 批次優化頁面中的所有圖片 */ optimizeAllImages(container = document, options = {}) { const images = container.querySelectorAll('img:not([data-optimized="true"])'); images.forEach(img => { this.optimizeImage(img, options); }); } /** * 預載入圖片陣列 */ async preloadImages(urls, onProgress = null) { const urlArray = Array.isArray(urls) ? urls : [urls]; const validUrls = urlArray.filter(url => url && url !== this.defaultImage); if (validUrls.length === 0) { return Promise.resolve([]); } let loaded = 0; const promises = validUrls.map(url => { if (this.preloadedImages.has(url)) { loaded++; if (onProgress) onProgress(loaded, validUrls.length); return Promise.resolve({ url, success: true, cached: true }); } return this._tryLoadImage(new Image(), url, 0) .then(() => { this.preloadedImages.set(url, true); loaded++; if (onProgress) onProgress(loaded, validUrls.length); return { url, success: true, cached: false }; }) .catch(() => { loaded++; if (onProgress) onProgress(loaded, validUrls.length); return { url, success: false, cached: false }; }); }); const results = await Promise.all(promises); this.loadingImages.clear(); return results; } /** * 監聽 DOM 變化,自動處理新加入的圖片 */ observeDOMChanges() { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === 1) { if (node.tagName === 'IMG') { this.optimizeImage(node); // 同時嘗試轉換為縮圖 this.autoConvertToThumbnails(node.parentElement); } else if (node.querySelectorAll) { this.optimizeAllImages(node); this.autoConvertToThumbnails(node); } } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); } /** * 清除快取和記錄 */ clearCache() { this.preloadedImages.clear(); this.loadingImages.clear(); this.loadingAttempts.clear(); } /** * 取得快取統計 */ getCacheStats() { return { cached: this.preloadedImages.size, loading: this.loadingImages.size, attempts: this.loadingAttempts.size }; } } // 建立全域實例 window.imageOptimizer = new UltimateImageOptimizer({ maxRetries: 2, retryDelay: 500, timeout: 10000, defaultImage: '/static/img/dog1.png', thumbnailEndpoint: '/thumbnail/' }); // 相容性別名 window.smartImageLoader = window.imageOptimizer; // 全域函數(向後相容) window.getThumbnailUrl = (url, options) => window.imageOptimizer.getThumbnailUrl(url, options); window.autoConvertToThumbnails = (container, sizeMap) => window.imageOptimizer.autoConvertToThumbnails(container, sizeMap); window.getResponsiveThumbnailUrl = (img, url) => window.imageOptimizer.getResponsiveThumbnailUrl(img, url); // 匯出供其他模組使用 if (typeof module !== 'undefined' && module.exports) { module.exports = UltimateImageOptimizer; }