// PromptLib frontend logic — vanilla JS, no framework
(() => {
const $ = (id) => document.getElementById(id);
const escapeHtml = (s = '') =>
String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const state = {
prompts: [],
images: [], // 生成历史(仅在 view='history' 时使用)
settings: { openaiBaseUrl: '', openaiApiKeySet: false, imageModel: '', translateModel: '', customSizes: [], defaultSize: '1024x1024', accessPasswordSet: false, jobPollInterval: 3000 },
view: 'all', // all | favorites | history
modelFilter: 'all',
tagFilter: null,
search: '',
editingId: null,
genTarget: null,
genSize: '1024x1024',
genQuality: 'hd',
};
// ---------- AUTH ----------
const LS_PASSWORD = 'promptlib.accessPassword';
let accessPassword = localStorage.getItem(LS_PASSWORD) || '';
// ---------- API ----------
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);
});
}
// ---------- IN-FLIGHT JOBS (server-side detached) ----------
// value 是 { jobId, startTime }(startTime 来自服务端 job.startedAt 或本地启动时间)
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');
}
// ---------- TOAST ----------
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())}`;
}
// ---------- MODAL ----------
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);
});
});
// ESC 关闭最上层未隐藏的弹窗
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();
});
// ---------- MOBILE SIDEBAR ----------
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);
// ---------- FILTER ----------
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;
}
// ---------- RENDER ----------
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
? '🔍 搜索历史(标题、提示词、尺寸...)'
: '🔍 搜索标题、提示词、标签...';
// tags cloud
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
? '
暂无标签
'
: tags.map((t) => `${escapeHtml(t)}`).join('');
tagEl.querySelectorAll('.filter-chip').forEach((c) =>
c.addEventListener('click', () => {
state.tagFilter = state.tagFilter === c.dataset.tag ? null : c.dataset.tag;
render();
})
);
// header
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;
}
// stats
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 = `
共 ${total} 条
|
gpt-image-2: ${gpt}
seedance-2: ${seed}
|
⭐ ${fav}
`;
// grid
const list = filtered();
$('emptyState').classList.toggle('hidden', list.length > 0);
$('cardGrid').classList.toggle('hidden', list.length === 0);
$('cardGrid').innerHTML = list.map(cardHtml).join('');
// bind card actions
$('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);
});
});
}
// ---------- HISTORY VIEW ----------
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 `
${escapeHtml(im.promptTitle || '(已删除)')}
${escapeHtml(im.model)}
${im.size.replace('x', '×')}
${im.quality === 'hd' ? '高清' : '标准'}
${(im.generationDuration / 1000).toFixed(1)}s
${fmtTime(im.createdAt)}
`;
}
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 = `
共 ${total} 张
|
gpt-image-2: ${gpt}
seedance-2: ${seed}
`;
$('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`],
['模型', `${escapeHtml(im.model)}`],
['尺寸', im.size.replace('x', ' × ')],
['质量', im.quality === 'hd' ? '高清' : '标准'],
['生成时间', fmtTime(im.createdAt)],
['所属提示词', escapeHtml(im.promptTitle || '(已删除)')],
];
$('info_meta').innerHTML = meta.map(([k, v]) => `
${k}${v}
`).join('') + `
原始提示词
${escapeHtml(im.promptUsed)}
`;
$('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 = `
已生成 0.0s…
`;
} else if (latest) {
imgArea = `
${hasImagePrompt ? `` : ''}
`;
} else {
imgArea = `
🖼️
${hasImagePrompt ? '点击生成图像' : '无图像提示词'}
`;
}
const tagsHtml = (p.tags || []).map((t) => `${escapeHtml(t)}`).join('');
const errMsg = genErrors.get(p.id);
const errorBlock = errMsg && !generating
? `
❌ 生成失败:${escapeHtml(errMsg).slice(0, 120)}
`
: '';
const imageBlock = hasImagePrompt
? `
gpt-image-2
${generating ? `生成中已生成 0.0s…` : ''}
${escapeHtml(p.contentImage)}
${p.translationImage ? `
💬 ${escapeHtml(p.translationImage)}
` : ''}
`
: '';
const hasVideoPrompt = (p.contentVideo || '').trim();
const videoBlock = hasVideoPrompt
? `
seedance-2
${escapeHtml(p.contentVideo)}
${p.translationVideo ? `
💬 ${escapeHtml(p.translationVideo)}
` : ''}
`
: '';
const previewAction = generating ? 'noop' : (latest ? 'info' : (hasImagePrompt ? 'gen' : 'noop'));
return `
${imgArea}
${escapeHtml(p.title)}
${tagsHtml ? `
${tagsHtml}
` : ''}
${errorBlock}
${imageBlock}
${videoBlock}
${p.sourceUrl ? `` : ''}
`;
}
// ---------- ACTIONS ----------
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');
}
}
// ---------- EDIT ----------
$('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');
}
});
// ---------- NAV ----------
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();
});
// ---------- SETTINGS ----------
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'];
// 优先用输入框里实时编辑的值,没打开就用 settings 里的
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) => ``).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();
// 间隔可能变了,重启 polling
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();
});
// ---------- GENERATE ----------
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) => `
${s.replace('x', '×')}
`).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');
}
// #g_sizes 的事件由 openGenerate 动态绑定(含自定义分辨率)
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;
// 把服务端运行中的任务回填到客户端 inflightGen(处理刷新后场景)
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) {
// 服务端已 GC(或重启丢失)→ 当作完成,刷新一下数据
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();
}
// ---------- ELAPSED TIMER (per-card live counter) ----------
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…`;
});
}
// ---------- TRANSLATE ----------
async function translate(id, kind) {
toast('翻译中...');
await api(`/api/translate/${id}`, { method: 'POST', body: JSON.stringify({ kind }) });
await loadPrompts(); render();
toast('翻译完成');
}
// ---------- INFO / DOWNLOAD ----------
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`],
['模型', `${escapeHtml(img.model)}`],
['尺寸', img.size.replace('x', ' × ')],
['质量', img.quality === 'hd' ? '高清' : '标准'],
['生成时间', fmtTime(img.createdAt)],
];
$('info_meta').innerHTML = meta.map(([k, v]) => `
${k}${v}
`).join('') + `
原始提示词
${escapeHtml(img.promptUsed)}
`;
$('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();
}
// ---------- INIT ----------
(async function init() {
try {
// 1. 探测是否需要密码
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();
})();
})();