Spaces:
Runtime error
Runtime error
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>Banana Pro AI</title> | |
| <link rel="stylesheet" href="style.css"> | |
| <style> | |
| /* 顶部固定布局样式 */ | |
| .input-section { | |
| position: fixed ; | |
| top: 0 ; | |
| bottom: auto ; | |
| left: 0 ; | |
| right: 0 ; | |
| transform: none ; | |
| width: 100% ; | |
| max-width: 100% ; | |
| border-radius: 0 ; | |
| z-index: 1000 ; | |
| box-shadow: 0 2px 20px rgba(0,0,0,0.3) ; | |
| } | |
| header { | |
| margin-top: var(--input-offset, 115px); /* 为输入区留空�?*/ | |
| } | |
| .history-container { | |
| padding-bottom: 20px ; | |
| } | |
| /* 调整输入区内部布�? */ | |
| .control-bar { | |
| max-width: 1400px; | |
| margin: 0 auto; | |
| padding: 12px 20px ; | |
| } | |
| .preview-bar { | |
| max-width: 1400px; | |
| margin: 0 auto; | |
| padding: 0 20px; | |
| } | |
| .preview-bar.visible { | |
| padding: 12px 20px; | |
| } | |
| /* 动�?�调整header间距 */ | |
| .has-preview header { | |
| margin-top: var(--input-offset, 260px); | |
| } | |
| @media (max-width: 768px) { | |
| header { | |
| margin-top: var(--input-offset, 170px); | |
| } | |
| .has-preview header { | |
| margin-top: var(--input-offset, 320px); | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- 在预览栏显示时添�?has-preview �?--> | |
| <script> | |
| // Observe preview bar changes and input height. | |
| const observePreviewBar = () => { | |
| const previewBar = document.getElementById('preview-bar'); | |
| if (!previewBar) return; | |
| const observer = new MutationObserver(() => { | |
| if (previewBar.classList.contains('visible')) { | |
| document.body.classList.add('has-preview'); | |
| } else { | |
| document.body.classList.remove('has-preview'); | |
| } | |
| }); | |
| observer.observe(previewBar, { attributes: true, attributeFilter: ['class'] }); | |
| }; | |
| const updateInputOffset = () => { | |
| const inputSection = document.querySelector('.input-section'); | |
| if (!inputSection) return; | |
| const gap = 16; | |
| const nextOffset = inputSection.offsetHeight + gap; | |
| document.documentElement.style.setProperty('--input-offset', nextOffset + 'px'); | |
| }; | |
| const observeInputSection = () => { | |
| const inputSection = document.querySelector('.input-section'); | |
| if (!inputSection) return; | |
| updateInputOffset(); | |
| if ('ResizeObserver' in window) { | |
| const observer = new ResizeObserver(() => { | |
| requestAnimationFrame(updateInputOffset); | |
| }); | |
| observer.observe(inputSection); | |
| } else { | |
| window.addEventListener('resize', updateInputOffset); | |
| } | |
| }; | |
| document.addEventListener('DOMContentLoaded', () => { | |
| observePreviewBar(); | |
| observeInputSection(); | |
| }); | |
| </script> | |
| <!-- 登录界面 --> | |
| <div id="login-overlay"> | |
| <div class="login-card glass-panel"> | |
| <h1 style="font-size: 2rem;">banana绘画网页项目</h1> | |
| <h3 style="margin: 10px 0; color: #cbd5e1;">Banana Pro</h3> | |
| <input type="password" id="pwd" placeholder="Password" onkeypress="if(event.key==='Enter')doLogin()"> | |
| <button class="btn-3d" onclick="doLogin()" style="width: 100%; padding: 10px;">Unlock</button> | |
| </div> | |
| </div> | |
| <!-- 主应�?--> | |
| <div class="app-container" id="app" style="filter: blur(10px); pointer-events: none;"> | |
| <!-- 顶部固定输入�?--> | |
| <div class="input-section glass-panel" id="drop-zone"> | |
| <!-- 上部分:预览�?--> | |
| <div class="preview-bar" id="preview-bar"></div> | |
| <!-- 下部分:操作�?--> | |
| <div class="control-bar"> | |
| <button class="upload-trigger" id="upload-btn" title="上传参考图">上传图片</button> | |
| <textarea id="prompt" class="main-input" rows="1" placeholder="描述画面... (支持拖拽图片)"></textarea> | |
| <button class="ghost-btn" id="clear-btn" title="清空输入" aria-label="清空输入">清空</button> | |
| <button class="btn-3d send-btn" id="send-btn"> | |
| <span>生成</span> | |
| <div class="loader"></div> | |
| </button> | |
| </div> | |
| <div class="tips-row" id="tips-row"> | |
| <span class="tip-pill">提示:写明主体 + 风格 + 光线</span> | |
| <span class="tip-pill">支持参考图:先上传再描述</span> | |
| <span class="tip-pill">画面比例 / 构图 写清楚更稳定</span> | |
| </div> | |
| </div> | |
| <header> | |
| <div class="header-stack"> | |
| <div class="title-row"> | |
| <h2>创意画板</h2> | |
| <a class="prompt-link" href="https://nanobananamaker.com/" target="_blank" rel="noopener">提示词用法库</a> | |
| <a class="prompt-link stats-link" href="stats.html">站点统计</a> | |
| </div> | |
| <div class="status-bar" id="status-bar" aria-live="polite"> | |
| <span class="status-text" id="status-text">Ready 生成较慢,放后台等待即可</span> | |
| <div class="status-meter" id="status-meter" aria-hidden="true"> | |
| <div class="status-meter-fill" id="status-meter-fill"></div> | |
| </div> | |
| <span class="status-timer" id="status-timer"></span> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- 历史画廊 --> | |
| <div class="history-container"> | |
| <div class="grid-layout" id="gallery"></div> | |
| <div class="gallery-actions"> | |
| <button class="icon-btn" id="gallery-load-more" title="加载更多" aria-label="加载更多">加载更多</button> | |
| </div> | |
| <section class="public-gallery-section glass-panel"> | |
| <div class="section-title"> | |
| <div> | |
| <h3>社区创意画廊</h3> | |
| <p class="section-subtitle" id="public-gallery-hint">分享你的作品,欣赏社区灵感</p> | |
| </div> | |
| <div class="section-actions"> | |
| <button class="icon-btn" id="refresh-public-gallery" title="刷新公共画廊" aria-label="刷新公共画廊"> | |
| <span class="refresh-icon" aria-hidden="true"></span> | |
| <span class="refresh-label">刷新画廊</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="grid-layout public-grid" id="public-gallery"></div> | |
| <div class="gallery-actions"> | |
| <button class="icon-btn" id="public-gallery-load-more" title="加载更多" aria-label="加载更多">加载更多</button> | |
| </div> | |
| </section> | |
| </div> | |
| <input type="file" id="file-input" multiple accept="image/*" hidden> | |
| </div> | |
| <!-- 详情弹窗 --> | |
| <div class="modal" id="modal"> | |
| <div class="modal-content glass-panel"> | |
| <button class="close-modal" onclick="closeModal()">×</button> | |
| <div class="modal-img-area"> | |
| <img id="m-img" src=""> | |
| </div> | |
| <div class="modal-footer"> | |
| <div class="input-refs" id="m-refs"></div> | |
| <div class="prompt-display" id="m-prompt"></div> | |
| <div class="modal-actions"> | |
| <button class="icon-btn" id="m-fullscreen" title="全屏查看" aria-label="全屏查看">全屏</button> | |
| <button class="icon-btn" id="m-download" title="保存图片" aria-label="保存图片">保存</button> | |
| </div> | |
| <button class="btn-3d" id="m-reuse" style="width: 100%; padding: 12px;"> | |
| 复用参数与图片 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // ============================================ | |
| // 全局状�?�管�? | |
| // ============================================ | |
| const AppState = { | |
| db: null, | |
| currentImages: [], // 当前上传图片的图�?Base64 数组 | |
| galleryData: [], // 个人画廊数据缓存 | |
| publicGalleryData: [], // 公共画廊数据缓存 | |
| currentModalItem: null // 当前弹窗显示的项�? | |
| }; | |
| const Device = { | |
| isMobile: (window.matchMedia && window.matchMedia('(max-width: 768px)').matches) | |
| || /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) | |
| }; | |
| const STORAGE_KEYS = { | |
| publicGalleryTokens: 'BananaPro_PublicGallery_Tokens_v1' | |
| }; | |
| const IMAGE_SIZE_STORAGE_KEY = 'BananaPro_ImageSize'; | |
| const IMAGE_RESPONSE_STORAGE_KEY = 'BananaPro_ImageResponse'; | |
| const API_BASE_STORAGE_KEY = 'BananaPro_ApiBase'; | |
| const resolveApiBase = () => { | |
| const params = new URLSearchParams(window.location.search); | |
| const paramBase = params.get('api'); | |
| if (paramBase) { | |
| localStorage.setItem(API_BASE_STORAGE_KEY, paramBase); | |
| } | |
| const stored = localStorage.getItem(API_BASE_STORAGE_KEY); | |
| const base = (paramBase || stored || '').trim(); | |
| return base.endsWith('/') ? base.slice(0, -1) : base; | |
| }; | |
| const API_BASE = resolveApiBase(); | |
| const apiFetch = (path, options) => { | |
| if (/^https?:\/\//i.test(path)) { | |
| return fetch(path, options); | |
| } | |
| const base = API_BASE; | |
| const url = base ? `${base}${path}` : path; | |
| return fetch(url, options); | |
| }; | |
| const normalizeImageSize = (value, fallback) => { | |
| const normalized = String(value || '').trim().toUpperCase(); | |
| if (normalized === '1K' || normalized === '2K' || normalized === '4K') { | |
| return normalized; | |
| } | |
| return fallback; | |
| }; | |
| const resolveImageSize = () => { | |
| const params = new URLSearchParams(window.location.search); | |
| const paramSize = params.get('size'); | |
| if (paramSize) { | |
| localStorage.setItem(IMAGE_SIZE_STORAGE_KEY, paramSize); | |
| } | |
| const stored = localStorage.getItem(IMAGE_SIZE_STORAGE_KEY); | |
| return normalizeImageSize(paramSize || stored, '4K'); | |
| }; | |
| const IMAGE_SIZE = resolveImageSize(); | |
| const normalizeResponseMode = (value, fallback) => { | |
| const normalized = String(value || '').trim().toLowerCase(); | |
| if (normalized === 'url' || normalized === 'base64' || normalized === 'both') { | |
| return normalized; | |
| } | |
| return fallback; | |
| }; | |
| const resolveResponseMode = () => { | |
| const params = new URLSearchParams(window.location.search); | |
| const paramMode = params.get('response'); | |
| if (paramMode) { | |
| localStorage.setItem(IMAGE_RESPONSE_STORAGE_KEY, paramMode); | |
| } | |
| const stored = localStorage.getItem(IMAGE_RESPONSE_STORAGE_KEY); | |
| const fallback = Device.isMobile ? 'url' : 'base64'; | |
| const resolved = normalizeResponseMode(paramMode || stored, fallback); | |
| if (Device.isMobile && !paramMode && resolved === 'base64') { | |
| return fallback; | |
| } | |
| return resolved; | |
| }; | |
| const RESPONSE_MODE = resolveResponseMode(); | |
| const resolveAssetUrl = (value) => { | |
| if (!value || typeof value !== 'string') return ''; | |
| if (/^https?:\/\//i.test(value) || value.startsWith('data:') || value.startsWith('blob:')) { | |
| return value; | |
| } | |
| const base = API_BASE; | |
| if (!base) return value; | |
| if (value.startsWith('/')) { | |
| return `${base}${value}`; | |
| } | |
| return `${base}/${value}`; | |
| }; | |
| const DEFAULT_PUBLIC_HINT = '分享你的作品,欣赏社区灵感'; | |
| const PLACEHOLDER_IMAGE = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><rect width="100%" height="100%" fill="%23222222"/><text x="50%" y="50%" fill="%23666" font-size="16" text-anchor="middle" dominant-baseline="middle">Loading</text></svg>'; | |
| const DB_NAME = 'BananaProDB_v3'; | |
| const DB_VERSION = 1; | |
| const STORE_NAME = 'artworks'; | |
| // ============================================ | |
| // IndexedDB 模块(缓存图像与缩略图) | |
| // ============================================ | |
| const Database = { | |
| async init() { | |
| return new Promise((resolve, reject) => { | |
| const request = indexedDB.open(DB_NAME, DB_VERSION); | |
| request.onerror = () => reject(request.error); | |
| request.onsuccess = () => { | |
| AppState.db = request.result; | |
| resolve(); | |
| }; | |
| request.onupgradeneeded = (event) => { | |
| const db = event.target.result; | |
| if (!db.objectStoreNames.contains(STORE_NAME)) { | |
| const store = db.createObjectStore(STORE_NAME, { | |
| keyPath: 'id', | |
| autoIncrement: true | |
| }); | |
| store.createIndex('timestamp', 'timestamp', { unique: false }); | |
| } | |
| }; | |
| }); | |
| }, | |
| async save(item) { | |
| return new Promise((resolve, reject) => { | |
| const tx = AppState.db.transaction([STORE_NAME], 'readwrite'); | |
| const store = tx.objectStore(STORE_NAME); | |
| // 直接存储完整对象,不做任何转�? | |
| const record = { | |
| prompt: item.prompt, | |
| image: item.image, // data:image/... �?/generated/... URL | |
| imageUrl: item.imageUrl || null, | |
| imageId: item.imageId || null, | |
| thumb: item.thumb || null, // 缩略图(用于加�?�列表渲染) | |
| inputImages: item.inputImages || [], | |
| timestamp: Date.now() | |
| }; | |
| const request = store.add(record); | |
| request.onsuccess = () => resolve(request.result); | |
| request.onerror = () => reject(request.error); | |
| }); | |
| }, | |
| async getAll() { | |
| return new Promise((resolve, reject) => { | |
| const tx = AppState.db.transaction([STORE_NAME], 'readonly'); | |
| const store = tx.objectStore(STORE_NAME); | |
| const request = store.getAll(); | |
| request.onsuccess = () => { | |
| // 按时间戳倒序 | |
| const results = request.result.sort((a, b) => b.timestamp - a.timestamp); | |
| resolve(results); | |
| }; | |
| request.onerror = () => reject(request.error); | |
| }); | |
| }, | |
| async delete(id) { | |
| return new Promise((resolve, reject) => { | |
| const tx = AppState.db.transaction([STORE_NAME], 'readwrite'); | |
| const store = tx.objectStore(STORE_NAME); | |
| const request = store.delete(id); | |
| request.onsuccess = () => resolve(); | |
| request.onerror = () => reject(request.error); | |
| }); | |
| }, | |
| async update(id, updates) { | |
| return new Promise((resolve, reject) => { | |
| const tx = AppState.db.transaction([STORE_NAME], 'readwrite'); | |
| const store = tx.objectStore(STORE_NAME); | |
| const getReq = store.get(id); | |
| getReq.onsuccess = () => { | |
| const record = getReq.result; | |
| if (!record) { | |
| resolve(false); | |
| return; | |
| } | |
| const nextRecord = { ...record, ...updates, id: record.id }; | |
| const putReq = store.put(nextRecord); | |
| putReq.onsuccess = () => resolve(true); | |
| putReq.onerror = () => reject(putReq.error); | |
| }; | |
| getReq.onerror = () => reject(getReq.error); | |
| }); | |
| }, | |
| async getById(id) { | |
| return new Promise((resolve, reject) => { | |
| const tx = AppState.db.transaction([STORE_NAME], 'readonly'); | |
| const store = tx.objectStore(STORE_NAME); | |
| const request = store.get(id); | |
| request.onsuccess = () => resolve(request.result); | |
| request.onerror = () => reject(request.error); | |
| }); | |
| } | |
| }; | |
| // ============================================ | |
| // 图片处理模块 | |
| // ============================================ | |
| const ImageHandler = { | |
| // 文件�?Base64 | |
| fileToBase64(file) { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => resolve(reader.result); | |
| reader.onerror = () => reject(reader.error); | |
| reader.readAsDataURL(file); | |
| }); | |
| }, | |
| // 压缩图片(手机端优化�? | |
| async compressImage(base64Data, maxWidth = 1280, quality = 0.8) { | |
| return new Promise((resolve) => { | |
| const img = new Image(); | |
| img.onload = () => { | |
| if (img.width <= maxWidth && base64Data.length < 500000) { | |
| resolve(base64Data); | |
| return; | |
| } | |
| const canvas = document.createElement('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| if (!ctx) { | |
| resolve(base64Data); | |
| return; | |
| } | |
| const scale = Math.min(1, maxWidth / img.width); | |
| canvas.width = img.width * scale; | |
| canvas.height = img.height * scale; | |
| ctx.drawImage(img, 0, 0, canvas.width, canvas.height); | |
| resolve(canvas.toDataURL('image/jpeg', quality)); | |
| }; | |
| img.onerror = () => resolve(base64Data); | |
| img.src = base64Data; | |
| }); | |
| }, | |
| // Create thumbnail for faster list rendering | |
| async createThumbnail(imageSrc, maxWidth = 768, quality = 0.76) { | |
| return new Promise((resolve) => { | |
| if (!imageSrc || typeof imageSrc !== 'string') { | |
| resolve(null); | |
| return; | |
| } | |
| const img = new Image(); | |
| img.crossOrigin = 'anonymous'; | |
| img.onload = () => { | |
| const isDataUrl = imageSrc.startsWith('data:image'); | |
| if (img.width <= maxWidth && (!isDataUrl || imageSrc.length < 400000)) { | |
| resolve(imageSrc); | |
| return; | |
| } | |
| const canvas = document.createElement('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| if (!ctx) { | |
| resolve(null); | |
| return; | |
| } | |
| const scale = Math.min(1, maxWidth / img.width); | |
| canvas.width = Math.round(img.width * scale); | |
| canvas.height = Math.round(img.height * scale); | |
| ctx.drawImage(img, 0, 0, canvas.width, canvas.height); | |
| try { | |
| resolve(canvas.toDataURL('image/jpeg', quality)); | |
| } catch (error) { | |
| resolve(null); | |
| } | |
| }; | |
| img.onerror = () => resolve(null); | |
| img.src = imageSrc; | |
| }); | |
| }, | |
| async reduceImageForMobile(imageSrc) { | |
| if (!imageSrc || typeof imageSrc !== 'string') return imageSrc; | |
| if (!imageSrc.startsWith('data:image')) return imageSrc; | |
| const targets = [ | |
| { width: 960, quality: 0.7 }, | |
| { width: 768, quality: 0.65 }, | |
| { width: 640, quality: 0.6 }, | |
| { width: 512, quality: 0.55 } | |
| ]; | |
| let current = imageSrc; | |
| for (const target of targets) { | |
| const next = await this.createThumbnail(current, target.width, target.quality); | |
| if (next && typeof next === 'string') { | |
| current = next; | |
| if (next.length < 900000) { | |
| break; | |
| } | |
| } | |
| } | |
| return current; | |
| }, | |
| dataUrlToObjectUrl(dataUrl) { | |
| if (!dataUrl || typeof dataUrl !== 'string') return null; | |
| if (dataUrl.length > 1200000) return null; | |
| const match = dataUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/); | |
| if (!match) return null; | |
| try { | |
| const mimeType = match[1]; | |
| const base64Data = match[2]; | |
| const binary = atob(base64Data); | |
| const bytes = new Uint8Array(binary.length); | |
| for (let i = 0; i < binary.length; i += 1) { | |
| bytes[i] = binary.charCodeAt(i); | |
| } | |
| const blob = new Blob([bytes], { type: mimeType }); | |
| return URL.createObjectURL(blob); | |
| } catch (error) { | |
| return null; | |
| } | |
| }, | |
| async dataUrlToObjectUrlAsync(dataUrl) { | |
| if (!dataUrl || typeof dataUrl !== 'string') return null; | |
| if (!dataUrl.startsWith('data:image')) return null; | |
| try { | |
| const res = await fetch(dataUrl); | |
| if (!res.ok) return null; | |
| const blob = await res.blob(); | |
| return URL.createObjectURL(blob); | |
| } catch (error) { | |
| return null; | |
| } | |
| }, | |
| getFullImageUrl(item) { | |
| if (!item) return ''; | |
| if (item.imageUrl) return resolveAssetUrl(item.imageUrl); | |
| if (item.image && typeof item.image === 'string') { | |
| const candidate = item.image.trim(); | |
| if (candidate && !candidate.startsWith('data:')) { | |
| return resolveAssetUrl(candidate); | |
| } | |
| } | |
| if (item.imageId && typeof item.imageId === 'string') { | |
| return resolveAssetUrl(`/generated/${item.imageId}`); | |
| } | |
| return ''; | |
| }, | |
| getDownloadUrl(item) { | |
| if (!item) return ''; | |
| const fullUrl = this.getFullImageUrl(item); | |
| if (!fullUrl) { | |
| if (item.image && typeof item.image === 'string' && item.image.startsWith('data:image')) { | |
| return item.image; | |
| } | |
| return ''; | |
| } | |
| try { | |
| const parsed = new URL(fullUrl, window.location.origin); | |
| const match = parsed.pathname.match(/^\/generated\/([^/]+)$/); | |
| if (match) { | |
| return resolveAssetUrl(`/api/download/${match[1]}`); | |
| } | |
| } catch (error) { | |
| // Ignore URL parsing failures. | |
| } | |
| return fullUrl; | |
| }, | |
| getDisplayImage(item) { | |
| if (!item) return ''; | |
| const fullUrl = this.getFullImageUrl(item); | |
| const thumbSrc = resolveAssetUrl(item.thumb || item.thumbUrl); | |
| const rawSource = resolveAssetUrl(item.image); | |
| if (Device.isMobile) { | |
| if (thumbSrc) return thumbSrc; | |
| if (item.displayUrl) return item.displayUrl; | |
| const source = rawSource || fullUrl; | |
| if (source && source.startsWith('data:image')) { | |
| if (source.length > 1200000) { | |
| return fullUrl || source || PLACEHOLDER_IMAGE; | |
| } | |
| const objectUrl = this.dataUrlToObjectUrl(source); | |
| if (objectUrl) { | |
| item.displayUrl = objectUrl; | |
| return objectUrl; | |
| } | |
| } | |
| return fullUrl || source || ''; | |
| } | |
| if (rawSource && rawSource.startsWith('data:image') && fullUrl) { | |
| if (rawSource.length > 400000) { | |
| return fullUrl; | |
| } | |
| } | |
| return rawSource || fullUrl || ''; | |
| }, | |
| revokeDisplayUrl(item) { | |
| if (!item || !item.displayUrl) return; | |
| if (item.displayUrl.startsWith('blob:')) { | |
| URL.revokeObjectURL(item.displayUrl); | |
| } | |
| item.displayUrl = null; | |
| }, | |
| async triggerDownload(source, filename) { | |
| if (!source) return; | |
| let finalSource = source; | |
| const isDataUrl = source.startsWith('data:image'); | |
| let blob = null; | |
| const shouldConvert = isDataUrl | |
| && (Device.isMobile || source.length > 1200000); | |
| if (Device.isMobile) { | |
| try { | |
| const res = await fetch(source, { credentials: 'include' }); | |
| if (res.ok) { | |
| blob = await res.blob(); | |
| } | |
| } catch (error) { | |
| // Ignore fetch errors and fallback. | |
| } | |
| } | |
| if (blob && Device.isMobile && navigator.share && navigator.canShare) { | |
| const file = new File([blob], filename, { | |
| type: blob.type || 'image/png' | |
| }); | |
| if (navigator.canShare({ files: [file] })) { | |
| try { | |
| await navigator.share({ files: [file], title: filename }); | |
| return; | |
| } catch (error) { | |
| if (error && error.name === 'AbortError') { | |
| return; | |
| } | |
| } | |
| } | |
| } | |
| if (blob) { | |
| finalSource = URL.createObjectURL(blob); | |
| } else if (shouldConvert) { | |
| const objectUrl = await this.dataUrlToObjectUrlAsync(source); | |
| if (objectUrl) { | |
| finalSource = objectUrl; | |
| } | |
| } | |
| const link = document.createElement('a'); | |
| link.href = finalSource; | |
| link.download = filename; | |
| link.rel = 'noopener'; | |
| if (Device.isMobile) { | |
| link.target = '_blank'; | |
| } | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| if (finalSource.startsWith('blob:')) { | |
| setTimeout(() => URL.revokeObjectURL(finalSource), 1000); | |
| } | |
| }, | |
| // 处理上传图片的文�? | |
| async processFiles(files) { | |
| const maxImages = 16; | |
| const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/')); | |
| for (const file of imageFiles) { | |
| if (AppState.currentImages.length >= maxImages) { | |
| alert(`最多只能上传 ${maxImages} 张图片`); | |
| break; | |
| } | |
| try { | |
| let base64 = await this.fileToBase64(file); | |
| const maxWidth = Device.isMobile ? 1024 : 1280; | |
| const quality = Device.isMobile ? 0.72 : 0.8; | |
| base64 = await this.compressImage(base64, maxWidth, quality); | |
| AppState.currentImages.push(base64); | |
| } catch (err) { | |
| console.error('图片读取失败:', err); | |
| } | |
| } | |
| PreviewManager.render(); | |
| }, | |
| // 移除指定索引的图�? | |
| removeAt(index) { | |
| AppState.currentImages.splice(index, 1); | |
| PreviewManager.render(); | |
| }, | |
| // 清空�?有上传图片的图片 | |
| clear() { | |
| AppState.currentImages = []; | |
| PreviewManager.render(); | |
| }, | |
| // 设置图片(用于复用功能) | |
| setImages(images) { | |
| AppState.currentImages = images ? [...images] : []; | |
| PreviewManager.render(); | |
| } | |
| }; | |
| // ============================================ | |
| // Preview bar | |
| // ============================================ | |
| const PreviewManager = { | |
| container: null, | |
| uploadBtn: null, | |
| init() { | |
| this.container = document.getElementById('preview-bar'); | |
| this.uploadBtn = document.getElementById('upload-btn'); | |
| }, | |
| render(options = {}) { | |
| const append = options.append === true; | |
| if (!append) { | |
| this.container.innerHTML = ''; | |
| } | |
| const images = AppState.currentImages; | |
| if (images.length === 0) { | |
| this.container.classList.remove('visible'); | |
| this.uploadBtn.classList.remove('active'); | |
| StatusBar.resetToContext(); | |
| return; | |
| } | |
| this.container.classList.add('visible'); | |
| this.uploadBtn.classList.add('active'); | |
| StatusBar.setText("已选择 " + images.length + '/16 ' + "张图片"); | |
| images.forEach((imgData, index) => { | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'thumb-wrapper'; | |
| const img = document.createElement('img'); | |
| img.src = imgData; | |
| img.decoding = 'async'; | |
| const removeBtn = document.createElement('div'); | |
| removeBtn.className = 'thumb-remove'; | |
| removeBtn.textContent = '\u00d7'; | |
| removeBtn.title = '移除'; | |
| removeBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| ImageHandler.removeAt(index); | |
| }; | |
| wrapper.appendChild(img); | |
| wrapper.appendChild(removeBtn); | |
| this.container.appendChild(wrapper); | |
| }); | |
| } | |
| }; | |
| // ============================================ | |
| // Lazy image loader | |
| // ============================================ | |
| const LazyImageLoader = { | |
| observer: null, | |
| init() { | |
| if (!('IntersectionObserver' in window)) { | |
| return; | |
| } | |
| this.observer = new IntersectionObserver( | |
| (entries) => { | |
| entries.forEach((entry) => { | |
| if (!entry.isIntersecting) return; | |
| const img = entry.target; | |
| const src = img.dataset.src; | |
| if (src) { | |
| img.removeAttribute('data-src'); | |
| const isInline = src.startsWith('data:') || src.startsWith('blob:'); | |
| const shouldPreload = Device.isMobile && !isInline; | |
| if (shouldPreload) { | |
| const preloader = new Image(); | |
| preloader.onload = () => { | |
| img.src = src; | |
| }; | |
| preloader.onerror = () => { | |
| img.src = src; | |
| }; | |
| preloader.src = src; | |
| } else { | |
| img.src = src; | |
| } | |
| } | |
| this.observer.unobserve(img); | |
| }); | |
| }, | |
| { rootMargin: '200px 0px' } | |
| ); | |
| }, | |
| observe(img, src) { | |
| if (!src) return; | |
| if (!this.observer) { | |
| img.src = src; | |
| return; | |
| } | |
| img.dataset.src = src; | |
| this.observer.observe(img); | |
| } | |
| }; | |
| // ============================================ | |
| // Status bar | |
| // ============================================ | |
| const StatusBar = { | |
| container: null, | |
| textEl: null, | |
| meterEl: null, | |
| meterFill: null, | |
| timerEl: null, | |
| defaultText: "Ready 生成较慢,放后台等待即可", | |
| locked: false, | |
| countdownTimer: null, | |
| flashTimer: null, | |
| countdownTotal: 60, | |
| init() { | |
| this.container = document.getElementById('status-bar'); | |
| this.textEl = document.getElementById('status-text'); | |
| this.meterEl = document.getElementById('status-meter'); | |
| this.meterFill = document.getElementById('status-meter-fill'); | |
| this.timerEl = document.getElementById('status-timer'); | |
| this.setText(this.defaultText, { force: true }); | |
| this.resetMeter(); | |
| }, | |
| setText(text, options = {}) { | |
| if (this.locked && !options.force) return; | |
| this.textEl.textContent = text; | |
| }, | |
| resetMeter() { | |
| if (this.meterFill) { | |
| this.meterFill.style.width = '0%'; | |
| } | |
| }, | |
| setMeterProgress(percent) { | |
| if (this.meterFill) { | |
| const width = Math.min(100, Math.max(0, percent)); | |
| this.meterFill.style.width = width + '%'; | |
| } | |
| }, | |
| resetToContext() { | |
| if (this.locked) return; | |
| this.setText(this.defaultText, { force: true }); | |
| this.resetMeter(); | |
| }, | |
| startCountdown(totalSeconds = 60) { | |
| clearInterval(this.countdownTimer); | |
| clearTimeout(this.flashTimer); | |
| this.countdownTotal = totalSeconds; | |
| this.locked = true; | |
| if (this.container) { | |
| this.container.classList.add('is-generating'); | |
| } | |
| let remaining = totalSeconds; | |
| const update = () => { | |
| const elapsed = totalSeconds - remaining; | |
| const progress = totalSeconds > 0 ? (elapsed / totalSeconds) * 100 : 0; | |
| this.setMeterProgress(progress); | |
| if (this.timerEl) { | |
| this.timerEl.textContent = remaining > 0 ? `${remaining}s` : `${totalSeconds}s+`; | |
| } | |
| }; | |
| update(); | |
| this.countdownTimer = setInterval(() => { | |
| remaining -= 1; | |
| if (remaining < 0) { | |
| remaining = 0; | |
| } | |
| update(); | |
| }, 1000); | |
| }, | |
| stopCountdown() { | |
| clearInterval(this.countdownTimer); | |
| this.countdownTimer = null; | |
| this.locked = false; | |
| if (this.container) { | |
| this.container.classList.remove('is-generating'); | |
| } | |
| if (this.timerEl) { | |
| this.timerEl.textContent = ''; | |
| } | |
| this.resetMeter(); | |
| }, | |
| flash(text, duration = 3000) { | |
| this.setText(text, { force: true }); | |
| clearTimeout(this.flashTimer); | |
| this.flashTimer = setTimeout(() => { | |
| this.resetToContext(); | |
| }, duration); | |
| } | |
| }; | |
| // ============================================ | |
| // Gallery | |
| // ============================================ | |
| const GalleryManager = { | |
| container: null, | |
| loadMoreBtn: null, | |
| pageSize: 12, | |
| visibleCount: 0, | |
| thumbProcessing: false, | |
| pendingThumbItems: null, | |
| init() { | |
| this.container = document.getElementById('gallery'); | |
| this.loadMoreBtn = document.getElementById('gallery-load-more'); | |
| this.pageSize = Device.isMobile ? 12 : 12; | |
| this.visibleCount = this.pageSize; | |
| if (this.loadMoreBtn) { | |
| this.loadMoreBtn.onclick = () => this.loadMore(); | |
| } | |
| }, | |
| resetPagination() { | |
| this.visibleCount = this.pageSize; | |
| }, | |
| getVisibleItems() { | |
| const items = AppState.galleryData || []; | |
| const total = items.length; | |
| if (!this.visibleCount || this.visibleCount < this.pageSize) { | |
| this.visibleCount = this.pageSize; | |
| } | |
| const count = Math.min(this.visibleCount, total); | |
| return items.slice(0, count); | |
| }, | |
| updateLoadMore(totalCount) { | |
| if (!this.loadMoreBtn) return; | |
| const visible = Math.min(this.visibleCount || this.pageSize, totalCount); | |
| const hasMore = totalCount > visible; | |
| const wrapper = this.loadMoreBtn.parentElement; | |
| if (wrapper) { | |
| wrapper.style.display = hasMore ? 'flex' : 'none'; | |
| } | |
| this.loadMoreBtn.style.display = hasMore ? 'inline-flex' : 'none'; | |
| if (hasMore) { | |
| this.loadMoreBtn.textContent = `加载更多 (${visible}/${totalCount})`; | |
| } | |
| }, | |
| loadMore() { | |
| const total = AppState.galleryData.length; | |
| if (!total || this.visibleCount >= total) return; | |
| const scroller = this.container ? this.container.closest('.history-container') : null; | |
| const scrollTop = scroller ? scroller.scrollTop : window.scrollY; | |
| this.visibleCount = Math.min(total, (this.visibleCount || this.pageSize) + this.pageSize); | |
| this.render({ append: true }); | |
| requestAnimationFrame(() => { | |
| if (scroller) { | |
| scroller.scrollTop = scrollTop; | |
| } else { | |
| window.scrollTo(0, scrollTop); | |
| } | |
| }); | |
| }, | |
| async load() { | |
| try { | |
| if (!AppState.db) { | |
| AppState.galleryData = []; | |
| this.resetPagination(); | |
| this.render(); | |
| return; | |
| } | |
| AppState.galleryData = await Database.getAll(); | |
| this.resetPagination(); | |
| this.render(); | |
| } catch (err) { | |
| console.error('加载画廊失败:', err); | |
| } | |
| }, | |
| render(options = {}) { | |
| const append = options.append === true; | |
| if (!append) { | |
| this.container.innerHTML = ''; | |
| } | |
| this.renderToken = (this.renderToken || 0) + 1; | |
| const token = this.renderToken; | |
| const totalCount = AppState.galleryData.length; | |
| if (totalCount === 0) { | |
| this.updateLoadMore(0); | |
| this.container.innerHTML = ` | |
| <div style="grid-column: 1/-1; text-align: center; color: var(--text-sub); padding: 60px 20px;"> | |
| <div style="font-size: 48px; margin-bottom: 10px;">暂无</div> | |
| <div>暂无作品,开始创作吧!</div> | |
| </div> | |
| `; | |
| return; | |
| } | |
| const items = this.getVisibleItems(); | |
| const startIndex = append ? this.container.childElementCount : 0; | |
| const renderItems = append ? items.slice(startIndex) : items; | |
| this.updateLoadMore(totalCount); | |
| const batchSize = this.pageSize; | |
| let index = 0; | |
| const renderBatch = () => { | |
| if (token != this.renderToken) return; | |
| const fragment = document.createDocumentFragment(); | |
| for (let i = 0; i < batchSize && index < renderItems.length; i += 1) { | |
| const card = this.createCard(renderItems[index]); | |
| fragment.appendChild(card); | |
| index += 1; | |
| } | |
| this.container.appendChild(fragment); | |
| if (index < renderItems.length) { | |
| if ('requestIdleCallback' in window) { | |
| requestIdleCallback(renderBatch); | |
| } else { | |
| requestAnimationFrame(renderBatch); | |
| } | |
| } | |
| }; | |
| renderBatch(); | |
| this.queueThumbnails(renderItems.length ? renderItems : items); | |
| }, | |
| createCard(item) { | |
| const el = document.createElement('div'); | |
| el.className = 'history-item'; | |
| el.dataset.id = item.id; | |
| // Reference badge | |
| if (item.inputImages && item.inputImages.length > 0) { | |
| const badge = document.createElement('div'); | |
| badge.className = 'item-badge'; | |
| badge.textContent = `参考 ${item.inputImages.length}`; | |
| el.appendChild(badge); | |
| } | |
| // Main image | |
| const img = document.createElement('img'); | |
| const isPriority = Boolean(item.isNew); | |
| img.loading = isPriority ? 'eager' : 'lazy'; | |
| img.decoding = 'async'; | |
| img.fetchPriority = isPriority ? 'high' : 'low'; | |
| const thumbSrc = resolveAssetUrl(item.thumb || item.thumbUrl); | |
| const fullSrc = ImageHandler.getFullImageUrl(item) || resolveAssetUrl(item.imageUrl); | |
| let displaySrc = thumbSrc || ImageHandler.getDisplayImage(item) || fullSrc; | |
| if (Device.isMobile && isPriority && fullSrc && !thumbSrc) { | |
| displaySrc = fullSrc; | |
| } | |
| if (Device.isMobile && isPriority) { | |
| img.src = displaySrc; | |
| } else { | |
| LazyImageLoader.observe(img, displaySrc); | |
| } | |
| img.alt = `作品 ${item.id}`; | |
| img.onload = () => { | |
| if (item.isNew) { | |
| item.isNew = false; | |
| } | |
| }; | |
| img.onerror = () => { | |
| console.error('图片加载失败, ID:', item.id); | |
| if (fullSrc && img.src !== fullSrc) { | |
| img.src = fullSrc; | |
| return; | |
| } | |
| img.src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect fill="%23333" width="100" height="100"/><text fill="%23666" x="50%" y="50%" text-anchor="middle" dy=".3em">Error</text></svg>'; | |
| }; | |
| el.appendChild(img); | |
| // Actions | |
| const actions = document.createElement('div'); | |
| actions.className = 'item-actions'; | |
| const shareBtn = document.createElement('button'); | |
| shareBtn.className = 'icon-btn share-btn'; | |
| shareBtn.textContent = '发布'; | |
| shareBtn.title = '发布到公共画廊'; | |
| shareBtn.setAttribute('aria-label', '发布到公共画廊'); | |
| shareBtn.addEventListener('touchstart', (e) => { | |
| e.stopPropagation(); | |
| }, { passive: true }); | |
| shareBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| PublicGalleryManager.share(item, shareBtn); | |
| }; | |
| const downloadBtn = document.createElement('button'); | |
| downloadBtn.className = 'icon-btn'; | |
| downloadBtn.textContent = '下载'; | |
| downloadBtn.title = '下载图片'; | |
| downloadBtn.addEventListener('touchstart', (e) => { | |
| e.stopPropagation(); | |
| }, { passive: true }); | |
| downloadBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| this.downloadImage(item); | |
| }; | |
| const deleteBtn = document.createElement('button'); | |
| deleteBtn.className = 'icon-btn'; | |
| deleteBtn.style.background = 'rgba(239,68,68,0.8)'; | |
| deleteBtn.textContent = '删除'; | |
| deleteBtn.title = '删除图片'; | |
| deleteBtn.addEventListener('touchstart', (e) => { | |
| e.stopPropagation(); | |
| }, { passive: true }); | |
| deleteBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| this.deleteItem(item.id); | |
| }; | |
| actions.appendChild(shareBtn); | |
| actions.appendChild(downloadBtn); | |
| actions.appendChild(deleteBtn); | |
| el.appendChild(actions); | |
| // Open modal | |
| el.onclick = () => ModalManager.open(item); | |
| return el; | |
| }, | |
| updateCardImage(item) { | |
| if (!this.container || !item) return; | |
| const img = this.container.querySelector(`[data-id="${item.id}"] img`); | |
| if (!img) return; | |
| const thumbSrc = resolveAssetUrl(item.thumb || item.thumbUrl); | |
| const fullSrc = ImageHandler.getFullImageUrl(item) || resolveAssetUrl(item.imageUrl); | |
| let nextSrc = thumbSrc || ImageHandler.getDisplayImage(item) || fullSrc; | |
| if (Device.isMobile && item.isNew && fullSrc && !thumbSrc) { | |
| nextSrc = fullSrc; | |
| } | |
| if (nextSrc) { | |
| img.src = nextSrc; | |
| img.removeAttribute('data-src'); | |
| } | |
| }, | |
| queueThumbnails(items) { | |
| if (!AppState.db) return; | |
| if (this.thumbProcessing) { | |
| this.pendingThumbItems = items; | |
| return; | |
| } | |
| const candidates = Array.isArray(items) ? items : this.getVisibleItems(); | |
| const queue = candidates.filter((item) => { | |
| if (item.thumb || item.thumbUrl) return false; | |
| const source = item.image || item.imageUrl; | |
| if (!source || typeof source !== 'string') return false; | |
| return source.startsWith('data:image') | |
| || source.startsWith('/') | |
| || source.startsWith(window.location.origin); | |
| }); | |
| if (queue.length === 0) return; | |
| this.thumbProcessing = true; | |
| const maxWidth = Device.isMobile ? 1024 : 768; | |
| const quality = Device.isMobile ? 0.82 : 0.76; | |
| const processNext = async () => { | |
| const item = queue.shift(); | |
| if (!item) { | |
| this.thumbProcessing = false; | |
| const pending = this.pendingThumbItems; | |
| this.pendingThumbItems = null; | |
| if (pending) { | |
| this.queueThumbnails(pending); | |
| } | |
| return; | |
| } | |
| const source = resolveAssetUrl(item.image || item.imageUrl); | |
| const thumb = await ImageHandler.createThumbnail(source, maxWidth, quality); | |
| if (thumb && thumb !== source) { | |
| item.thumb = thumb; | |
| Database.update(item.id, { thumb }).catch(() => {}); | |
| this.updateCardImage(item); | |
| } | |
| if ('requestIdleCallback' in window) { | |
| requestIdleCallback(processNext); | |
| } else { | |
| setTimeout(processNext, 120); | |
| } | |
| }; | |
| if ('requestIdleCallback' in window) { | |
| requestIdleCallback(processNext); | |
| } else { | |
| setTimeout(processNext, 120); | |
| } | |
| }, | |
| async downloadImage(item) { | |
| const source = ImageHandler.getDownloadUrl(item) || item.image || item.thumb; | |
| const filename = `banana-pro-${item.id}-${Date.now()}.png`; | |
| await ImageHandler.triggerDownload(source, filename); | |
| }, | |
| async deleteItem(id) { | |
| if (!confirm('确定要删除这张图片吗?')) return; | |
| try { | |
| const targetItem = (AppState.galleryData || []).find((item) => item.id === id); | |
| if (targetItem) { | |
| ImageHandler.revokeDisplayUrl(targetItem); | |
| } | |
| if (!AppState.db) { | |
| AppState.galleryData = (AppState.galleryData || []).filter((item) => item.id !== id); | |
| this.render(); | |
| return; | |
| } | |
| await Database.delete(id); | |
| await this.load(); | |
| } catch (err) { | |
| console.error('删除失败:', err); | |
| alert('删除失败'); | |
| } | |
| } | |
| }; | |
| // ============================================ | |
| // ============================================ | |
| // 弹窗管理模块 | |
| // ============================================ | |
| const ModalManager = { | |
| modal: null, | |
| imgEl: null, | |
| promptEl: null, | |
| refsEl: null, | |
| reuseBtn: null, | |
| fullscreenBtn: null, | |
| downloadBtn: null, | |
| init() { | |
| this.modal = document.getElementById('modal'); | |
| this.imgEl = document.getElementById('m-img'); | |
| this.promptEl = document.getElementById('m-prompt'); | |
| this.refsEl = document.getElementById('m-refs'); | |
| this.reuseBtn = document.getElementById('m-reuse'); | |
| this.fullscreenBtn = document.getElementById('m-fullscreen'); | |
| this.downloadBtn = document.getElementById('m-download'); | |
| // 点击背景关闭 | |
| this.modal.onclick = (e) => { | |
| if (e.target === this.modal) this.close(); | |
| }; | |
| // ESC 关闭 | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'Escape') this.close(); | |
| }); | |
| }, | |
| open(item) { | |
| AppState.currentModalItem = item; | |
| // 设置主图 - 直接赋�?? | |
| const fullUrl = ImageHandler.getFullImageUrl(item); | |
| const thumbSrc = resolveAssetUrl(item.thumb || item.thumbUrl); | |
| const displaySrc = ImageHandler.getDisplayImage(item); | |
| const initialSrc = fullUrl || thumbSrc || displaySrc || ''; | |
| this.imgEl.src = initialSrc; | |
| this.imgEl.onerror = () => { | |
| if (fullUrl && this.imgEl.src !== fullUrl) { | |
| this.imgEl.src = fullUrl; | |
| return; | |
| } | |
| if (thumbSrc && this.imgEl.src !== thumbSrc) { | |
| this.imgEl.src = thumbSrc; | |
| return; | |
| } | |
| if (displaySrc && this.imgEl.src !== displaySrc) { | |
| this.imgEl.src = displaySrc; | |
| } | |
| }; | |
| const setFullFromBase64 = () => { | |
| if (AppState.currentModalItem === item) { | |
| this.imgEl.src = item.image; | |
| } | |
| }; | |
| const allowFullSwap = !Device.isMobile | |
| || !fullUrl; | |
| if (item.image && item.image.startsWith('data:image') && !fullUrl && allowFullSwap) { | |
| if (item.image.length > 1200000) { | |
| ImageHandler.dataUrlToObjectUrlAsync(item.image).then((url) => { | |
| if (url) { | |
| item.displayUrl = url; | |
| if (AppState.currentModalItem === item) { | |
| this.imgEl.src = url; | |
| } | |
| return; | |
| } | |
| if ('requestIdleCallback' in window) { | |
| requestIdleCallback(setFullFromBase64); | |
| } else { | |
| setTimeout(setFullFromBase64, 0); | |
| } | |
| }); | |
| } else if (initialSrc !== item.image) { | |
| if ('requestIdleCallback' in window) { | |
| requestIdleCallback(setFullFromBase64); | |
| } else { | |
| setTimeout(setFullFromBase64, 0); | |
| } | |
| } | |
| } | |
| if (fullUrl && initialSrc !== fullUrl) { | |
| const preloader = new Image(); | |
| preloader.onload = () => { | |
| if (AppState.currentModalItem === item) { | |
| this.imgEl.src = fullUrl; | |
| } | |
| }; | |
| preloader.src = fullUrl; | |
| } | |
| // 设置提示�? | |
| this.promptEl.textContent = item.prompt; | |
| // 渲染参�?�图 | |
| this.refsEl.innerHTML = ''; | |
| if (item.inputImages && item.inputImages.length > 0) { | |
| item.inputImages.forEach(imgData => { | |
| const thumb = document.createElement('img'); | |
| thumb.className = 'ref-thumb'; | |
| thumb.src = imgData; // 直接赋�?? | |
| thumb.onclick = () => window.open(imgData, '_blank'); | |
| this.refsEl.appendChild(thumb); | |
| }); | |
| } | |
| // 绑定复用按钮 | |
| this.reuseBtn.onclick = () => this.reuse(); | |
| if (this.fullscreenBtn) { | |
| this.fullscreenBtn.onclick = () => this.toggleFullscreen(); | |
| } | |
| if (this.downloadBtn) { | |
| this.downloadBtn.addEventListener('touchstart', (e) => { | |
| e.stopPropagation(); | |
| }, { passive: true }); | |
| this.downloadBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| this.downloadCurrent(); | |
| }; | |
| } | |
| this.modal.style.display = 'flex'; | |
| }, | |
| close() { | |
| this.modal.style.display = 'none'; | |
| AppState.currentModalItem = null; | |
| if (document.fullscreenElement && document.exitFullscreen) { | |
| document.exitFullscreen(); | |
| } else if (document.webkitFullscreenElement && document.webkitExitFullscreen) { | |
| document.webkitExitFullscreen(); | |
| } | |
| }, | |
| reuse() { | |
| const item = AppState.currentModalItem; | |
| if (!item) return; | |
| // 复用提示�? | |
| const textarea = document.getElementById('prompt'); | |
| textarea.value = item.prompt; | |
| textarea.style.height = 'auto'; | |
| textarea.style.height = textarea.scrollHeight + 'px'; | |
| // 复用参�?�图 | |
| if (item.inputImages && item.inputImages.length > 0) { | |
| ImageHandler.setImages(item.inputImages); | |
| } else { | |
| ImageHandler.clear(); | |
| } | |
| this.close(); | |
| textarea.focus(); | |
| }, | |
| toggleFullscreen() { | |
| const target = this.imgEl; | |
| if (!target) return; | |
| const item = AppState.currentModalItem; | |
| if (document.fullscreenElement || document.webkitFullscreenElement) { | |
| if (document.exitFullscreen) { | |
| document.exitFullscreen(); | |
| } else if (document.webkitExitFullscreen) { | |
| document.webkitExitFullscreen(); | |
| } | |
| return; | |
| } | |
| if (Device.isMobile && item && item.image && item.image.startsWith('data:image')) { | |
| const fullUrl = ImageHandler.getFullImageUrl(item); | |
| if (!fullUrl && target.src !== item.image) { | |
| ImageHandler.dataUrlToObjectUrlAsync(item.image).then((url) => { | |
| if (url) { | |
| item.displayUrl = url; | |
| target.src = url; | |
| } else { | |
| target.src = item.image; | |
| } | |
| }); | |
| } | |
| } | |
| if (target.requestFullscreen) { | |
| target.requestFullscreen(); | |
| } else if (target.webkitRequestFullscreen) { | |
| target.webkitRequestFullscreen(); | |
| } | |
| }, | |
| async downloadCurrent() { | |
| const item = AppState.currentModalItem; | |
| if (!item) return; | |
| const source = ImageHandler.getDownloadUrl(item) || item.image || item.thumb; | |
| if (!source) return; | |
| const filename = `banana-pro-${item.id || Date.now()}.png`; | |
| await ImageHandler.triggerDownload(source, filename); | |
| } | |
| }; | |
| // ============================================ | |
| // 公共画廊管理模块 | |
| // ============================================ | |
| const PublicGalleryManager = { | |
| container: null, | |
| refreshBtn: null, | |
| hintEl: null, | |
| loadMoreBtn: null, | |
| pageSize: 12, | |
| visibleCount: 0, | |
| thumbProcessing: false, | |
| pendingThumbItems: null, | |
| tokens: {}, | |
| isLoading: false, | |
| defaultHint: DEFAULT_PUBLIC_HINT, | |
| hintTimer: null, | |
| syncTimer: null, | |
| syncInterval: 0, | |
| loadTimeout: null, | |
| lastFetchTime: 0, | |
| minFetchInterval: 5000, | |
| init() { | |
| this.container = document.getElementById('public-gallery'); | |
| this.refreshBtn = document.getElementById('refresh-public-gallery'); | |
| this.hintEl = document.getElementById('public-gallery-hint'); | |
| this.loadMoreBtn = document.getElementById('public-gallery-load-more'); | |
| this.pageSize = Device.isMobile ? 12 : 12; | |
| this.visibleCount = this.pageSize; | |
| this.tokens = this.loadTokens(); | |
| if (this.hintEl && this.hintEl.textContent) { | |
| this.defaultHint = this.hintEl.textContent; | |
| } | |
| this.setHint(this.defaultHint); | |
| if (this.refreshBtn) { | |
| this.refreshBtn.onclick = () => this.fetch(); | |
| } | |
| if (this.loadMoreBtn) { | |
| this.loadMoreBtn.onclick = () => this.loadMore(); | |
| } | |
| this.bindAutoLoad(); | |
| this.initialFetch(); | |
| }, | |
| bindAutoLoad() { | |
| if (!Device.isMobile || this.autoLoadBound) return; | |
| const scroller = document.querySelector('.history-container'); | |
| if (!scroller) return; | |
| this.autoLoadBound = true; | |
| this.scroller = scroller; | |
| this.autoLoading = false; | |
| const onScroll = () => { | |
| if (this.autoLoading) return; | |
| const total = (AppState.publicGalleryData || []).length; | |
| if (!total || this.visibleCount >= total) return; | |
| const nearBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 240; | |
| if (!nearBottom) return; | |
| this.autoLoading = true; | |
| this.loadMore(); | |
| setTimeout(() => { | |
| this.autoLoading = false; | |
| }, 120); | |
| }; | |
| scroller.addEventListener('scroll', onScroll, { passive: true }); | |
| this.autoLoadHandler = onScroll; | |
| }, | |
| resetPagination() { | |
| this.visibleCount = this.pageSize; | |
| }, | |
| getVisibleItems() { | |
| const items = AppState.publicGalleryData || []; | |
| const total = items.length; | |
| if (!this.visibleCount || this.visibleCount < this.pageSize) { | |
| this.visibleCount = this.pageSize; | |
| } | |
| const count = Math.min(this.visibleCount, total); | |
| return items.slice(0, count); | |
| }, | |
| updateLoadMore(totalCount) { | |
| if (!this.loadMoreBtn) return; | |
| const visible = Math.min(this.visibleCount || this.pageSize, totalCount); | |
| const hasMore = totalCount > visible; | |
| const wrapper = this.loadMoreBtn.parentElement; | |
| if (wrapper) { | |
| wrapper.style.display = hasMore ? 'flex' : 'none'; | |
| } | |
| this.loadMoreBtn.style.display = hasMore ? 'inline-flex' : 'none'; | |
| if (hasMore) { | |
| this.loadMoreBtn.textContent = `加载更多 (${visible}/${totalCount})`; | |
| } | |
| }, | |
| loadMore() { | |
| const total = AppState.publicGalleryData.length; | |
| if (!total || this.visibleCount >= total) return; | |
| const scroller = this.container ? this.container.closest('.history-container') : null; | |
| const scrollTop = scroller ? scroller.scrollTop : window.scrollY; | |
| this.visibleCount = Math.min(total, (this.visibleCount || this.pageSize) + this.pageSize); | |
| this.render({ append: true }); | |
| requestAnimationFrame(() => { | |
| if (scroller) { | |
| scroller.scrollTop = scrollTop; | |
| } else { | |
| window.scrollTo(0, scrollTop); | |
| } | |
| }); | |
| }, | |
| async initialFetch() { | |
| await this.fetch(); | |
| }, | |
| startRealtimeSync() { | |
| // 实时同步已禁�?- 改为手动刷新提高性能 | |
| }, | |
| stopRealtimeSync() { | |
| if (this.syncTimer) { | |
| clearInterval(this.syncTimer); | |
| this.syncTimer = null; | |
| } | |
| }, | |
| loadTokens() { | |
| try { | |
| const raw = localStorage.getItem(STORAGE_KEYS.publicGalleryTokens); | |
| return raw ? JSON.parse(raw) : {}; | |
| } catch (error) { | |
| console.warn('无法读取公共画廊令牌:', error); | |
| return {}; | |
| } | |
| }, | |
| saveTokens() { | |
| try { | |
| localStorage.setItem(STORAGE_KEYS.publicGalleryTokens, JSON.stringify(this.tokens)); | |
| } catch (error) { | |
| console.warn('无法保存公共画廊令牌:', error); | |
| } | |
| }, | |
| setHint(message, mode = 'default') { | |
| if (!this.hintEl) return; | |
| this.hintEl.textContent = message; | |
| this.hintEl.classList.remove('is-error', 'is-success'); | |
| if (mode === 'error') { | |
| this.hintEl.classList.add('is-error'); | |
| } else if (mode === 'success') { | |
| this.hintEl.classList.add('is-success'); | |
| } | |
| clearTimeout(this.hintTimer); | |
| if (mode !== 'default') { | |
| this.hintTimer = setTimeout(() => { | |
| this.setHint(this.defaultHint); | |
| }, 4000); | |
| } | |
| }, | |
| setLoading(loading) { | |
| this.isLoading = loading; | |
| if (this.refreshBtn) { | |
| this.refreshBtn.classList.toggle('spinning', loading); | |
| this.refreshBtn.disabled = loading; | |
| } | |
| // 防止加载超时 - 30秒后强制停止 | |
| if (loading) { | |
| clearTimeout(this.loadTimeout); | |
| this.loadTimeout = setTimeout(() => { | |
| if (this.isLoading) { | |
| this.setLoading(false); | |
| this.setHint('加载超时,请稍后重试', 'error'); | |
| } | |
| }, 30000); | |
| } else { | |
| clearTimeout(this.loadTimeout); | |
| } | |
| }, | |
| async fetch() { | |
| if (!this.container) return; | |
| // 防止过于频繁的请�? | |
| const now = Date.now(); | |
| if (now - this.lastFetchTime < this.minFetchInterval) { | |
| return; | |
| } | |
| this.lastFetchTime = now; | |
| this.setLoading(true); | |
| try { | |
| const controller = new AbortController(); | |
| const timeoutId = setTimeout(() => controller.abort(), 15000); | |
| const query = Device.isMobile ? '?lite=1' : ''; | |
| const res = await apiFetch(`/api/public-gallery${query}`, { | |
| signal: controller.signal | |
| }); | |
| clearTimeout(timeoutId); | |
| const data = await res.json(); | |
| if (!res.ok || !data.success) { | |
| throw new Error(data.message || '无法加载公共画廊'); | |
| } | |
| AppState.publicGalleryData = data.items || []; | |
| this.resetPagination(); | |
| this.render(); | |
| this.setHint(this.defaultHint); | |
| } catch (error) { | |
| console.error('发布到公共画廊失败:', error); | |
| let errorMessage = '加载失败,请稍后重试'; | |
| if (error.name === 'AbortError') { | |
| errorMessage = '加载超时,请检查网络'; | |
| this.setHint('加载超时,请检查网络', 'error'); | |
| } else { | |
| this.setHint(errorMessage, 'error'); | |
| } | |
| if (AppState.publicGalleryData.length === 0) { | |
| this.showEmpty(errorMessage); | |
| } | |
| } finally { | |
| this.setLoading(false); | |
| } | |
| }, | |
| showEmpty(message) { | |
| if (!this.container) return; | |
| this.updateLoadMore(0); | |
| this.container.innerHTML = ` | |
| <div class="public-gallery-empty"> | |
| <div>暂无</div> | |
| <p>${message}</p> | |
| </div> | |
| `; | |
| }, | |
| render(options = {}) { | |
| if (!this.container) return; | |
| const append = options.append === true; | |
| if (!append) { | |
| this.container.innerHTML = ''; | |
| } | |
| this.renderToken = (this.renderToken || 0) + 1; | |
| const token = this.renderToken; | |
| const totalCount = (AppState.publicGalleryData || []).length; | |
| if (totalCount === 0) { | |
| this.showEmpty('\u8fd8\u6ca1\u6709\u4eba\u5206\u4eab\u4f5c\u54c1\uff0c\u6210\u4e3a\u7b2c\u4e00\u4e2a\u5427\uff01'); | |
| return; | |
| } | |
| const items = this.getVisibleItems(); | |
| const startIndex = append ? this.container.childElementCount : 0; | |
| const renderItems = append ? items.slice(startIndex) : items; | |
| this.updateLoadMore(totalCount); | |
| const batchSize = this.pageSize; | |
| let index = 0; | |
| const renderBatch = () => { | |
| if (token != this.renderToken) return; | |
| const fragment = document.createDocumentFragment(); | |
| for (let i = 0; i < batchSize && index < renderItems.length; i += 1) { | |
| const card = this.createCard(renderItems[index]); | |
| fragment.appendChild(card); | |
| index += 1; | |
| } | |
| this.container.appendChild(fragment); | |
| if (index < renderItems.length) { | |
| if ('requestIdleCallback' in window) { | |
| requestIdleCallback(renderBatch); | |
| } else { | |
| requestAnimationFrame(renderBatch); | |
| } | |
| } | |
| }; | |
| renderBatch(); | |
| this.queueThumbnails(renderItems.length ? renderItems : items); | |
| }, | |
| queueThumbnails(items) { | |
| if (!Device.isMobile) return; | |
| if (this.thumbProcessing) { | |
| this.pendingThumbItems = items; | |
| return; | |
| } | |
| const candidates = Array.isArray(items) ? items : this.getVisibleItems(); | |
| const queue = candidates.filter((item) => { | |
| if (item.thumb || item.thumbUrl) return false; | |
| const source = item.image || item.imageUrl; | |
| if (!source || typeof source !== 'string') return false; | |
| return source.startsWith('data:image') | |
| || source.startsWith('/') | |
| || source.startsWith(window.location.origin); | |
| }); | |
| if (queue.length === 0) return; | |
| this.thumbProcessing = true; | |
| const maxWidth = 1024; | |
| const quality = 0.82; | |
| const processNext = async () => { | |
| const item = queue.shift(); | |
| if (!item) { | |
| this.thumbProcessing = false; | |
| const pending = this.pendingThumbItems; | |
| this.pendingThumbItems = null; | |
| if (pending) { | |
| this.queueThumbnails(pending); | |
| } | |
| return; | |
| } | |
| const source = resolveAssetUrl(item.image || item.imageUrl); | |
| const thumb = await ImageHandler.createThumbnail(source, maxWidth, quality); | |
| if (thumb && thumb !== source) { | |
| item.thumb = thumb; | |
| this.updateCardImage(item); | |
| } | |
| if ('requestIdleCallback' in window) { | |
| requestIdleCallback(processNext); | |
| } else { | |
| setTimeout(processNext, 120); | |
| } | |
| }; | |
| if ('requestIdleCallback' in window) { | |
| requestIdleCallback(processNext); | |
| } else { | |
| setTimeout(processNext, 120); | |
| } | |
| }, | |
| createCard(item) { | |
| const card = document.createElement('div'); | |
| card.className = 'public-card'; | |
| card.dataset.id = item.id; | |
| const img = document.createElement('img'); | |
| img.loading = 'lazy'; | |
| img.decoding = 'async'; | |
| img.fetchPriority = 'low'; | |
| const thumbSrc = resolveAssetUrl(item.thumb || item.thumbUrl); | |
| const fullSrc = ImageHandler.getFullImageUrl(item) || resolveAssetUrl(item.imageUrl); | |
| const imageSrc = thumbSrc || ImageHandler.getDisplayImage(item) || fullSrc; | |
| LazyImageLoader.observe(img, imageSrc); | |
| img.alt = item.prompt || '创意作品'; | |
| img.onerror = () => { | |
| if (fullSrc && img.src !== fullSrc) { | |
| img.src = fullSrc; | |
| } | |
| }; | |
| card.appendChild(img); | |
| const info = document.createElement('div'); | |
| info.className = 'public-card-info'; | |
| const promptText = document.createElement('p'); | |
| promptText.className = 'public-card-prompt'; | |
| promptText.textContent = item.prompt || '未提供提示词'; | |
| promptText.title = item.prompt; | |
| info.appendChild(promptText); | |
| const footer = document.createElement('div'); | |
| footer.className = 'public-card-footer'; | |
| const timeText = document.createElement('span'); | |
| timeText.textContent = this.formatTimestamp(item.timestamp); | |
| footer.appendChild(timeText); | |
| if (this.canDelete(item.id)) { | |
| const deleteBtn = document.createElement('button'); | |
| deleteBtn.className = 'icon-btn small public-delete-btn'; | |
| deleteBtn.textContent = '删除'; | |
| deleteBtn.title = '删除这张作品'; | |
| deleteBtn.addEventListener('touchstart', (e) => { | |
| e.stopPropagation(); | |
| }, { passive: true }); | |
| deleteBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| this.delete(item.id); | |
| }; | |
| footer.appendChild(deleteBtn); | |
| } | |
| info.appendChild(footer); | |
| card.appendChild(info); | |
| card.onclick = () => ModalManager.open(item); | |
| return card; | |
| }, | |
| updateCardImage(item) { | |
| if (!this.container || !item) return; | |
| const img = this.container.querySelector(`[data-id="${item.id}"] img`); | |
| if (!img) return; | |
| const thumbSrc = resolveAssetUrl(item.thumb || item.thumbUrl); | |
| const fullSrc = ImageHandler.getFullImageUrl(item) || resolveAssetUrl(item.imageUrl); | |
| const nextSrc = thumbSrc || ImageHandler.getDisplayImage(item) || fullSrc; | |
| if (nextSrc) { | |
| img.src = nextSrc; | |
| img.removeAttribute('data-src'); | |
| } | |
| }, | |
| formatTimestamp(timestamp) { | |
| if (!timestamp) return ''; | |
| const date = new Date(timestamp); | |
| if (Number.isNaN(date.getTime())) { | |
| return ''; | |
| } | |
| return date.toLocaleString(); | |
| }, | |
| canDelete(id) { | |
| return true; | |
| }, | |
| markOwned(id, token) { | |
| if (!id || !token) return; | |
| this.tokens[id] = token; | |
| this.saveTokens(); | |
| }, | |
| removeOwnership(id) { | |
| if (this.tokens[id]) { | |
| delete this.tokens[id]; | |
| this.saveTokens(); | |
| } | |
| }, | |
| async share(item, triggerBtn) { | |
| if (!item) return; | |
| const confirmShare = confirm('确认将这张作品发布到公共画廊?提示词将对所有人可见。'); | |
| if (!confirmShare) { | |
| return; | |
| } | |
| if (triggerBtn) { | |
| triggerBtn.disabled = true; | |
| } | |
| try { | |
| const payload = { | |
| prompt: item.prompt, | |
| inputImages: item.inputImages || [] | |
| }; | |
| const sourceImage = item.imageUrl || item.image; | |
| if (sourceImage && typeof sourceImage === 'string') { | |
| if (sourceImage.startsWith('data:image')) { | |
| payload.image = sourceImage; | |
| } else { | |
| payload.imageUrl = sourceImage; | |
| } | |
| } | |
| if (item.imageId) { | |
| payload.imageId = item.imageId; | |
| } | |
| const res = await apiFetch('/api/public-gallery', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload) | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok || !data.success) { | |
| throw new Error(data.message || '发布失败'); | |
| } | |
| AppState.publicGalleryData = [data.item, ...(AppState.publicGalleryData || [])]; | |
| this.markOwned(data.item.id, data.deleteToken); | |
| this.render(); | |
| this.setHint('作品已发布至公共画廊', 'success'); | |
| } catch (error) { | |
| console.error('发布到公共画廊失败:', error); | |
| alert('发布失败: ' + error.message); | |
| this.setHint('发布失败,请稍后重试', 'error'); | |
| } finally { | |
| if (triggerBtn) { | |
| triggerBtn.disabled = false; | |
| } | |
| } | |
| }, | |
| async delete(id) { | |
| const confirmDelete = confirm('确定要删除这张公共画廊作品吗?'); | |
| if (!confirmDelete) { | |
| return; | |
| } | |
| try { | |
| const res = await apiFetch(`/api/public-gallery/${id}`, { | |
| method: 'DELETE', | |
| headers: { 'Content-Type': 'application/json' } | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok || !data.success) { | |
| throw new Error(data.message || '删除失败'); | |
| } | |
| const targetItem = (AppState.publicGalleryData || []).find((item) => item.id === id); | |
| if (targetItem) { | |
| ImageHandler.revokeDisplayUrl(targetItem); | |
| } | |
| AppState.publicGalleryData = AppState.publicGalleryData.filter(item => item.id !== id); | |
| this.removeOwnership(id); | |
| this.render(); | |
| this.setHint('作品已删除', 'success'); | |
| } catch (error) { | |
| console.error('发布到公共画廊失败:', error); | |
| alert('删除失败: ' + error.message); | |
| this.setHint('删除失败,请稍后再试', 'error'); | |
| } | |
| } | |
| }; | |
| // ============================================ | |
| // 生成请求模块 | |
| // ============================================ | |
| const Generator = { | |
| sendBtn: null, | |
| uploadBtn: null, | |
| clearBtn: null, | |
| textarea: null, | |
| isGenerating: false, | |
| generateTimeout: null, | |
| requestTimeoutId: null, | |
| currentController: null, | |
| generateStartedAt: 0, | |
| generateTimeoutMs: 0, | |
| abortMessage: '', | |
| init() { | |
| this.sendBtn = document.getElementById('send-btn'); | |
| this.uploadBtn = document.getElementById('upload-btn'); | |
| this.clearBtn = document.getElementById('clear-btn'); | |
| this.textarea = document.getElementById('prompt'); | |
| if (window.matchMedia('(max-width: 768px)').matches) { | |
| this.textarea.classList.add('horizontal-scroll'); | |
| this.textarea.setAttribute('wrap', 'off'); | |
| } | |
| this.sendBtn.onclick = () => this.generate(); | |
| if (this.clearBtn) { | |
| this.clearBtn.onclick = () => { | |
| const hasPrompt = this.textarea.value.trim().length > 0; | |
| const hasImages = AppState.currentImages.length > 0; | |
| if (!hasPrompt && !hasImages) { | |
| return; | |
| } | |
| this.textarea.value = ''; | |
| this.textarea.style.height = 'auto'; | |
| ImageHandler.clear(); | |
| StatusBar.resetToContext(); | |
| }; | |
| } | |
| this.textarea.addEventListener('input', () => { | |
| this.textarea.style.height = 'auto'; | |
| this.textarea.style.height = this.textarea.scrollHeight + 'px'; | |
| }); | |
| this.textarea.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| this.generate(); | |
| } | |
| }); | |
| }, | |
| setLoading(loading) { | |
| this.isGenerating = loading; | |
| if (loading) { | |
| this.sendBtn.classList.add('loading'); | |
| this.sendBtn.disabled = true; | |
| if (this.clearBtn) { | |
| this.clearBtn.disabled = true; | |
| } | |
| if (this.uploadBtn) { | |
| this.uploadBtn.disabled = true; | |
| } | |
| StatusBar.startCountdown(60); | |
| const uiTimeoutMs = Device.isMobile ? 300000 : 180000; | |
| clearTimeout(this.generateTimeout); | |
| this.generateTimeout = setTimeout(() => { | |
| if (this.isGenerating) { | |
| this.setLoading(false); | |
| StatusBar.flash("生成超时"); | |
| alert("生成超时,请检查网络后重试"); | |
| } | |
| }, uiTimeoutMs); | |
| } else { | |
| this.sendBtn.classList.remove('loading'); | |
| this.sendBtn.disabled = false; | |
| if (this.clearBtn) { | |
| this.clearBtn.disabled = false; | |
| } | |
| if (this.uploadBtn) { | |
| this.uploadBtn.disabled = false; | |
| } | |
| clearTimeout(this.generateTimeout); | |
| StatusBar.stopCountdown(); | |
| } | |
| }, | |
| handleVisibilityRestore() { | |
| if (!this.isGenerating || !this.currentController) return; | |
| const startedAt = this.generateStartedAt || 0; | |
| const timeoutMs = this.generateTimeoutMs || 0; | |
| if (!startedAt || !timeoutMs) return; | |
| const elapsed = Date.now() - startedAt; | |
| if (elapsed > timeoutMs) { | |
| this.abortMessage = "切换后台导致请求暂停,请回到前台后重试"; | |
| this.currentController.abort(); | |
| } | |
| }, | |
| async generate() { | |
| if (this.isGenerating) return; | |
| const prompt = this.textarea.value.trim(); | |
| if (!prompt) { | |
| alert("\u8bf7\u8f93\u5165\u63d0\u793a\u8bcd"); | |
| return; | |
| } | |
| this.setLoading(true); | |
| let statusMessage = null; | |
| try { | |
| const controller = new AbortController(); | |
| const timeoutMs = Device.isMobile ? 300000 : 120000; | |
| this.currentController = controller; | |
| this.generateStartedAt = Date.now(); | |
| this.generateTimeoutMs = timeoutMs; | |
| this.abortMessage = ''; | |
| clearTimeout(this.requestTimeoutId); | |
| this.requestTimeoutId = setTimeout(() => { | |
| this.abortMessage = "生成超时,请检查网络后重试"; | |
| controller.abort(); | |
| }, timeoutMs); | |
| const wantsUrl = true; | |
| const wantsBase64 = Device.isMobile ? false : RESPONSE_MODE !== 'url'; | |
| const requestBody = { | |
| prompt: prompt, | |
| images: AppState.currentImages, | |
| preferUrl: wantsUrl, | |
| returnBase64: wantsBase64, | |
| imageSize: IMAGE_SIZE | |
| }; | |
| const response = await apiFetch('/api/generate', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(requestBody), | |
| signal: controller.signal | |
| }); | |
| clearTimeout(this.requestTimeoutId); | |
| this.requestTimeoutId = null; | |
| const data = await response.json(); | |
| if (!response.ok || !data.success) { | |
| throw new Error(data.message || "\u751f\u6210\u5931\u8d25"); | |
| } | |
| const rawImage = typeof data.image === 'string' ? data.image : null; | |
| const rawImageUrl = typeof data.imageUrl === 'string' ? data.imageUrl : null; | |
| let imageSrc = null; | |
| if (Device.isMobile && rawImageUrl) { | |
| imageSrc = rawImageUrl; | |
| } else { | |
| imageSrc = rawImageUrl || rawImage; | |
| } | |
| if (!imageSrc || typeof imageSrc !== 'string') { | |
| throw new Error("\u8fd4\u56de\u7684\u56fe\u7247\u6570\u636e\u65e0\u6548"); | |
| } | |
| const previewImage = imageSrc; | |
| const imageUrl = rawImageUrl || (previewImage && !previewImage.startsWith('data:image') ? previewImage : null); | |
| const thumbWidth = 768; | |
| const thumbQuality = 0.76; | |
| const serverThumb = typeof data.thumbUrl === 'string' ? data.thumbUrl : null; | |
| let thumb = serverThumb; | |
| if (!thumb && !Device.isMobile) { | |
| thumb = await ImageHandler.createThumbnail(previewImage, thumbWidth, thumbQuality); | |
| } | |
| const newItem = { | |
| id: `temp-${Date.now()}`, | |
| prompt: prompt, | |
| image: previewImage, | |
| imageUrl: imageUrl, | |
| imageId: data.imageId || null, | |
| thumb: thumb, | |
| inputImages: [...AppState.currentImages], | |
| isNew: true, | |
| timestamp: Date.now() | |
| }; | |
| AppState.galleryData = [newItem, ...(AppState.galleryData || [])]; | |
| GalleryManager.render(); | |
| if (Device.isMobile && previewImage.startsWith('data:image')) { | |
| const reduceTask = async () => { | |
| try { | |
| if (!newItem.thumb) { | |
| const newThumb = await ImageHandler.createThumbnail(previewImage, 1024, 0.82); | |
| if (newThumb && newThumb !== previewImage) { | |
| newItem.thumb = newThumb; | |
| } | |
| } | |
| if (AppState.db && newItem.id && !String(newItem.id).startsWith('temp-')) { | |
| Database.update(newItem.id, { thumb: newItem.thumb }).catch(() => {}); | |
| } | |
| GalleryManager.updateCardImage(newItem); | |
| } catch (error) { | |
| console.warn('Mobile image async reduce failed:', error); | |
| } | |
| }; | |
| if ('requestIdleCallback' in window) { | |
| requestIdleCallback(reduceTask); | |
| } else { | |
| setTimeout(reduceTask, 0); | |
| } | |
| } | |
| const savePayload = { | |
| prompt: prompt, | |
| image: previewImage, | |
| imageUrl: imageUrl, | |
| imageId: data.imageId || null, | |
| thumb: thumb, | |
| inputImages: [...AppState.currentImages] | |
| }; | |
| const handleSaveError = (saveErr) => { | |
| console.warn('Local save failed:', saveErr); | |
| StatusBar.flash("\u672c\u5730\u4fdd\u5b58\u5931\u8d25\uff0c\u4ec5\u672c\u6b21\u53ef\u89c1"); | |
| }; | |
| if (AppState.db) { | |
| if (Device.isMobile) { | |
| Database.save(savePayload) | |
| .then((savedId) => { | |
| if (savedId) { | |
| newItem.id = savedId; | |
| } | |
| }) | |
| .catch(handleSaveError); | |
| } else { | |
| try { | |
| const savedId = await Database.save(savePayload); | |
| if (savedId) { | |
| newItem.id = savedId; | |
| } | |
| } catch (saveErr) { | |
| handleSaveError(saveErr); | |
| } | |
| } | |
| } else { | |
| StatusBar.flash("\u672c\u5730\u5b58\u50a8\u4e0d\u53ef\u7528\uff0c\u4ec5\u672c\u6b21\u53ef\u89c1"); | |
| } | |
| this.textarea.value = ''; | |
| this.textarea.style.height = 'auto'; | |
| ImageHandler.clear(); | |
| statusMessage = "\u751f\u6210\u6210\u529f"; | |
| } catch (err) { | |
| console.error('Generate failed:', err); | |
| let errorMessage = "\u751f\u6210\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5"; | |
| if (err.message.includes("API\u8ba4\u8bc1\u5931\u8d25")) { | |
| errorMessage = "API\u8ba4\u8bc1\u5931\u8d25\uff0c\u8bf7\u8054\u7cfb\u7ba1\u7406\u5458\u68c0\u67e5API\u914d\u7f6e"; | |
| } else if (err.message.includes("\u65e0\u6cd5\u8fde\u63a5\u5230API\u670d\u52a1\u5668")) { | |
| errorMessage = "\u65e0\u6cd5\u8fde\u63a5\u5230API\u670d\u52a1\u5668\uff0c\u8bf7\u68c0\u67e5\u7f51\u7edc\u8fde\u63a5"; | |
| } else if (err.message.includes("API\u8bf7\u6c42\u8d85\u65f6")) { | |
| errorMessage = "API\u8bf7\u6c42\u8d85\u65f6\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5"; | |
| } else if (err.message) { | |
| errorMessage = err.message; | |
| } | |
| if (err.name === 'AbortError') { | |
| statusMessage = "\u751f\u6210\u8d85\u65f6"; | |
| alert(this.abortMessage || "\u751f\u6210\u8d85\u65f6\uff0c\u8bf7\u68c0\u67e5\u7f51\u7edc\u540e\u91cd\u8bd5"); | |
| } else { | |
| statusMessage = "\u751f\u6210\u5931\u8d25"; | |
| alert(errorMessage); | |
| } | |
| } finally { | |
| if (this.requestTimeoutId) { | |
| clearTimeout(this.requestTimeoutId); | |
| this.requestTimeoutId = null; | |
| } | |
| this.currentController = null; | |
| this.generateStartedAt = 0; | |
| this.generateTimeoutMs = 0; | |
| this.abortMessage = ''; | |
| this.setLoading(false); | |
| if (statusMessage) { | |
| StatusBar.flash(statusMessage); | |
| } else { | |
| StatusBar.resetToContext(); | |
| } | |
| } | |
| } | |
| }; | |
| // ============================================ | |
| // 认证模块 | |
| // ============================================ | |
| const Auth = { | |
| async check() { | |
| try { | |
| const res = await apiFetch('/api/check-auth'); | |
| const data = await res.json(); | |
| return data.authenticated; | |
| } catch { | |
| return false; | |
| } | |
| }, | |
| async login(password) { | |
| try { | |
| const res = await apiFetch('/api/login', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ password }) | |
| }); | |
| let data = {}; | |
| try { | |
| data = await res.json(); | |
| } catch { | |
| data = {}; | |
| } | |
| if (!res.ok) { | |
| return { success: false, message: data.message || '登录失败' }; | |
| } | |
| return { success: Boolean(data.success), message: data.message || '' }; | |
| } catch (error) { | |
| return { success: false, message: '无法连接到登录接口,请确认后端服务已启动' }; | |
| } | |
| }, unlock() { | |
| document.getElementById('login-overlay').style.display = 'none'; | |
| const app = document.getElementById('app'); | |
| app.style.filter = 'none'; | |
| app.style.pointerEvents = 'all'; | |
| } | |
| }; | |
| // ============================================ | |
| // 拖拽上传图片模块 | |
| // ============================================ | |
| const DragDrop = { | |
| dropZone: null, | |
| init() { | |
| this.dropZone = document.getElementById('drop-zone'); | |
| // 阻止默认行为 | |
| ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(event => { | |
| this.dropZone.addEventListener(event, (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| }); | |
| }); | |
| // 拖入高亮 | |
| ['dragenter', 'dragover'].forEach(event => { | |
| this.dropZone.addEventListener(event, () => { | |
| this.dropZone.style.borderColor = 'var(--accent-color)'; | |
| this.dropZone.style.background = 'rgba(59, 130, 246, 0.1)'; | |
| }); | |
| }); | |
| // 拖出恢复 | |
| ['dragleave', 'drop'].forEach(event => { | |
| this.dropZone.addEventListener(event, () => { | |
| this.dropZone.style.borderColor = ''; | |
| this.dropZone.style.background = ''; | |
| }); | |
| }); | |
| // 放下处理 | |
| this.dropZone.addEventListener('drop', async (e) => { | |
| const files = e.dataTransfer.files; | |
| if (files.length > 0) { | |
| await ImageHandler.processFiles(files); | |
| } | |
| }); | |
| } | |
| }; | |
| // ============================================ | |
| // 文件选择模块 | |
| // ============================================ | |
| const FileSelector = { | |
| input: null, | |
| btn: null, | |
| init() { | |
| this.input = document.getElementById('file-input'); | |
| this.btn = document.getElementById('upload-btn'); | |
| this.btn.onclick = () => this.input.click(); | |
| this.input.onchange = async () => { | |
| if (this.input.files.length > 0) { | |
| await ImageHandler.processFiles(this.input.files); | |
| } | |
| this.input.value = ''; // 重置以便重复选择 | |
| }; | |
| } | |
| }; | |
| // ============================================ | |
| // 全局函数(供 HTML onclick 调用�? | |
| // ============================================ | |
| async function loadWorkspaceData() { | |
| try { | |
| await GalleryManager.load(); | |
| if ('requestIdleCallback' in window) { | |
| requestIdleCallback(() => PublicGalleryManager.fetch()); | |
| } else { | |
| setTimeout(() => PublicGalleryManager.fetch(), 300); | |
| } | |
| } catch (error) { | |
| console.error('加载画廊数据失败:', error); | |
| } | |
| } | |
| async function doLogin() { | |
| const pwd = document.getElementById('pwd').value; | |
| if (!pwd) return; | |
| const result = await Auth.login(pwd); | |
| if (result.success) { | |
| Auth.unlock(); | |
| await loadWorkspaceData(); | |
| } else { | |
| alert(result.message || '登录失败'); | |
| } | |
| } | |
| function closeModal() { | |
| ModalManager.close(); | |
| } | |
| // ============================================ | |
| // 应用初始�? | |
| // ============================================ | |
| async function initApp() { | |
| try { | |
| // 初始化数据库(允许失败,避免移动端被阻塞) | |
| try { | |
| await Database.init(); | |
| } catch (dbErr) { | |
| console.warn('Database init failed:', dbErr); | |
| } | |
| StatusBar.init(); | |
| // 初始化各模块 | |
| PreviewManager.init(); | |
| LazyImageLoader.init(); | |
| GalleryManager.init(); | |
| ModalManager.init(); | |
| PublicGalleryManager.init(); | |
| Generator.init(); | |
| DragDrop.init(); | |
| FileSelector.init(); | |
| const handleVisibility = () => { | |
| if (!document.hidden) { | |
| Generator.handleVisibilityRestore(); | |
| } | |
| }; | |
| document.addEventListener('visibilitychange', handleVisibility); | |
| window.addEventListener('pageshow', handleVisibility); | |
| window.addEventListener('focus', handleVisibility); | |
| // 检查认证状态 | |
| const isAuth = await Auth.check(); | |
| if (isAuth) { | |
| Auth.unlock(); | |
| await loadWorkspaceData(); | |
| } | |
| console.log('App initialized successfully'); | |
| } catch (err) { | |
| console.error('App initialization failed:', err); | |
| } | |
| } | |
| // 启动应用 | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', initApp); | |
| } else { | |
| initApp(); | |
| } | |
| </script> | |
| </body> | |
| </html> | |