ai_v/static/js/main.js

1049 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

lucide.createIcons();
async function checkAuth() {
const r = await fetch('/api/auth/me');
const d = await r.json();
const profile = document.getElementById('userProfile');
const entry = document.getElementById('loginEntryBtn');
const loginHint = document.getElementById('loginHint');
const submitBtn = document.getElementById('submitBtn');
if (d.logged_in) {
if (profile) profile.classList.remove('hidden');
if (entry) entry.classList.add('hidden');
if (loginHint) loginHint.classList.add('hidden');
submitBtn.disabled = false;
submitBtn.classList.remove('opacity-50', 'cursor-not-allowed');
const phoneDisp = document.getElementById('userPhoneDisplay');
if (phoneDisp) phoneDisp.innerText = d.phone;
// 处理积分显示
const pointsBadge = document.getElementById('pointsBadge');
const pointsDisplay = document.getElementById('pointsDisplay');
if (pointsBadge && pointsDisplay) {
pointsBadge.classList.remove('hidden');
pointsDisplay.innerText = d.points;
}
const headerPoints = document.getElementById('headerPoints');
if (headerPoints) headerPoints.innerText = d.points;
// 处理自定义 Key 显示逻辑
// 处理自定义 Key 显示逻辑
const keyBtn = document.getElementById('modeKeyBtn');
const modeButtonsContainer = keyBtn ? keyBtn.parentElement : null;
if (keyBtn && modeButtonsContainer) {
if (d.hide_custom_key) {
// 如果后端说要隐藏(用过积分生成),则隐藏整个按钮组
modeButtonsContainer.classList.add('hidden');
// 修改标题为“账户状态”
const authTitle = document.querySelector('#authSection h3');
if (authTitle) authTitle.innerText = '账户状态';
} else {
// 否则显示按钮组,让用户可以选择
modeButtonsContainer.classList.remove('hidden');
// 如果有 Key默认帮忙切过去方便老用户
if (d.api_key) {
switchMode('key');
const keyInput = document.getElementById('apiKey');
if (keyInput && !keyInput.value) keyInput.value = d.api_key;
return;
}
}
}
// 如果用户已经有绑定的 Key且当前没手动输入则默认切到 Key 模式
// 强制使用积分模式
switchMode('trial');
} else {
if (profile) profile.classList.add('hidden');
if (entry) entry.classList.remove('hidden');
if (loginHint) loginHint.classList.remove('hidden');
submitBtn.disabled = true;
submitBtn.classList.add('opacity-50', 'cursor-not-allowed');
}
lucide.createIcons();
}
// 移除 redundant logout 监听,因为 base.html 已处理全局登出
// 历史记录分页状态
let currentHistoryPage = 1;
let hasMoreHistory = true;
let isHistoryLoading = false;
// 存储当前生成的所有图片 URL
let currentGeneratedUrls = [];
let currentMode = 'trial'; // 'trial' 或 'key'
let uploadedFiles = []; // 存储当前待上传的参考图
let isSetterActive = false; // 是否激活了拍摄角度设置器模式
function switchMode(mode) {
currentMode = mode;
const trialBtn = document.getElementById('modeTrialBtn');
const keyBtn = document.getElementById('modeKeyBtn');
const keyInputGroup = document.getElementById('keyInputGroup');
const premiumToggle = document.getElementById('premiumToggle');
if (mode === 'trial') {
trialBtn.classList.add('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2');
trialBtn.classList.remove('border-slate-200', 'text-slate-400');
keyBtn.classList.remove('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2');
keyBtn.classList.add('border-slate-200', 'text-slate-400');
keyInputGroup.classList.add('hidden');
if (premiumToggle) premiumToggle.classList.remove('hidden');
} else {
keyBtn.classList.add('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2');
keyBtn.classList.remove('border-slate-200', 'text-slate-400');
trialBtn.classList.remove('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2');
trialBtn.classList.add('border-slate-200', 'text-slate-400');
keyInputGroup.classList.remove('hidden');
if (premiumToggle) premiumToggle.classList.add('hidden');
}
updateCostPreview(); // 切换模式时同步计费预览
}
async function downloadImage(url) {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
// 从 URL 提取文件名
const filename = url.split('/').pop().split('?')[0] || 'ai-vision-image.png';
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(blobUrl);
} catch (e) {
console.error('下载失败:', e);
showToast('下载失败,请尝试右键保存', 'error');
}
}
async function loadHistory(isLoadMore = false) {
if (isHistoryLoading || (!hasMoreHistory && isLoadMore)) return;
isHistoryLoading = true;
if (!isLoadMore) {
currentHistoryPage = 1;
document.getElementById('historyList').innerHTML = '';
}
const footer = document.getElementById('historyFooter');
footer.classList.remove('hidden');
try {
const r = await fetch(`/api/history?page=${currentHistoryPage}&per_page=10&filter_type=image`);
const d = await r.json();
const list = document.getElementById('historyList');
if (d.history && d.history.length > 0) {
const html = d.history.map(item => `
<div class="bg-white border border-slate-100 rounded-2xl p-4 space-y-3 hover:border-indigo-100 transition-all shadow-sm group">
<div class="flex items-center justify-between">
<span class="text-[10px] font-black text-slate-400 bg-slate-50 px-2 py-0.5 rounded-md uppercase tracking-widest">${item.created_at}</span>
<span class="text-[10px] font-bold text-indigo-500">${item.model}</span>
</div>
<div class="grid grid-cols-3 gap-2">
${item.urls.map(u => `
<div class="aspect-square rounded-lg overflow-hidden border border-slate-100 cursor-pointer transition-transform hover:scale-105" onclick="window.open('${u.url}')">
<img src="${u.thumb}" class="w-full h-full object-cover" loading="lazy">
</div>
`).join('')}
</div>
</div>
`).join('');
if (isLoadMore) {
list.insertAdjacentHTML('beforeend', html);
} else {
list.innerHTML = html;
}
hasMoreHistory = d.has_next;
currentHistoryPage++;
} else if (!isLoadMore) {
list.innerHTML = `<div class="flex flex-col items-center justify-center h-64 text-slate-300">
<i data-lucide="inbox" class="w-12 h-12 mb-4"></i>
<span class="text-xs font-bold">暂无生成记录</span>
</div>`;
}
lucide.createIcons();
} catch (e) {
console.error('加载历史失败:', e);
if (!isLoadMore) {
document.getElementById('historyList').innerHTML = `<div class="text-center text-rose-400 text-xs font-bold py-10">加载失败: ${e.message}</div>`;
}
} finally {
isHistoryLoading = false;
footer.classList.add('hidden');
}
}
async function init() {
checkAuth();
// 模式切换监听
const modeTrialBtn = document.getElementById('modeTrialBtn');
const modeKeyBtn = document.getElementById('modeKeyBtn');
const isPremiumCheckbox = document.getElementById('isPremium');
if (modeTrialBtn) modeTrialBtn.onclick = () => switchMode('trial');
if (modeKeyBtn) modeKeyBtn.onclick = () => switchMode('key');
if (isPremiumCheckbox) isPremiumCheckbox.onchange = () => updateCostPreview();
const numSelect = document.getElementById('numSelect');
if (numSelect) numSelect.onchange = () => updateCostPreview();
// 历史记录控制
const historyDrawer = document.getElementById('historyDrawer');
const showHistoryBtn = document.getElementById('showHistoryBtn');
const closeHistoryBtn = document.getElementById('closeHistoryBtn');
const historyList = document.getElementById('historyList');
// 3D 构图辅助控制
const openVisualizerBtn = document.getElementById('openVisualizerBtn');
const closeVisualizerBtn = document.getElementById('closeVisualizerBtn');
if (openVisualizerBtn) openVisualizerBtn.onclick = openVisualizerModal;
if (closeVisualizerBtn) closeVisualizerBtn.onclick = closeVisualizerModal;
const openSavePromptBtn = document.getElementById('openSavePromptBtn');
if (openSavePromptBtn) openSavePromptBtn.onclick = openSavePromptModal;
if (showHistoryBtn) {
showHistoryBtn.onclick = () => {
historyDrawer.classList.remove('translate-x-full');
loadHistory(false);
};
}
if (closeHistoryBtn) {
closeHistoryBtn.onclick = () => {
historyDrawer.classList.add('translate-x-full');
};
}
// 瀑布流滚动加载
if (historyList) {
historyList.onscroll = () => {
const threshold = 100;
if (historyList.scrollTop + historyList.clientHeight >= historyList.scrollHeight - threshold) {
loadHistory(true);
}
};
}
// 全部下载按钮逻辑
const downloadAllBtn = document.getElementById('downloadAllBtn');
if (downloadAllBtn) {
downloadAllBtn.onclick = async () => {
if (currentGeneratedUrls.length === 0) return;
showToast(`正在准备下载 ${currentGeneratedUrls.length} 张作品...`, 'info');
for (const url of currentGeneratedUrls) {
await downloadImage(url);
// 稍微延迟一下,防止浏览器拦截
await new Promise(r => setTimeout(r, 300));
}
};
}
// 重新生成按钮逻辑
const regenBtn = document.getElementById('regenBtn');
if (regenBtn) {
regenBtn.onclick = () => {
const submitBtn = document.getElementById('submitBtn');
if (submitBtn) submitBtn.click();
};
}
// 检查是否有来自 URL 的错误提示
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('error')) {
showToast(urlParams.get('error'), 'error');
// 清理 URL 参数以防刷新时重复提示
window.history.replaceState({}, document.title, window.location.pathname);
}
try {
await refreshPromptsList();
updateCostPreview(); // 初始化时显示默认模型的积分
} catch (e) { console.error(e); }
// 初始化拖拽排序
const prev = document.getElementById('imagePreview');
if (prev) {
new Sortable(prev, {
animation: 150,
ghostClass: 'opacity-50',
onEnd: function (evt) {
// 拖拽结束,重新对 uploadedFiles 排序
const oldIndex = evt.oldIndex;
const newIndex = evt.newIndex;
const movedFile = uploadedFiles.splice(oldIndex, 1)[0];
uploadedFiles.splice(newIndex, 0, movedFile);
// 重新渲染以更新“图1/图2/图3”标签
renderImagePreviews();
}
});
}
}
function fillSelect(id, list) {
const el = document.getElementById(id);
if (!el) return;
if (id === 'modelSelect') {
el.innerHTML = list.map(i => `<option value="${i.value}" data-cost="${i.cost || 0}">${i.label} (${i.cost || 0}积分)</option>`).join('');
} else {
el.innerHTML = list.map(i => {
// 处理 data-id
const dataIdAttr = i.id ? ` data-id="${i.id}"` : '';
const disabledAttr = i.disabled ? ' disabled' : '';
return `<option value="${i.value}"${dataIdAttr}${disabledAttr}>${i.label}</option>`;
}).join('');
}
}
// 更新计费预览显示
function updateCostPreview() {
const modelSelect = document.getElementById('modelSelect');
const costPreview = document.getElementById('costPreview');
const numSelect = document.getElementById('numSelect');
const isPremium = document.getElementById('isPremium')?.checked || false;
const selectedOption = modelSelect.options[modelSelect.selectedIndex];
if (currentMode === 'trial' && selectedOption) {
let baseCost = parseInt(selectedOption.getAttribute('data-cost') || 0);
let num = parseInt(numSelect?.value || 1);
let totalCost = baseCost * num;
if (isPremium) totalCost *= 2; // 优质模式 2 倍积分
costPreview.innerText = `本次生成将消耗 ${totalCost} 积分`;
costPreview.classList.remove('hidden');
} else {
costPreview.classList.add('hidden');
}
}
// 渲染参考图预览
function renderImagePreviews() {
const prev = document.getElementById('imagePreview');
if (!prev) return;
prev.innerHTML = '';
uploadedFiles.forEach((file, index) => {
// 同步创建容器,确保编号顺序永远正确
const d = document.createElement('div');
d.className = 'relative w-20 h-20 rounded-2xl overflow-hidden flex-shrink-0 border-2 border-white shadow-md group cursor-move';
d.setAttribute('data-index', index);
d.innerHTML = `
<div class="w-full h-full bg-slate-100 animate-pulse flex items-center justify-center">
<i data-lucide="image" class="w-5 h-5 text-slate-300"></i>
</div>
<div class="absolute bottom-1 left-1 bg-black/60 text-white text-[8px] px-1.5 py-0.5 rounded-md font-black backdrop-blur-sm z-10">图${index + 1}</div>
<button onclick="removeUploadedFile(${index})" class="absolute top-1 right-1 bg-rose-500 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center z-20">
<i data-lucide="x" class="w-3 h-3"></i>
</button>
`;
prev.appendChild(d);
// 异步加载图片内容
const reader = new FileReader();
reader.onload = (ev) => {
d.innerHTML = `
<img src="${ev.target.result}" class="w-full h-full object-cover">
<div class="absolute bottom-1 left-1 bg-black/60 text-white text-[8px] px-1.5 py-0.5 rounded-md font-black backdrop-blur-sm z-10">图${index + 1}</div>
<button onclick="removeUploadedFile(${index})" class="absolute top-1 right-1 bg-rose-500 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center z-20">
<i data-lucide="x" class="w-3 h-3"></i>
</button>
`;
lucide.createIcons();
};
reader.readAsDataURL(file);
});
lucide.createIcons();
}
function removeUploadedFile(index) {
uploadedFiles.splice(index, 1);
renderImagePreviews();
}
// 统一处理新上传/粘贴/拖入的文件
function handleNewFiles(files) {
const newFiles = Array.from(files).filter(f => f.type.startsWith('image/'));
if (newFiles.length === 0) return;
// 如果处于设置器模式,严格限制为 1 张
if (isSetterActive) {
if (newFiles.length > 0) {
uploadedFiles = [newFiles[0]];
showToast('设置器模式已开启,仅保留第一张参考图', 'info');
}
} else {
if (uploadedFiles.length + newFiles.length > 3) {
showToast('最多只能上传 3 张参考图', 'warning');
const remaining = 3 - uploadedFiles.length;
if (remaining > 0) {
uploadedFiles = uploadedFiles.concat(newFiles.slice(0, remaining));
}
} else {
uploadedFiles = uploadedFiles.concat(newFiles);
}
}
renderImagePreviews();
}
document.getElementById('fileInput').onchange = (e) => {
handleNewFiles(e.target.files);
e.target.value = ''; // 重置以允许重复选择同一文件
};
// 拖拽上传逻辑
const dropZone = document.getElementById('dropZone');
if (dropZone) {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(name => {
dropZone.addEventListener(name, (e) => {
e.preventDefault();
e.stopPropagation();
}, false);
});
['dragenter', 'dragover'].forEach(name => {
dropZone.addEventListener(name, () => dropZone.classList.add('border-indigo-500', 'bg-indigo-50/50'), false);
});
['dragleave', 'drop'].forEach(name => {
dropZone.addEventListener(name, () => dropZone.classList.remove('border-indigo-500', 'bg-indigo-50/50'), false);
});
dropZone.addEventListener('drop', (e) => {
handleNewFiles(e.dataTransfer.files);
}, false);
}
// 粘贴上传逻辑
document.addEventListener('paste', (e) => {
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
const files = [];
for (let item of items) {
if (item.type.indexOf('image') !== -1) {
files.push(item.getAsFile());
}
}
if (files.length > 0) {
handleNewFiles(files);
}
});
document.getElementById('modelSelect').onchange = (e) => {
document.getElementById('sizeGroup').classList.toggle('hidden', e.target.value !== 'nano-banana-2');
updateCostPreview(); // 切换模型时更新计费预览
};
document.getElementById('promptTpl').onchange = (e) => {
const area = document.getElementById('manualPrompt');
const deleteContainer = document.getElementById('deletePromptContainer');
const selectedOption = e.target.options[e.target.selectedIndex];
// 判断是否是用户的收藏项 (通过 data-id 属性)
const promptId = selectedOption.getAttribute('data-id');
if (promptId) {
deleteContainer.classList.remove('hidden');
document.getElementById('deletePromptBtn').onclick = () => openDeleteConfirmModal(promptId);
} else {
deleteContainer.classList.add('hidden');
}
if (e.target.value !== 'manual') {
area.value = e.target.value;
area.classList.add('hidden');
} else {
area.value = '';
area.classList.remove('hidden');
}
};
document.getElementById('submitBtn').onclick = async () => {
const btn = document.getElementById('submitBtn');
const prompt = document.getElementById('manualPrompt').value;
const apiKey = document.getElementById('apiKey').value;
const num = parseInt(document.getElementById('numSelect').value);
// 检查登录状态并获取积分
const authCheck = await fetch('/api/auth/me');
const authData = await authCheck.json();
if (!authData.logged_in) {
showToast('请先登录后再生成作品', 'warning');
return;
}
// 根据模式验证
if (currentMode === 'key') {
if (!apiKey) return showToast('请输入您的 API 密钥', 'warning');
} else {
if (authData.points <= 0) return showToast('可用积分已耗尽,请充值或切换至自定义 Key 模式', 'warning');
}
// 允许文生图(不强制要求图片),但至少得有提示词或图片
if (!prompt && uploadedFiles.length === 0) {
return showToast('请至少输入提示词或上传参考图', 'warning');
}
// UI 锁定
btn.disabled = true;
const btnText = btn.querySelector('span');
btnText.innerText = uploadedFiles.length > 0 ? "正在同步参考图..." : "正在开启 AI 引擎...";
document.getElementById('statusInfo').classList.remove('hidden');
document.getElementById('placeholder').classList.add('hidden');
document.getElementById('finalWrapper').classList.remove('hidden');
const grid = document.getElementById('imageGrid');
grid.innerHTML = ''; // 清空
currentGeneratedUrls = []; // 重置当前生成列表
try {
let image_data = [];
// 1. 将图片转换为 Base64
if (uploadedFiles.length > 0) {
btnText.innerText = "正在准备图片数据...";
const readFileAsBase64 = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
image_data = await Promise.all(uploadedFiles.map(f => readFileAsBase64(f)));
}
// 2. 并行启动多个生成任务
btnText.innerText = `AI 构思中 (0/${num})...`;
let finishedCount = 0;
const startTask = async (index) => {
const slot = document.createElement('div');
slot.className = 'image-frame relative bg-white/50 animate-pulse min-h-[200px] flex items-center justify-center rounded-[2.5rem] border border-slate-100 shadow-sm';
slot.innerHTML = `<div class="text-slate-400 text-[10px] font-bold italic">正在排队中...</div>`;
grid.appendChild(slot);
try {
// 1. 发起生成请求,获取任务 ID
const r = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: currentMode,
is_premium: document.getElementById('isPremium')?.checked || false,
apiKey: currentMode === 'key' ? apiKey : '',
prompt: document.getElementById('manualPrompt').value,
model: document.getElementById('modelSelect').value,
ratio: document.getElementById('ratioSelect').value,
size: document.getElementById('sizeSelect').value,
image_data // 发送 Base64 数组
})
});
const res = await r.json();
if (res.error) throw new Error(res.error);
// 如果直接返回了 data (比如聊天模型),直接显示
if (res.data) {
displayResult(slot, res.data[0]);
return;
}
// 2. 轮询任务状态
const taskId = res.task_id;
let pollCount = 0;
const maxPolls = 500; // 最多轮询约 16 分钟 (2s * 500 = 1000s)
while (pollCount < maxPolls) {
await new Promise(resolve => setTimeout(resolve, 2000));
pollCount++;
const statusR = await fetch(`/api/task_status/${taskId}`);
const statusRes = await statusR.json();
if (statusRes.status === 'complete') {
const imgUrl = statusRes.urls[0];
currentGeneratedUrls.push(imgUrl);
displayResult(slot, { url: imgUrl });
finishedCount++;
btnText.innerText = `AI 构思中 (${finishedCount}/${num})...`;
if (currentMode === 'trial') checkAuth();
return; // 任务正常结束
} else if (statusRes.status === 'error') {
throw new Error(statusRes.message || "生成失败");
} else {
// 继续轮询状态显示
slot.innerHTML = `<div class="text-slate-400 text-[10px] font-bold italic">AI 正在努力创作中 (${pollCount * 2}s)...</div>`;
}
}
throw new Error("生成超时,请稍后在历史记录中查看");
} catch (e) {
slot.className = 'image-frame relative bg-red-50/50 flex items-center justify-center rounded-[2.5rem] border border-red-100 p-6';
if (e.message.includes('401') || e.message.includes('请先登录')) {
slot.innerHTML = `<div class="text-red-400 text-[10px] font-bold text-center">登录已过期,请重新登录</div>`;
} else {
slot.innerHTML = `<div class="text-red-400 text-[10px] font-bold text-center">生成异常: ${e.message}</div>`;
}
}
};
const tasks = Array.from({ length: num }, (_, i) => startTask(i));
await Promise.all(tasks);
} catch (e) {
showToast('创作引擎中断: ' + e.message, 'error');
document.getElementById('placeholder').classList.remove('hidden');
} finally {
btn.disabled = false;
btnText.innerText = "立即生成作品";
document.getElementById('statusInfo').classList.add('hidden');
}
};
// 提取结果展示逻辑
const displayResult = (slot, data) => {
if (data.type === 'text') {
slot.className = 'image-frame relative bg-white border border-slate-100 p-8 rounded-[2.5rem] shadow-xl overflow-y-auto max-h-[600px]';
slot.innerHTML = `<div class="prose prose-slate prose-sm max-w-none text-slate-600 font-medium leading-relaxed">${data.content.replace(/\n/g, '<br>')}</div>`;
} else {
const imgUrl = data.url;
slot.className = 'image-frame group relative animate-in zoom-in-95 duration-700 flex flex-col items-center justify-center overflow-hidden bg-white shadow-2xl transition-all hover:shadow-indigo-100/50';
slot.innerHTML = `
<div class="w-full h-full flex items-center justify-center bg-slate-50/20 p-2">
<img src="${imgUrl}" class="max-w-full max-h-[60vh] md:max-h-[70vh] object-contain rounded-2xl shadow-sm transition-transform duration-500 group-hover:scale-[1.01]">
</div>
<div class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-slate-900/40 backdrop-blur-[2px] rounded-[2.5rem]">
<button onclick="downloadImage('${imgUrl}')" class="bg-white/90 p-4 rounded-full text-indigo-600 shadow-xl hover:scale-110 transition-transform">
<i data-lucide="download-cloud" class="w-6 h-6"></i>
</button>
</div>
`;
}
lucide.createIcons();
};
init();
// 修改密码弹窗控制
function openPwdModal() {
const modal = document.getElementById('pwdModal');
if (!modal) return;
modal.classList.remove('hidden');
setTimeout(() => {
modal.classList.add('opacity-100');
modal.querySelector('div').classList.remove('scale-95');
}, 10);
}
function closePwdModal() {
const modal = document.getElementById('pwdModal');
if (!modal) return;
modal.classList.remove('opacity-100');
modal.querySelector('div').classList.add('scale-95');
setTimeout(() => {
modal.classList.add('hidden');
document.getElementById('pwdForm').reset();
}, 300);
}
document.addEventListener('click', (e) => {
if (e.target.closest('#openPwdModalBtn')) {
openPwdModal();
}
});
document.getElementById('pwdForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const old_password = document.getElementById('oldPwd').value;
const new_password = document.getElementById('newPwd').value;
try {
const r = await fetch('/api/auth/change_password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ old_password, new_password })
});
const d = await r.json();
if (r.ok) {
showToast('密码修改成功,请记牢新密码', 'success');
closePwdModal();
} else {
showToast(d.error || '修改失败', 'error');
}
} catch (err) {
showToast('网络连接失败', 'error');
}
});
// 拍摄角度设置器弹窗控制
function openVisualizerModal() {
const modal = document.getElementById('visualizerModal');
if (!modal) return;
// 检查是否已上传参考图
if (uploadedFiles.length === 0) {
return showToast('请先上传一张参考图作为基准', 'warning');
}
modal.classList.remove('hidden');
setTimeout(() => {
modal.classList.add('opacity-100');
modal.querySelector('div').classList.remove('scale-95');
// 将主页面的图片同步到设置器 iframe
const reader = new FileReader();
reader.onload = (e) => {
const iframe = document.getElementById('visualizerFrame');
iframe.contentWindow.postMessage({ type: 'sync_image', dataUrl: e.target.result }, '*');
};
reader.readAsDataURL(uploadedFiles[0]);
}, 10);
}
function closeVisualizerModal() {
const modal = document.getElementById('visualizerModal');
if (!modal) return;
modal.classList.remove('opacity-100');
modal.querySelector('div').classList.add('scale-95');
setTimeout(() => {
modal.classList.add('hidden');
}, 300);
}
// 监听来自设置器的消息
window.addEventListener('message', (e) => {
if (e.data.type === 'apply_prompt') {
isSetterActive = true;
const area = document.getElementById('manualPrompt');
const promptTpl = document.getElementById('promptTpl');
const modelSelect = document.getElementById('modelSelect');
const sizeGroup = document.getElementById('sizeGroup');
// 1. 强制切换并锁定版本 2.0 (nano-banana-2)
if (modelSelect) {
modelSelect.value = 'nano-banana-2';
modelSelect.disabled = true; // 锁定选择
sizeGroup.classList.remove('hidden');
updateCostPreview();
}
// 2. 隐藏模板选择器
if (promptTpl) {
promptTpl.classList.add('hidden');
promptTpl.value = 'manual';
}
// 3. 强制图片数量为 1
if (uploadedFiles.length > 1) {
uploadedFiles = uploadedFiles.slice(0, 1);
renderImagePreviews();
}
// 4. 替换提示词
if (area) {
area.value = e.data.prompt;
area.classList.remove('hidden');
showToast('已同步拍摄角度并切换至 2.0 引擎', 'success');
}
closeVisualizerModal();
}
});
// --- 积分与钱包中心逻辑 ---
let pointsChart = null;
async function openPointsModal() {
const modal = document.getElementById('pointsModal');
if (!modal) return;
modal.classList.remove('hidden');
setTimeout(() => {
modal.classList.add('opacity-100');
modal.querySelector('div').classList.remove('scale-95');
}, 10);
// 加载数据
loadPointStats();
loadPointDetails();
// 更新当前余额
const r = await fetch('/api/auth/me');
const d = await r.json();
if (d.logged_in) {
document.getElementById('modalPointsDisplay').innerText = d.points;
}
}
function closePointsModal() {
const modal = document.getElementById('pointsModal');
modal.classList.remove('opacity-100');
modal.querySelector('div').classList.add('scale-95');
setTimeout(() => modal.classList.add('hidden'), 300);
}
async function loadPointStats() {
const r = await fetch('/api/stats/points?days=7');
const d = await r.json();
const canvas = document.getElementById('pointsChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (pointsChart) pointsChart.destroy();
if (typeof Chart === 'undefined') {
console.error('Chart.js not loaded');
return;
}
pointsChart = new Chart(ctx, {
type: 'line',
data: {
labels: d.labels,
datasets: [
{
label: '消耗积分',
data: d.deductions,
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
borderWidth: 3,
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: '#6366f1'
},
{
label: '充值积分',
data: d.incomes,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
borderWidth: 3,
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: '#10b981'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(241, 245, 249, 1)' },
ticks: { font: { weight: 'bold' } }
},
x: {
grid: { display: false },
ticks: { font: { weight: 'bold' } }
}
}
}
});
}
async function loadPointDetails() {
const body = document.getElementById('pointDetailsBody');
if (!body) return;
body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-slate-400">正在加载明细...</td></tr>';
try {
const r = await fetch('/api/stats/details?page=1');
const d = await r.json();
if (d.items.length === 0) {
body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-slate-400">暂无积分变动记录</td></tr>';
return;
}
body.innerHTML = d.items.map(item => `
<tr class="hover:bg-slate-50 transition-colors border-b border-slate-50">
<td class="px-8 py-5">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full ${item.type === 'deduction' ? 'bg-indigo-500' : 'bg-emerald-500'}"></div>
<span class="truncate max-w-[200px]">${item.desc}</span>
</div>
</td>
<td class="px-8 py-5 text-slate-400 uppercase font-black text-[10px] tracking-widest">${item.model}</td>
<td class="px-8 py-5 font-black ${item.type === 'deduction' ? 'text-rose-500' : 'text-emerald-500'}">${item.change}</td>
<td class="px-8 py-5 text-right font-mono text-slate-400">${item.time}</td>
</tr>
`).join('');
} catch (e) {
body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-rose-500">加载失败</td></tr>';
}
}
// --- 自定义提示词逻辑 ---
async function loadUserPrompts() {
try {
const r = await fetch('/api/prompts');
const d = await r.json();
return d.error ? [] : d;
} catch (e) {
return [];
}
}
async function refreshPromptsList() {
try {
const r = await fetch('/api/config');
const d = await r.json();
fillSelect('modelSelect', d.models);
fillSelect('ratioSelect', d.ratios);
fillSelect('sizeSelect', d.sizes);
const userPrompts = await loadUserPrompts();
const mergedPrompts = [
{ label: '✨ 自定义创作', value: 'manual' },
...(userPrompts.length > 0 ? [{ label: '--- 我的收藏 ---', value: 'manual', disabled: true }] : []),
...userPrompts.map(p => ({ id: p.id, label: '⭐ ' + p.label, value: p.value })),
{ label: '--- 系统预设 ---', value: 'manual', disabled: true },
...d.prompts
];
fillSelect('promptTpl', mergedPrompts);
} catch (e) { console.error(e); }
}
function openSavePromptModal() {
// Check login
if (document.getElementById('loginEntryBtn').offsetParent !== null) {
showToast('请先登录', 'warning');
return;
}
const modal = document.getElementById('savePromptModal');
const input = document.getElementById('promptTitleInput');
const prompt = document.getElementById('manualPrompt').value;
if (!prompt || !prompt.trim()) {
showToast('请先输入一些提示词内容', 'warning');
return;
}
if (modal) {
modal.classList.remove('hidden');
input.value = '';
setTimeout(() => {
modal.classList.add('opacity-100');
modal.querySelector('div').classList.remove('scale-95');
input.focus();
}, 10);
}
}
function closeSavePromptModal() {
const modal = document.getElementById('savePromptModal');
if (modal) {
modal.classList.remove('opacity-100');
modal.querySelector('div').classList.add('scale-95');
setTimeout(() => modal.classList.add('hidden'), 300);
}
}
async function confirmSavePrompt() {
const title = document.getElementById('promptTitleInput').value.trim();
const prompt = document.getElementById('manualPrompt').value.trim();
if (!title) {
showToast('请输入标题', 'warning');
return;
}
try {
const r = await fetch('/api/prompts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, prompt })
});
const d = await r.json();
if (r.ok) {
showToast('收藏成功', 'success');
closeSavePromptModal();
refreshPromptsList();
} else {
showToast(d.error || '保存失败', 'error');
}
} catch (e) {
showToast('保存异常', 'error');
}
}
async function deleteUserPrompt(id) {
// Legacy function replaced by openDeleteConfirmModal
// Kept for reference or simple fallback if needed, but logic moved to executeDeletePrompt
}
function openDeleteConfirmModal(id) {
const modal = document.getElementById('deleteConfirmModal');
const confirmBtn = document.getElementById('confirmDeleteBtn');
if (modal && confirmBtn) {
// Store ID on the button
confirmBtn.setAttribute('data-delete-id', id);
confirmBtn.onclick = () => executeDeletePrompt(id); // Bind click
modal.classList.remove('hidden');
setTimeout(() => {
modal.classList.add('opacity-100');
modal.querySelector('div').classList.remove('scale-95');
}, 10);
}
}
function closeDeleteConfirmModal() {
const modal = document.getElementById('deleteConfirmModal');
if (modal) {
modal.classList.remove('opacity-100');
modal.querySelector('div').classList.add('scale-95');
setTimeout(() => modal.classList.add('hidden'), 300);
}
}
async function executeDeletePrompt(id) {
const confirmBtn = document.getElementById('confirmDeleteBtn');
const originalText = confirmBtn.innerHTML;
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i>';
lucide.createIcons();
try {
const r = await fetch(`/api/prompts/${id}`, {
method: 'DELETE'
});
if (r.ok) {
showToast('已删除', 'success');
// Reset selection
document.getElementById('promptTpl').value = 'manual';
document.getElementById('manualPrompt').value = '';
document.getElementById('manualPrompt').classList.remove('hidden');
document.getElementById('deletePromptContainer').classList.add('hidden');
closeDeleteConfirmModal();
refreshPromptsList();
} else {
showToast('删除失败', 'error');
}
} catch (e) {
showToast('删除异常', 'error');
} finally {
confirmBtn.disabled = false;
confirmBtn.innerHTML = originalText;
}
}