| |
| (() => { |
| const $ = (id) => document.getElementById(id); |
| const escapeHtml = (s = '') => |
| String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); |
|
|
| const state = { |
| prompts: [], |
| images: [], |
| settings: { openaiBaseUrl: '', openaiApiKeySet: false, imageModel: '', translateModel: '', customSizes: [], defaultSize: '1024x1024', accessPasswordSet: false, jobPollInterval: 3000 }, |
| view: 'all', |
| modelFilter: 'all', |
| tagFilter: null, |
| search: '', |
| editingId: null, |
| genTarget: null, |
| genSize: '1024x1024', |
| genQuality: 'hd', |
| }; |
|
|
| |
| const LS_PASSWORD = 'promptlib.accessPassword'; |
| let accessPassword = localStorage.getItem(LS_PASSWORD) || ''; |
|
|
| |
| async function api(path, opts = {}) { |
| const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) }; |
| if (accessPassword) headers['X-Access-Password'] = accessPassword; |
| const res = await fetch(path, { ...opts, headers }); |
| if (res.status === 401) { |
| |
| const pw = await promptLogin(); |
| if (pw) { |
| accessPassword = pw; |
| localStorage.setItem(LS_PASSWORD, pw); |
| return api(path, opts); |
| } |
| throw new Error('未授权'); |
| } |
| if (!res.ok) { |
| let msg = `HTTP ${res.status}`; |
| try { const j = await res.json(); msg = j.message || j.error || msg; } catch {} |
| throw new Error(msg); |
| } |
| return res.status === 204 ? null : res.json(); |
| } |
|
|
| function promptLogin() { |
| return new Promise((resolve) => { |
| $('login_password').value = ''; |
| $('login_hint').style.display = 'none'; |
| openModal('loginModal'); |
| const form = $('loginForm'); |
| const onSubmit = async (e) => { |
| e.preventDefault(); |
| const pw = $('login_password').value; |
| |
| try { |
| const r = await fetch('/api/auth/login', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ password: pw }), |
| }); |
| if (!r.ok) { |
| $('login_hint').style.display = 'block'; |
| return; |
| } |
| form.removeEventListener('submit', onSubmit); |
| closeModal('loginModal'); |
| resolve(pw); |
| } catch (err) { |
| $('login_hint').textContent = '验证失败:' + (err.message || err); |
| $('login_hint').style.display = 'block'; |
| } |
| }; |
| form.addEventListener('submit', onSubmit); |
| }); |
| } |
|
|
| |
| |
| const inflightGen = new Map(); |
| const genErrors = new Map(); |
| let pollTimer = null; |
| let elapsedTimer = null; |
|
|
| function isGenerating(id) { return inflightGen.has(id); } |
|
|
| async function cancelGeneration(promptId) { |
| const entry = inflightGen.get(promptId); |
| if (!entry) return; |
| try { |
| await api(`/api/generate/jobs/${entry.jobId}`, { method: 'DELETE' }); |
| } catch {} |
| } |
|
|
| async function loadPrompts() { |
| state.prompts = await api('/api/prompts'); |
| } |
| async function loadSettings() { |
| state.settings = await api('/api/settings'); |
| } |
| async function loadImages() { |
| state.images = await api('/api/images'); |
| } |
|
|
| |
| function toast(msg, kind = 'info') { |
| const el = document.createElement('div'); |
| el.className = 'toast' + (kind === 'error' ? ' error' : ''); |
| el.textContent = msg; |
| document.body.appendChild(el); |
| setTimeout(() => el.remove(), 2400); |
| } |
|
|
| async function copyText(t) { |
| try { await navigator.clipboard.writeText(t); toast('已复制'); } |
| catch { toast('复制失败', 'error'); } |
| } |
|
|
| function fmtTime(ts) { |
| const d = new Date(ts); |
| const pad = (n) => String(n).padStart(2, '0'); |
| return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; |
| } |
|
|
| |
| function openModal(id) { $(id).classList.remove('hidden'); } |
| function closeModal(id) { |
| $(id).classList.add('hidden'); |
| } |
| document.querySelectorAll('[data-close]').forEach((b) => { |
| b.addEventListener('click', () => closeModal(b.getAttribute('data-close'))); |
| }); |
| document.querySelectorAll('.modal-overlay').forEach((o) => { |
| o.addEventListener('click', (e) => { |
| if (e.target === o) closeModal(o.id); |
| }); |
| }); |
| |
| document.addEventListener('keydown', (e) => { |
| if (e.key !== 'Escape') return; |
| const opened = Array.from(document.querySelectorAll('.modal-overlay')).filter((o) => !o.classList.contains('hidden')); |
| if (opened.length) closeModal(opened[opened.length - 1].id); |
| else closeSidebar(); |
| }); |
|
|
| |
| function openSidebar() { |
| $('sidebar').classList.add('open'); |
| $('sidebarBackdrop').classList.remove('hidden'); |
| $('mobileMenuBtn').setAttribute('aria-expanded', 'true'); |
| } |
|
|
| function closeSidebar() { |
| $('sidebar').classList.remove('open'); |
| $('sidebarBackdrop').classList.add('hidden'); |
| $('mobileMenuBtn').setAttribute('aria-expanded', 'false'); |
| } |
|
|
| function toggleSidebar() { |
| if ($('sidebar').classList.contains('open')) closeSidebar(); |
| else openSidebar(); |
| } |
|
|
| $('mobileMenuBtn').addEventListener('click', toggleSidebar); |
| $('sidebarBackdrop').addEventListener('click', closeSidebar); |
|
|
| |
| function filtered() { |
| let list = state.prompts.slice(); |
| if (state.view === 'favorites') list = list.filter((p) => p.isFavorite); |
| if (state.modelFilter === 'gpt-image-2') list = list.filter((p) => (p.contentImage || '').trim()); |
| else if (state.modelFilter === 'seedance-2') list = list.filter((p) => (p.contentVideo || '').trim()); |
| if (state.tagFilter) list = list.filter((p) => (p.tags || []).includes(state.tagFilter)); |
| const q = state.search.trim().toLowerCase(); |
| if (q) { |
| list = list.filter((p) => { |
| const blob = [p.title, p.contentImage, p.contentVideo, (p.tags || []).join(','), p.translationImage, p.translationVideo] |
| .filter(Boolean).join(' ').toLowerCase(); |
| return blob.includes(q); |
| }); |
| } |
| return list; |
| } |
|
|
| |
| function render() { |
| $('passwordBanner').classList.toggle('hidden', !!state.settings.accessPasswordSet); |
| document.querySelectorAll('.nav-item[data-view]').forEach((n) => |
| n.classList.toggle('active', n.dataset.view === state.view) |
| ); |
| document.querySelectorAll('#modelFilter .filter-chip').forEach((c) => |
| c.classList.toggle('active', c.dataset.model === state.modelFilter) |
| ); |
|
|
| const isHistory = state.view === 'history'; |
| $('newBtn').classList.toggle('hidden', isHistory); |
| $('searchInput').placeholder = isHistory |
| ? '🔍 搜索历史(标题、提示词、尺寸...)' |
| : '🔍 搜索标题、提示词、标签...'; |
|
|
| |
| const tagCounts = {}; |
| state.prompts.forEach((p) => (p.tags || []).forEach((t) => { tagCounts[t] = (tagCounts[t] || 0) + 1; })); |
| const tags = Object.keys(tagCounts).sort((a, b) => tagCounts[b] - tagCounts[a]).slice(0, 12); |
| const tagEl = $('tagFilter'); |
| tagEl.innerHTML = tags.length === 0 |
| ? '<div style="color:var(--text-tertiary);font-size:12px;padding-left:4px">暂无标签</div>' |
| : tags.map((t) => `<span class="filter-chip ${state.tagFilter === t ? 'active' : ''}" data-tag="${escapeHtml(t)}">${escapeHtml(t)}</span>`).join(''); |
| tagEl.querySelectorAll('.filter-chip').forEach((c) => |
| c.addEventListener('click', () => { |
| state.tagFilter = state.tagFilter === c.dataset.tag ? null : c.dataset.tag; |
| render(); |
| }) |
| ); |
|
|
| |
| const titleMap = { |
| all: ['🏠 全部 Prompts', 'AI 图像 & 视频提示词'], |
| favorites: ['⭐ 收藏', '已收藏的提示词'], |
| history: ['📜 生成历史', '所有生成过的图片'], |
| }; |
| const t = titleMap[state.view] || titleMap.all; |
| $('pageTitle').textContent = t[0]; |
| $('pageSubtitle').textContent = t[1]; |
|
|
| if (state.view === 'history') { |
| renderHistory(); |
| return; |
| } |
|
|
| |
| const total = state.prompts.length; |
| const gpt = state.prompts.filter((p) => (p.contentImage || '').trim()).length; |
| const seed = state.prompts.filter((p) => (p.contentVideo || '').trim()).length; |
| const fav = state.prompts.filter((p) => p.isFavorite).length; |
| $('stats').innerHTML = ` |
| <span>共 ${total} 条</span> |
| <span class="sep">|</span> |
| <span>gpt-image-2: ${gpt}</span> |
| <span>seedance-2: ${seed}</span> |
| <span class="sep">|</span> |
| <span>⭐ ${fav}</span> |
| `; |
|
|
| |
| const list = filtered(); |
| $('emptyState').classList.toggle('hidden', list.length > 0); |
| $('cardGrid').classList.toggle('hidden', list.length === 0); |
| $('cardGrid').innerHTML = list.map(cardHtml).join(''); |
|
|
| |
| $('cardGrid').querySelectorAll('[data-action]').forEach((btn) => { |
| btn.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| const card = btn.closest('[data-id]'); |
| if (!card) return; |
| handleAction(btn.dataset.action, card.dataset.id, btn.dataset); |
| }); |
| }); |
| } |
|
|
| |
| function historyFiltered() { |
| let list = state.images.slice(); |
| if (state.modelFilter === 'gpt-image-2' || state.modelFilter === 'seedance-2') { |
| list = list.filter((im) => im.model === state.modelFilter); |
| } |
| const q = state.search.trim().toLowerCase(); |
| if (q) { |
| list = list.filter((im) => { |
| const blob = [im.promptTitle, im.promptUsed, im.model, im.size].filter(Boolean).join(' ').toLowerCase(); |
| return blob.includes(q); |
| }); |
| } |
| return list; |
| } |
|
|
| function historyCardHtml(im) { |
| const modelClass = im.model.startsWith('seedance') ? 'model-badge-seedance' : 'model-badge-gpt'; |
| return ` |
| <div class="card" data-img-id="${im.id}"> |
| <div class="img-preview" data-history-action="info"> |
| <img src="${im.b64}" alt="" /> |
| <div class="img-overlay"> |
| <button class="btn btn-sm" data-history-action="info">ℹ️ 信息</button> |
| <button class="btn btn-sm" data-history-action="dl">💾 下载</button> |
| </div> |
| </div> |
| <div class="card-body"> |
| <div class="card-title-row"> |
| <h3>${escapeHtml(im.promptTitle || '(已删除)')}</h3> |
| </div> |
| <div class="card-tags"> |
| <span class="tag ${modelClass}">${escapeHtml(im.model)}</span> |
| <span class="tag">${im.size.replace('x', '×')}</span> |
| <span class="tag">${im.quality === 'hd' ? '高清' : '标准'}</span> |
| <span class="tag">${(im.generationDuration / 1000).toFixed(1)}s</span> |
| </div> |
| <div class="translation" style="margin-top:0">${fmtTime(im.createdAt)}</div> |
| <div class="card-actions"> |
| <button class="btn btn-sm spacer" data-history-action="del">🗑️</button> |
| </div> |
| </div> |
| </div> |
| `; |
| } |
|
|
| function renderHistory() { |
| const list = historyFiltered(); |
| const total = state.images.length; |
| const gpt = state.images.filter((im) => im.model.startsWith('gpt-image')).length; |
| const seed = state.images.filter((im) => im.model.startsWith('seedance')).length; |
| $('stats').innerHTML = ` |
| <span>共 ${total} 张</span> |
| <span class="sep">|</span> |
| <span>gpt-image-2: ${gpt}</span> |
| <span>seedance-2: ${seed}</span> |
| `; |
| $('emptyState').classList.toggle('hidden', list.length > 0); |
| $('cardGrid').classList.toggle('hidden', list.length === 0); |
| $('cardGrid').innerHTML = list.map(historyCardHtml).join(''); |
| $('cardGrid').querySelectorAll('[data-history-action]').forEach((btn) => { |
| btn.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| const card = btn.closest('[data-img-id]'); |
| if (!card) return; |
| handleHistoryAction(btn.dataset.historyAction, card.dataset.imgId); |
| }); |
| }); |
| } |
|
|
| async function handleHistoryAction(action, imgId) { |
| const im = state.images.find((x) => x.id === imgId); |
| if (!im) return; |
| try { |
| switch (action) { |
| case 'info': openInfoFromHistory(im); break; |
| case 'dl': downloadImage( |
| { id: im.id, b64: im.b64, size: im.size }, |
| im.promptTitle || 'image' |
| ); break; |
| case 'del': |
| if (!confirm('删除这张生成历史?')) return; |
| await api(`/api/images/${imgId}`, { method: 'DELETE' }); |
| await Promise.all([loadImages(), loadPrompts()]); |
| render(); |
| toast('已删除'); |
| break; |
| } |
| } catch (err) { |
| toast(err.message || String(err), 'error'); |
| } |
| } |
|
|
| function openInfoFromHistory(im) { |
| $('info_img').src = im.b64; |
| const meta = [ |
| ['生成时长', `${(im.generationDuration / 1000).toFixed(1)}s`], |
| ['模型', `<span class="tag ${im.model.startsWith('seedance') ? 'model-badge-seedance' : 'model-badge-gpt'}">${escapeHtml(im.model)}</span>`], |
| ['尺寸', im.size.replace('x', ' × ')], |
| ['质量', im.quality === 'hd' ? '高清' : '标准'], |
| ['生成时间', fmtTime(im.createdAt)], |
| ['所属提示词', escapeHtml(im.promptTitle || '(已删除)')], |
| ]; |
| $('info_meta').innerHTML = meta.map(([k, v]) => ` |
| <div class="meta-item"><span class="meta-label">${k}</span><span class="meta-value">${v}</span></div> |
| `).join('') + ` |
| <div class="meta-item span2"> |
| <span class="meta-label">原始提示词</span> |
| <span class="meta-value" style="font-size:12px;color:var(--text-secondary);line-height:1.5;white-space:pre-wrap">${escapeHtml(im.promptUsed)}</span> |
| </div> |
| `; |
| $('info_copyBtn').onclick = () => copyText(im.promptUsed); |
| $('info_dlBtn').onclick = () => downloadImage( |
| { id: im.id, b64: im.b64, size: im.size }, |
| im.promptTitle || 'image' |
| ); |
| openModal('infoModal'); |
| } |
|
|
| function cardHtml(p) { |
| const imgs = p.images || []; |
| const latest = imgs[imgs.length - 1]; |
| const hasImagePrompt = (p.contentImage || '').trim(); |
| const generating = isGenerating(p.id); |
|
|
| let imgArea; |
| if (generating) { |
| imgArea = `<div class="img-empty" style="position:relative"> |
| <span class="spinner" style="width:28px;height:28px;border-width:3px"></span> |
| <span class="elapsed" data-elapsed-for="${p.id}" style="margin-top:4px">已生成 0.0s…</span> |
| <button class="btn btn-sm" data-action="cancelGen" style="margin-top:8px">取消</button> |
| </div>`; |
| } else if (latest) { |
| imgArea = `<img src="${latest.b64}" alt="" /> |
| <div class="img-overlay"> |
| <button class="btn btn-sm" data-action="info">ℹ️ 信息</button> |
| <button class="btn btn-sm" data-action="dl">💾 下载</button> |
| ${hasImagePrompt ? `<button class="btn btn-sm" data-action="gen">🔄 重新生成</button>` : ''} |
| </div>`; |
| } else { |
| imgArea = `<div class="img-empty"> |
| <span style="font-size:24px">🖼️</span> |
| <span>${hasImagePrompt ? '点击生成图像' : '无图像提示词'}</span> |
| </div>`; |
| } |
|
|
| const tagsHtml = (p.tags || []).map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join(''); |
|
|
| const errMsg = genErrors.get(p.id); |
| const errorBlock = errMsg && !generating |
| ? `<div class="gen-error" data-action="retryGen" title="点击重试"> |
| <span>❌ 生成失败:${escapeHtml(errMsg).slice(0, 120)}</span> |
| <button class="btn btn-sm" data-action="retryGen">🔄 重试</button> |
| <button class="btn btn-ghost btn-sm" data-action="dismissError" title="关闭">✕</button> |
| </div>` |
| : ''; |
|
|
| const imageBlock = hasImagePrompt |
| ? `<div class="card-prompt-block"> |
| <div class="card-prompt-head"> |
| <span class="tag model-badge-gpt">gpt-image-2</span> |
| ${generating ? `<span class="tag" style="background:#fef3c7;color:#92400e"><span class="spinner" style="width:10px;height:10px;border-width:2px;margin-right:4px"></span>生成中</span><span class="elapsed" data-elapsed-for="${p.id}">已生成 0.0s…</span>` : ''} |
| <button class="btn btn-ghost btn-sm spacer" data-action="copyImg" title="复制">📋</button> |
| <button class="btn btn-ghost btn-sm" data-action="trImg" title="翻译">🌐</button> |
| <button class="btn btn-ghost btn-sm" data-action="gen" title="生成图像" ${generating ? 'disabled' : ''}>🖼️</button> |
| </div> |
| <div class="prompt-text">${escapeHtml(p.contentImage)}</div> |
| ${p.translationImage ? `<div class="translation">💬 ${escapeHtml(p.translationImage)}</div>` : ''} |
| </div>` |
| : ''; |
|
|
| const hasVideoPrompt = (p.contentVideo || '').trim(); |
| const videoBlock = hasVideoPrompt |
| ? `<div class="card-prompt-block"> |
| <div class="card-prompt-head"> |
| <span class="tag model-badge-seedance">seedance-2</span> |
| <button class="btn btn-ghost btn-sm spacer" data-action="copyVid" title="复制">📋</button> |
| <button class="btn btn-ghost btn-sm" data-action="trVid" title="翻译">🌐</button> |
| </div> |
| <div class="prompt-text">${escapeHtml(p.contentVideo)}</div> |
| ${p.translationVideo ? `<div class="translation">💬 ${escapeHtml(p.translationVideo)}</div>` : ''} |
| </div>` |
| : ''; |
|
|
| const previewAction = generating ? 'noop' : (latest ? 'info' : (hasImagePrompt ? 'gen' : 'noop')); |
|
|
| return ` |
| <div class="card" data-id="${p.id}"> |
| <div class="img-preview" data-action="${previewAction}">${imgArea}</div> |
| <div class="card-body"> |
| <div class="card-title-row"> |
| <h3>${escapeHtml(p.title)}</h3> |
| <button class="btn btn-ghost btn-sm" data-action="fav" style="padding:2px 6px;font-size:16px" title="收藏">${p.isFavorite ? '⭐' : '☆'}</button> |
| </div> |
| ${tagsHtml ? `<div class="card-tags">${tagsHtml}</div>` : ''} |
| ${errorBlock} |
| ${imageBlock} |
| ${videoBlock} |
| <div class="card-actions"> |
| <button class="btn btn-sm" data-action="edit">✏️ 编辑</button> |
| ${p.sourceUrl ? `<button class="btn btn-sm" data-action="openUrl">🔗 来源</button>` : ''} |
| <button class="btn btn-sm spacer" data-action="del" ${generating ? 'disabled' : ''}>🗑️</button> |
| </div> |
| </div> |
| </div> |
| `; |
| } |
|
|
| |
| function findPrompt(id) { return state.prompts.find((p) => p.id === id); } |
|
|
| async function handleAction(action, id) { |
| const p = findPrompt(id); |
| if (!p && action !== 'noop') return; |
| try { |
| switch (action) { |
| case 'edit': openEdit(id); break; |
| case 'del': |
| if (!confirm(`删除 “${p.title}” ?`)) return; |
| await api(`/api/prompts/${id}`, { method: 'DELETE' }); |
| toast('已删除'); |
| await loadPrompts(); render(); |
| break; |
| case 'fav': |
| await api(`/api/prompts/${id}/favorite`, { method: 'POST' }); |
| await loadPrompts(); render(); |
| break; |
| case 'copyImg': copyText(p.contentImage); break; |
| case 'copyVid': copyText(p.contentVideo); break; |
| case 'trImg': await translate(id, 'image'); break; |
| case 'trVid': await translate(id, 'video'); break; |
| case 'gen': |
| if (isGenerating(id)) { toast('已在生成中', 'error'); return; } |
| openGenerate(id); |
| break; |
| case 'cancelGen': cancelGeneration(id); break; |
| case 'retryGen': |
| genErrors.delete(id); |
| if (isGenerating(id)) { toast('已在生成中', 'error'); return; } |
| openGenerate(id); |
| break; |
| case 'dismissError': |
| genErrors.delete(id); |
| render(); |
| break; |
| case 'info': openInfo(id); break; |
| case 'dl': downloadLatest(p); break; |
| case 'openUrl': window.open(p.sourceUrl, '_blank', 'noopener'); break; |
| } |
| } catch (err) { |
| toast(err.message || String(err), 'error'); |
| } |
| } |
|
|
| |
| $('newBtn').addEventListener('click', () => openEdit(null)); |
| $('passwordBannerBtn').addEventListener('click', () => openSettings()); |
|
|
| function openEdit(id) { |
| state.editingId = id; |
| const p = id ? findPrompt(id) : null; |
| $('editModalTitle').textContent = id ? '编辑提示词' : '新增提示词'; |
| $('f_title').value = p?.title || ''; |
| $('f_imagePrompt').value = p?.contentImage || ''; |
| $('f_videoPrompt').value = p?.contentVideo || ''; |
| $('f_tags').value = (p?.tags || []).join(', '); |
| $('f_sourceUrl').value = p?.sourceUrl || ''; |
| openModal('editModal'); |
| setTimeout(() => $('f_title').focus(), 50); |
| } |
|
|
| $('editForm').addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const title = $('f_title').value.trim(); |
| const contentImage = $('f_imagePrompt').value.trim(); |
| const contentVideo = $('f_videoPrompt').value.trim(); |
| if (!title) return toast('请填标题', 'error'); |
| if (!contentImage && !contentVideo) return toast('至少填写一个提示词', 'error'); |
| const tags = $('f_tags').value.split(',').map((s) => s.trim()).filter(Boolean); |
| const sourceUrl = $('f_sourceUrl').value.trim(); |
| const payload = { title, contentImage, contentVideo, tags, sourceUrl }; |
| try { |
| if (state.editingId) { |
| await api(`/api/prompts/${state.editingId}`, { method: 'PUT', body: JSON.stringify(payload) }); |
| } else { |
| await api('/api/prompts', { method: 'POST', body: JSON.stringify(payload) }); |
| } |
| closeModal('editModal'); |
| await loadPrompts(); render(); |
| toast('已保存'); |
| } catch (err) { |
| toast(err.message || String(err), 'error'); |
| } |
| }); |
|
|
| |
| document.querySelectorAll('.nav-item').forEach((n) => { |
| n.addEventListener('click', async () => { |
| if (n.dataset.action === 'open-settings') { closeSidebar(); openSettings(); return; } |
| const v = n.dataset.view; |
| if (!v) return; |
| state.view = v; |
| state.tagFilter = null; |
| |
| if (v === 'history') { |
| try { await loadImages(); } catch (err) { toast(err.message || String(err), 'error'); } |
| } |
| render(); |
| closeSidebar(); |
| }); |
| }); |
|
|
| document.querySelectorAll('#modelFilter .filter-chip').forEach((c) => { |
| c.addEventListener('click', () => { state.modelFilter = c.dataset.model; render(); }); |
| }); |
|
|
| $('searchInput').addEventListener('input', (e) => { |
| state.search = e.target.value; |
| render(); |
| }); |
|
|
| |
| async function openSettings() { |
| await loadSettings(); |
| $('s_openaiBaseUrl').value = state.settings.openaiBaseUrl || ''; |
| $('s_openaiApiKey').value = ''; |
| $('s_openaiApiKey').placeholder = state.settings.openaiApiKeySet ? '已配置(留空保持不变)' : 'sk-...'; |
| $('s_imageModel').value = state.settings.imageModel || 'gpt-image-2'; |
| $('s_translateModel').value = state.settings.translateModel || 'gpt-4o-mini'; |
| $('s_customSizes').value = (state.settings.customSizes || []).join(', '); |
| $('s_accessPassword').value = ''; |
| $('s_accessPassword').placeholder = state.settings.accessPasswordSet ? '已配置(留空保持不变)' : '留空 = 不需要密码'; |
| $('s_accessPasswordClear').checked = false; |
| $('s_jobPollInterval').value = state.settings.jobPollInterval || 3000; |
| renderDefaultSizeSelect(); |
| openModal('settingsModal'); |
| } |
|
|
| |
| function renderDefaultSizeSelect() { |
| const presets = ['1024x1024', '1792x1024', '1024x1792']; |
| |
| const input = $('s_customSizes'); |
| let customs = []; |
| if (input && input.value) { |
| customs = input.value.split(/[\s,;\n]+/) |
| .map((s) => s.trim().toLowerCase().replace(/×/g, 'x')) |
| .filter((s) => /^\d{2,5}x\d{2,5}$/.test(s)); |
| } else { |
| customs = state.settings.customSizes || []; |
| } |
| const all = [...presets, ...customs.filter((s) => !presets.includes(s))]; |
| const current = state.settings.defaultSize || '1024x1024'; |
| const sel = $('s_defaultSize'); |
| sel.innerHTML = all.map((s) => `<option value="${s}">${s.replace('x', '×')}</option>`).join(''); |
| sel.value = all.includes(current) ? current : '1024x1024'; |
| } |
|
|
| $('saveSettingsBtn').addEventListener('click', async () => { |
| try { |
| const clearPw = $('s_accessPasswordClear').checked; |
| const newPw = $('s_accessPassword').value; |
| const payload = { |
| openaiBaseUrl: $('s_openaiBaseUrl').value, |
| openaiApiKey: $('s_openaiApiKey').value, |
| imageModel: $('s_imageModel').value, |
| translateModel: $('s_translateModel').value, |
| customSizes: $('s_customSizes').value, |
| defaultSize: $('s_defaultSize').value, |
| jobPollInterval: Number($('s_jobPollInterval').value) || 3000, |
| }; |
| if (clearPw) payload.accessPassword = null; |
| else if (newPw) payload.accessPassword = newPw; |
| await api('/api/settings', { method: 'PUT', body: JSON.stringify(payload) }); |
| |
| if (clearPw) { |
| accessPassword = ''; |
| localStorage.removeItem(LS_PASSWORD); |
| } else if (newPw) { |
| accessPassword = newPw; |
| localStorage.setItem(LS_PASSWORD, newPw); |
| } |
| closeModal('settingsModal'); |
| await loadSettings(); |
| |
| if (pollTimer) { stopPolling(); ensurePolling(); } |
| render(); |
| toast('配置已保存'); |
| } catch (err) { |
| toast(err.message || String(err), 'error'); |
| } |
| }); |
|
|
| |
| document.addEventListener('input', (e) => { |
| if (e.target && e.target.id === 's_customSizes') renderDefaultSizeSelect(); |
| }); |
|
|
| |
| function openGenerate(id) { |
| const p = findPrompt(id); |
| if (!(p.contentImage || '').trim()) return toast('该条目没有图像提示词', 'error'); |
| state.genTarget = { promptId: id }; |
| const presets = ['1024x1024', '1792x1024', '1024x1792']; |
| const customs = (state.settings.customSizes || []).filter((s) => !presets.includes(s)); |
| const all = [...presets, ...customs]; |
| const def = state.settings.defaultSize || '1024x1024'; |
| state.genSize = all.includes(def) ? def : '1024x1024'; |
| state.genQuality = 'hd'; |
| $('g_sizes').innerHTML = all.map((s) => ` |
| <div class="size-option ${s === state.genSize ? 'active' : ''}" data-size="${s}">${s.replace('x', '×')}</div> |
| `).join(''); |
| $('g_sizes').querySelectorAll('.size-option').forEach((o) => |
| o.addEventListener('click', () => { |
| $('g_sizes').querySelectorAll('.size-option').forEach((x) => x.classList.remove('active')); |
| o.classList.add('active'); |
| state.genSize = o.dataset.size; |
| }) |
| ); |
| document.querySelectorAll('#g_quality .size-option').forEach((o) => |
| o.classList.toggle('active', o.dataset.quality === state.genQuality) |
| ); |
| openModal('genModal'); |
| } |
|
|
| |
| document.querySelectorAll('#g_quality .size-option').forEach((o) => |
| o.addEventListener('click', () => { |
| document.querySelectorAll('#g_quality .size-option').forEach((x) => x.classList.remove('active')); |
| o.classList.add('active'); |
| state.genQuality = o.dataset.quality; |
| }) |
| ); |
|
|
| $('genStartBtn').addEventListener('click', async () => { |
| const id = state.genTarget?.promptId; |
| if (!id) return; |
| if (inflightGen.has(id)) { |
| toast('该条目正在生成中', 'error'); |
| return; |
| } |
| const size = state.genSize; |
| const quality = state.genQuality; |
| closeModal('genModal'); |
| startGeneration(id, size, quality); |
| }); |
|
|
| async function startGeneration(promptId, size, quality) { |
| genErrors.delete(promptId); |
| try { |
| const data = await api(`/api/generate/${promptId}`, { |
| method: 'POST', |
| body: JSON.stringify({ size, quality }), |
| }); |
| inflightGen.set(promptId, { jobId: data.jobId, startTime: Date.now() }); |
| render(); |
| ensurePolling(); |
| ensureElapsedTimer(); |
| } catch (err) { |
| const msg = err.message || String(err); |
| genErrors.set(promptId, msg); |
| toast('启动失败:' + msg, 'error'); |
| render(); |
| } |
| } |
|
|
| function ensurePolling() { |
| if (pollTimer) return; |
| const interval = Math.max(1000, Math.min(30000, Number(state.settings.jobPollInterval) || 3000)); |
| pollTimer = setInterval(pollJobs, interval); |
| pollJobs(); |
| } |
|
|
| function stopPolling() { |
| if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } |
| } |
|
|
| async function pollJobs() { |
| let list; |
| try { |
| list = await api('/api/generate/jobs'); |
| } catch { return; } |
| const byPromptId = new Map(); |
| const runningPromptIds = new Set(); |
| for (const j of list) { |
| byPromptId.set(j.jobId, j); |
| if (j.status === 'running') runningPromptIds.add(j.promptId); |
| } |
|
|
| let needsReload = false; |
| let needsRender = false; |
|
|
| |
| for (const j of list) { |
| if (j.status === 'running' && !inflightGen.has(j.promptId)) { |
| inflightGen.set(j.promptId, { jobId: j.jobId, startTime: j.startedAt || Date.now() }); |
| needsRender = true; |
| } |
| } |
|
|
| |
| for (const [promptId, entry] of Array.from(inflightGen)) { |
| const jobId = entry.jobId; |
| const j = byPromptId.get(jobId); |
| if (!j) { |
| |
| inflightGen.delete(promptId); |
| needsReload = true; |
| needsRender = true; |
| continue; |
| } |
| if (j.status === 'done') { |
| inflightGen.delete(promptId); |
| needsReload = true; |
| needsRender = true; |
| const title = findPrompt(promptId)?.title || ''; |
| toast(`「${title}」生成完成 (${(j.generationDuration / 1000).toFixed(1)}s)`); |
| } else if (j.status === 'error') { |
| inflightGen.delete(promptId); |
| genErrors.set(promptId, j.errorMsg || '生成失败'); |
| needsRender = true; |
| toast('生成失败:' + (j.errorMsg || ''), 'error'); |
| } else if (j.status === 'cancelled') { |
| inflightGen.delete(promptId); |
| needsRender = true; |
| toast('已取消'); |
| } |
| } |
|
|
| if (needsReload) { |
| const reloads = [loadPrompts()]; |
| if (state.view === 'history') reloads.push(loadImages()); |
| try { await Promise.all(reloads); } catch {} |
| } |
| if (needsRender || needsReload) render(); |
|
|
| |
| if (inflightGen.size === 0) { stopPolling(); stopElapsedTimer(); } |
| else ensureElapsedTimer(); |
| } |
|
|
| |
| function ensureElapsedTimer() { |
| if (elapsedTimer) return; |
| elapsedTimer = setInterval(updateElapsedDom, 100); |
| updateElapsedDom(); |
| } |
| function stopElapsedTimer() { |
| if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; } |
| } |
| function updateElapsedDom() { |
| const now = Date.now(); |
| document.querySelectorAll('[data-elapsed-for]').forEach((el) => { |
| const promptId = el.getAttribute('data-elapsed-for'); |
| const entry = inflightGen.get(promptId); |
| if (!entry) return; |
| const elapsed = (now - entry.startTime) / 1000; |
| el.textContent = `已生成 ${elapsed.toFixed(1)}s…`; |
| }); |
| } |
|
|
| |
| async function translate(id, kind) { |
| toast('翻译中...'); |
| await api(`/api/translate/${id}`, { method: 'POST', body: JSON.stringify({ kind }) }); |
| await loadPrompts(); render(); |
| toast('翻译完成'); |
| } |
|
|
| |
| function openInfo(id) { |
| const p = findPrompt(id); |
| const img = p.images?.[p.images.length - 1]; |
| if (!img) return; |
| $('info_img').src = img.b64; |
| const meta = [ |
| ['生成时长', `${(img.generationDuration / 1000).toFixed(1)}s`], |
| ['模型', `<span class="tag model-badge-gpt">${escapeHtml(img.model)}</span>`], |
| ['尺寸', img.size.replace('x', ' × ')], |
| ['质量', img.quality === 'hd' ? '高清' : '标准'], |
| ['生成时间', fmtTime(img.createdAt)], |
| ]; |
| $('info_meta').innerHTML = meta.map(([k, v]) => ` |
| <div class="meta-item"><span class="meta-label">${k}</span><span class="meta-value">${v}</span></div> |
| `).join('') + ` |
| <div class="meta-item span2"> |
| <span class="meta-label">原始提示词</span> |
| <span class="meta-value" style="font-size:12px;color:var(--text-secondary);line-height:1.5;white-space:pre-wrap">${escapeHtml(img.promptUsed)}</span> |
| </div> |
| `; |
| $('info_copyBtn').onclick = () => copyText(img.promptUsed); |
| $('info_dlBtn').onclick = () => downloadImage(img, p.title); |
| openModal('infoModal'); |
| } |
|
|
| function downloadLatest(p) { |
| const img = p.images?.[p.images.length - 1]; |
| if (!img) return; |
| downloadImage(img, p.title); |
| } |
|
|
| function downloadImage(img, title) { |
| const a = document.createElement('a'); |
| a.href = img.b64; |
| const safe = (title || 'image').replace(/[^\w\u4e00-\u9fa5-]+/g, '_').slice(0, 40); |
| a.download = `${safe}_${img.size}_${img.id}.png`; |
| document.body.appendChild(a); |
| a.click(); |
| a.remove(); |
| } |
|
|
| |
| (async function init() { |
| try { |
| |
| const headers = {}; |
| if (accessPassword) headers['X-Access-Password'] = accessPassword; |
| const r = await fetch('/api/auth/check', { headers }); |
| const auth = await r.json(); |
| if (auth.required && !auth.authorized) { |
| const pw = await promptLogin(); |
| if (pw) { |
| accessPassword = pw; |
| localStorage.setItem(LS_PASSWORD, pw); |
| } |
| } |
| await Promise.all([loadPrompts(), loadSettings()]); |
| |
| try { |
| const list = await api('/api/generate/jobs'); |
| for (const j of list) { |
| if (j.status === 'running') { |
| inflightGen.set(j.promptId, { jobId: j.jobId, startTime: j.startedAt || Date.now() }); |
| } else if (j.status === 'error') { |
| genErrors.set(j.promptId, j.errorMsg || '生成失败'); |
| } |
| } |
| if (inflightGen.size > 0) { ensurePolling(); ensureElapsedTimer(); } |
| } catch {} |
| } catch (err) { |
| toast('加载失败:' + (err.message || err), 'error'); |
| } |
| render(); |
| })(); |
| })(); |