banber / index.html
486CHD's picture
Upload 13 files
4f58cf6 verified
<!DOCTYPE html>
<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 !important;
top: 0 !important;
bottom: auto !important;
left: 0 !important;
right: 0 !important;
transform: none !important;
width: 100% !important;
max-width: 100% !important;
border-radius: 0 !important;
z-index: 1000 !important;
box-shadow: 0 2px 20px rgba(0,0,0,0.3) !important;
}
header {
margin-top: var(--input-offset, 115px); /* 为输入区留空�?*/
}
.history-container {
padding-bottom: 20px !important;
}
/* 调整输入区内部布�? */
.control-bar {
max-width: 1400px;
margin: 0 auto;
padding: 12px 20px !important;
}
.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">&#x7AD9;&#x70B9;&#x7EDF;&#x8BA1;</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>