Spaces:
Running
Running
| <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: 80px; /* 为输入区留空间 */ | |
| } | |
| .history-container { | |
| padding-bottom: 20px ; | |
| } | |
| /* 调整输入区内部布局 */ | |
| .control-bar { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| padding: 12px 20px ; | |
| } | |
| .preview-bar { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| padding: 0 20px; | |
| } | |
| .preview-bar.visible { | |
| padding: 12px 20px; | |
| } | |
| /* 动态调整header间距 */ | |
| .has-preview header { | |
| margin-top: 180px; | |
| } | |
| @media (max-width: 768px) { | |
| header { | |
| margin-top: 70px; | |
| } | |
| .has-preview header { | |
| margin-top: 150px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- 在预览栏显示时添加 has-preview 类 --> | |
| <script> | |
| // 监听预览栏变化 | |
| const observePreviewBar = () => { | |
| const previewBar = document.getElementById('preview-bar'); | |
| 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'] }); | |
| }; | |
| document.addEventListener('DOMContentLoaded', observePreviewBar); | |
| </script> | |
| <!-- 登录界面 --> | |
| <div id="login-overlay"> | |
| <div class="login-card glass-panel"> | |
| <h1 style="font-size: 2rem;">🍌</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="btn-3d send-btn" id="send-btn"> | |
| <span>生成</span> | |
| <div class="loader"></div> | |
| </button> | |
| </div> | |
| </div> | |
| <header> | |
| <div> | |
| <h2>创意画板 提示词用法库https://nanobananamaker.com/</h2> | |
| <div style="font-size: 12px; color: var(--text-sub);" id="status-bar">Ready 生成较慢,放后台等待即可</div> | |
| </div> | |
| </header> | |
| <!-- 历史画廊 --> | |
| <div class="history-container"> | |
| <div class="grid-layout" id="gallery"></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="刷新公共画廊">⟳</button> | |
| </div> | |
| </div> | |
| <div class="grid-layout public-grid" id="public-gallery"></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> | |
| <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 STORAGE_KEYS = { | |
| publicGalleryTokens: 'BananaPro_PublicGallery_Tokens_v1' | |
| }; | |
| const DEFAULT_PUBLIC_HINT = '分享你的作品,欣赏社区灵感'; | |
| const DB_NAME = 'BananaProDB_v3'; | |
| const DB_VERSION = 1; | |
| const STORE_NAME = 'artworks'; | |
| // ============================================ | |
| // IndexedDB 模块(使用 Blob 存储避免 Base64 问题) | |
| // ============================================ | |
| 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/... 字符串 | |
| 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 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) { | |
| 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'); | |
| 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', 0.8)); | |
| }; | |
| img.onerror = () => resolve(base64Data); | |
| img.src = base64Data; | |
| }); | |
| }, | |
| // 处理上传的文件 | |
| 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); | |
| base64 = await this.compressImage(base64); | |
| 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(); | |
| } | |
| }; | |
| // ============================================ | |
| // 预览条管理模块 | |
| // ============================================ | |
| const PreviewManager = { | |
| container: null, | |
| uploadBtn: null, | |
| statusBar: null, | |
| init() { | |
| this.container = document.getElementById('preview-bar'); | |
| this.uploadBtn = document.getElementById('upload-btn'); | |
| this.statusBar = document.getElementById('status-bar'); | |
| }, | |
| render() { | |
| // 清空容器 | |
| this.container.innerHTML = ''; | |
| const images = AppState.currentImages; | |
| if (images.length === 0) { | |
| this.container.classList.remove('visible'); | |
| this.uploadBtn.classList.remove('active'); | |
| this.statusBar.textContent = 'Ready'; | |
| return; | |
| } | |
| this.container.classList.add('visible'); | |
| this.uploadBtn.classList.add('active'); | |
| this.statusBar.textContent = `已选择 ${images.length}/16 张图片`; | |
| // 使用 DOM API 创建元素,避免 innerHTML 导致的编码问题 | |
| images.forEach((imgData, index) => { | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'thumb-wrapper'; | |
| const img = document.createElement('img'); | |
| img.src = imgData; // 直接设置 src,不经过字符串拼接 | |
| const removeBtn = document.createElement('div'); | |
| removeBtn.className = 'thumb-remove'; | |
| removeBtn.textContent = '×'; | |
| removeBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| ImageHandler.removeAt(index); | |
| }; | |
| wrapper.appendChild(img); | |
| wrapper.appendChild(removeBtn); | |
| this.container.appendChild(wrapper); | |
| }); | |
| } | |
| }; | |
| // ============================================ | |
| // 画廊管理模块 | |
| // ============================================ | |
| const GalleryManager = { | |
| container: null, | |
| init() { | |
| this.container = document.getElementById('gallery'); | |
| }, | |
| async load() { | |
| try { | |
| AppState.galleryData = await Database.getAll(); | |
| this.render(); | |
| } catch (err) { | |
| console.error('加载画廊失败:', err); | |
| } | |
| }, | |
| render() { | |
| this.container.innerHTML = ''; | |
| if (AppState.galleryData.length === 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 fragment = document.createDocumentFragment(); | |
| AppState.galleryData.forEach(item => { | |
| const card = this.createCard(item); | |
| fragment.appendChild(card); | |
| }); | |
| this.container.appendChild(fragment); | |
| }, | |
| createCard(item) { | |
| const el = document.createElement('div'); | |
| el.className = 'history-item'; | |
| el.dataset.id = item.id; | |
| // 参考图标记 | |
| if (item.inputImages && item.inputImages.length > 0) { | |
| const badge = document.createElement('div'); | |
| badge.className = 'item-badge'; | |
| badge.textContent = `📎 ${item.inputImages.length}`; | |
| el.appendChild(badge); | |
| } | |
| // 主图片 - 使用 DOM API 设置 src | |
| const img = document.createElement('img'); | |
| img.loading = 'lazy'; | |
| img.decoding = 'async'; | |
| img.src = item.image; | |
| img.alt = `作品 ${item.id}`; | |
| img.onerror = () => { | |
| console.error('图片加载失败, ID:', item.id); | |
| 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); | |
| // 操作按钮 | |
| 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.onclick = (e) => { | |
| e.stopPropagation(); | |
| PublicGalleryManager.share(item, shareBtn); | |
| }; | |
| const downloadBtn = document.createElement('button'); | |
| downloadBtn.className = 'icon-btn'; | |
| downloadBtn.textContent = '⬇'; | |
| downloadBtn.title = '下载图片'; | |
| 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.onclick = (e) => { | |
| e.stopPropagation(); | |
| this.deleteItem(item.id); | |
| }; | |
| actions.appendChild(shareBtn); | |
| actions.appendChild(downloadBtn); | |
| actions.appendChild(deleteBtn); | |
| el.appendChild(actions); | |
| // 点击打开弹窗 | |
| el.onclick = () => ModalManager.open(item); | |
| return el; | |
| }, | |
| downloadImage(item) { | |
| const link = document.createElement('a'); | |
| link.href = item.image; | |
| link.download = `banana-pro-${item.id}-${Date.now()}.png`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| }, | |
| async deleteItem(id) { | |
| if (!confirm('确定要删除这张图片吗?')) return; | |
| try { | |
| 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, | |
| 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.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; | |
| // 设置主图 - 直接赋值 | |
| this.imgEl.src = item.image; | |
| // 设置提示词 | |
| 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(); | |
| this.modal.style.display = 'flex'; | |
| }, | |
| close() { | |
| this.modal.style.display = 'none'; | |
| AppState.currentModalItem = null; | |
| }, | |
| 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(); | |
| } | |
| }; | |
| // ============================================ | |
| // 公共画廊管理模块 | |
| // ============================================ | |
| const PublicGalleryManager = { | |
| container: null, | |
| refreshBtn: null, | |
| hintEl: 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.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(); | |
| } | |
| this.initialFetch(); | |
| }, | |
| 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 res = await fetch('/api/public-gallery', { | |
| 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.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.container.innerHTML = ` | |
| <div class="public-gallery-empty"> | |
| <div>🌌</div> | |
| <p>${message}</p> | |
| </div> | |
| `; | |
| }, | |
| render() { | |
| if (!this.container) return; | |
| if (!AppState.publicGalleryData || AppState.publicGalleryData.length === 0) { | |
| this.showEmpty('还没有人分享作品,成为第一个吧!'); | |
| return; | |
| } | |
| this.container.innerHTML = ''; | |
| const fragment = document.createDocumentFragment(); | |
| AppState.publicGalleryData.forEach(item => { | |
| const card = this.createCard(item); | |
| fragment.appendChild(card); | |
| }); | |
| this.container.appendChild(fragment); | |
| }, | |
| 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.src = item.image; | |
| img.alt = item.prompt || '创意作品'; | |
| 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.onclick = (e) => { | |
| e.stopPropagation(); | |
| this.delete(item.id); | |
| }; | |
| footer.appendChild(deleteBtn); | |
| } | |
| info.appendChild(footer); | |
| card.appendChild(info); | |
| card.onclick = () => ModalManager.open(item); | |
| return card; | |
| }, | |
| 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 res = await fetch('/api/public-gallery', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| prompt: item.prompt, | |
| image: item.image, | |
| inputImages: item.inputImages || [] | |
| }) | |
| }); | |
| 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 fetch(`/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 || '删除失败'); | |
| } | |
| 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, | |
| textarea: null, | |
| statusBar: null, | |
| isGenerating: false, | |
| generateTimeout: null, | |
| init() { | |
| this.sendBtn = document.getElementById('send-btn'); | |
| this.textarea = document.getElementById('prompt'); | |
| this.statusBar = document.getElementById('status-bar'); | |
| this.sendBtn.onclick = () => this.generate(); | |
| // 输入框自动高度 | |
| this.textarea.addEventListener('input', () => { | |
| this.textarea.style.height = 'auto'; | |
| this.textarea.style.height = this.textarea.scrollHeight + 'px'; | |
| }); | |
| // 回车发送(Shift+Enter 换行) | |
| 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; | |
| this.statusBar.textContent = '⏳ 生成中...'; | |
| // 防止生成超时 - 3分钟后强制停止 | |
| clearTimeout(this.generateTimeout); | |
| this.generateTimeout = setTimeout(() => { | |
| if (this.isGenerating) { | |
| this.setLoading(false); | |
| this.statusBar.textContent = '生成超时,请重试'; | |
| alert('生成超时,请检查网络后重试'); | |
| } | |
| }, 180000); | |
| } else { | |
| this.sendBtn.classList.remove('loading'); | |
| this.sendBtn.disabled = false; | |
| this.statusBar.textContent = 'Ready'; | |
| clearTimeout(this.generateTimeout); | |
| } | |
| }, | |
| async generate() { | |
| if (this.isGenerating) return; | |
| const prompt = this.textarea.value.trim(); | |
| if (!prompt) { | |
| alert('请输入提示词'); | |
| return; | |
| } | |
| this.setLoading(true); | |
| try { | |
| const controller = new AbortController(); | |
| const timeoutId = setTimeout(() => controller.abort(), 120000); | |
| const response = await fetch('/api/generate', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| prompt: prompt, | |
| images: AppState.currentImages | |
| }), | |
| signal: controller.signal | |
| }); | |
| clearTimeout(timeoutId); | |
| const data = await response.json(); | |
| if (!response.ok || !data.success) { | |
| throw new Error(data.message || '生成失败'); | |
| } | |
| // 验证返回的图片数据 | |
| if (!data.image || !data.image.startsWith('data:image')) { | |
| throw new Error('返回的图片数据无效'); | |
| } | |
| // 保存到数据库 | |
| await Database.save({ | |
| prompt: prompt, | |
| image: data.image, | |
| inputImages: [...AppState.currentImages] | |
| }); | |
| // 刷新画廊 | |
| await GalleryManager.load(); | |
| // 清空输入 | |
| this.textarea.value = ''; | |
| this.textarea.style.height = 'auto'; | |
| ImageHandler.clear(); | |
| this.statusBar.textContent = '✅ 生成成功'; | |
| } catch (err) { | |
| console.error('生成失败:', err); | |
| let errorMessage = '生成失败,请重试'; | |
| if (err.message.includes('API认证失败')) { | |
| errorMessage = 'API认证失败,请联系管理员检查API配置'; | |
| } else if (err.message.includes('无法连接到API服务器')) { | |
| errorMessage = '无法连接到API服务器,请检查网络连接'; | |
| } else if (err.message.includes('API请求超时')) { | |
| errorMessage = 'API请求超时,请稍后重试'; | |
| } else if (err.message) { | |
| errorMessage = err.message; | |
| } | |
| if (err.name === 'AbortError') { | |
| this.statusBar.textContent = '⚠️ 生成超时'; | |
| alert('生成超时,请检查网络后重试'); | |
| } else { | |
| this.statusBar.textContent = '❌ 生成失败'; | |
| alert(errorMessage); | |
| } | |
| } finally { | |
| this.setLoading(false); | |
| setTimeout(() => { | |
| if (this.statusBar.textContent !== 'Ready') { | |
| this.statusBar.textContent = 'Ready'; | |
| } | |
| }, 3000); | |
| } | |
| } | |
| }; | |
| // ============================================ | |
| // 认证模块 | |
| // ============================================ | |
| const Auth = { | |
| async check() { | |
| try { | |
| const res = await fetch('/api/check-auth'); | |
| const data = await res.json(); | |
| return data.authenticated; | |
| } catch { | |
| return false; | |
| } | |
| }, | |
| async login(password) { | |
| const res = await fetch('/api/login', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ password }) | |
| }); | |
| const data = await res.json(); | |
| return data.success; | |
| }, | |
| 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 Promise.all([ | |
| GalleryManager.load(), | |
| PublicGalleryManager.fetch() | |
| ]); | |
| } catch (error) { | |
| console.error('加载画廊数据失败:', error); | |
| } | |
| } | |
| async function doLogin() { | |
| const pwd = document.getElementById('pwd').value; | |
| if (!pwd) return; | |
| const success = await Auth.login(pwd); | |
| if (success) { | |
| Auth.unlock(); | |
| await loadWorkspaceData(); | |
| } else { | |
| alert('密码错误'); | |
| } | |
| } | |
| function closeModal() { | |
| ModalManager.close(); | |
| } | |
| // ============================================ | |
| // 应用初始化 | |
| // ============================================ | |
| async function initApp() { | |
| try { | |
| // 初始化数据库 | |
| await Database.init(); | |
| // 初始化各模块 | |
| PreviewManager.init(); | |
| GalleryManager.init(); | |
| ModalManager.init(); | |
| PublicGalleryManager.init(); | |
| Generator.init(); | |
| DragDrop.init(); | |
| FileSelector.init(); | |
| // 检查认证状态 | |
| 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> |