ai_v/templates/mobile.html
24024 fbdb232502 ```
feat(app): 添加移动端检测和配置优化

- 添加request和redirect导入以支持移动端检测
- 实现用户代理检测逻辑,自动重定向移动设备到/mobile页面
- 优化mobile.html模板中的UI布局和标签文本
- 移动端特定功能:调整尺寸选择器显示逻辑,仅在特定模型时显示
- 添加fillSelect工具函数统一处理下拉选项填充
- 集成用户收藏提示词功能,合并系统预设和用户自定义模板
- 改进错误处理机制,在配置加载和历史记录加载中添加try-catch
- 优化历史记录数据显示,适配新的数据结构字段
- 增强成本预览功能,实时计算积分消耗并在UI上展示
```
2026-03-13 22:28:20 +08:00

860 lines
40 KiB
HTML

<!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>AI 视界 - 移动端</title>
<link rel="icon" href="/static/A_2IGfT6uTwlgAAAAAQmAAAAgAerF1AQ.png" type="image/png">
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
<script src="{{ url_for('static', filename='js/lucide.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<style>
* {
-webkit-tap-highlight-color: transparent;
}
body {
overscroll-behavior: none;
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0);
}
.safe-area-top {
padding-top: env(safe-area-inset-top, 0);
}
/* 隐藏滚动条 */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* 底部弹出动画 */
.bottom-sheet {
transform: translateY(100%);
transition: transform 0.3s ease-out;
}
.bottom-sheet.active {
transform: translateY(0);
}
/* 加载动画 */
@keyframes pulse-ring {
0% { transform: scale(0.8); opacity: 1; }
100% { transform: scale(1.5); opacity: 0; }
}
.pulse-ring {
animation: pulse-ring 1.5s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
}
</style>
</head>
<body class="text-slate-700 antialiased bg-slate-50 overflow-hidden">
<div id="toastContainer" class="toast-container"></div>
<!-- 主容器 -->
<div class="flex flex-col h-screen w-screen overflow-hidden">
<!-- 顶部状态栏 -->
<header class="safe-area-top flex-shrink-0 bg-white/80 backdrop-blur-xl border-b border-slate-100 z-30">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-2">
<img src="/static/A_2IGfT6uTwlgAAAAAQmAAAAgAerF1AQ.png" alt="Logo" class="w-8 h-8 rounded-xl">
<span class="text-base font-black text-slate-900">AI 视界</span>
</div>
<div id="headerRight" class="flex items-center gap-3">
<!-- 未登录状态 -->
<a id="loginEntryBtn" href="/login" class="bg-indigo-600 text-white px-4 py-2 rounded-xl text-xs font-bold">
登录
</a>
<!-- 已登录状态 -->
<div id="userProfile" class="hidden flex items-center gap-2 bg-indigo-50 px-3 py-1.5 rounded-xl" onclick="openPointsModal()">
<i data-lucide="wallet" class="w-4 h-4 text-indigo-600"></i>
<span id="headerPoints" class="text-xs font-bold text-indigo-600">0</span>
</div>
</div>
</div>
</header>
<!-- 主内容区域 -->
<main class="flex-1 overflow-y-auto hide-scrollbar bg-slate-50">
<!-- 结果展示区 -->
<div id="resultArea" class="min-h-full">
<!-- 占位状态 -->
<div id="placeholder" class="flex flex-col items-center justify-center px-6 py-12 min-h-[60vh]">
<div class="w-32 h-32 bg-white rounded-[3rem] shadow-xl flex items-center justify-center mb-6 border border-slate-100">
<i data-lucide="image" class="w-16 h-16 text-indigo-500"></i>
</div>
<h2 class="text-xl font-black text-slate-900 mb-2">AI 图片生成</h2>
<p class="text-sm text-slate-400 text-center">点击下方设置按钮开始创作</p>
</div>
<!-- 结果网格 -->
<div id="resultGrid" class="hidden p-4 space-y-4">
<!-- 动态生成结果 -->
</div>
<!-- 加载状态 -->
<div id="loadingState" class="hidden flex flex-col items-center justify-center py-20">
<div class="relative">
<div class="w-20 h-20 rounded-full bg-indigo-100 flex items-center justify-center">
<i data-lucide="sparkles" class="w-10 h-10 text-indigo-600 animate-pulse"></i>
</div>
<div class="absolute inset-0 rounded-full bg-indigo-200 pulse-ring"></div>
</div>
<p class="mt-4 text-sm font-bold text-slate-500">AI 正在创作中...</p>
<p id="loadingProgress" class="text-xs text-slate-400 mt-1"></p>
</div>
</div>
</main>
<!-- 底部操作栏 -->
<footer class="flex-shrink-0 bg-white border-t border-slate-100 safe-area-bottom z-30">
<div class="flex items-center justify-around py-3 px-4">
<button id="historyBtn" class="flex flex-col items-center gap-1 p-2 rounded-xl transition-colors">
<i data-lucide="history" class="w-6 h-6 text-slate-400"></i>
<span class="text-[10px] font-bold text-slate-400">历史</span>
</button>
<button id="generateBtn" class="flex items-center justify-center bg-indigo-600 text-white px-8 py-3 rounded-2xl shadow-lg shadow-indigo-200 active:scale-95 transition-transform">
<i data-lucide="wand-2" class="w-5 h-5 mr-2"></i>
<span class="text-sm font-bold">开始生成</span>
</button>
<button id="settingsBtn" class="flex flex-col items-center gap-1 p-2 rounded-xl transition-colors">
<i data-lucide="sliders-horizontal" class="w-6 h-6 text-slate-400"></i>
<span class="text-[10px] font-bold text-slate-400">设置</span>
</button>
</div>
</footer>
</div>
<!-- 设置面板 (底部弹出) -->
<div id="settingsOverlay" class="fixed inset-0 bg-slate-900/40 z-40 hidden opacity-0 transition-opacity duration-300"></div>
<div id="settingsSheet" class="fixed inset-x-0 bottom-0 bg-white rounded-t-3xl z-50 bottom-sheet max-h-[85vh] overflow-hidden">
<div class="flex flex-col h-full max-h-[85vh]">
<!-- 拖动条 -->
<div class="flex justify-center py-3">
<div class="w-10 h-1 bg-slate-200 rounded-full"></div>
</div>
<!-- 标题 -->
<div class="flex items-center justify-between px-5 pb-4 border-b border-slate-100">
<h3 class="text-lg font-black text-slate-900">创作设置</h3>
<button id="closeSettingsBtn" class="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center">
<i data-lucide="x" class="w-4 h-4 text-slate-500"></i>
</button>
</div>
<!-- 设置内容 -->
<div class="flex-1 overflow-y-auto hide-scrollbar p-5 space-y-6">
<!-- 积分显示 -->
<div id="pointsBadge" class="hidden bg-amber-50 border border-amber-100 rounded-2xl p-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-amber-100 rounded-xl flex items-center justify-center">
<i data-lucide="coins" class="w-5 h-5 text-amber-600"></i>
</div>
<div>
<p class="text-[10px] font-bold text-amber-500 uppercase">可用积分</p>
<p id="pointsDisplay" class="text-xl font-black text-amber-600">0</p>
</div>
</div>
<a href="/buy" class="px-4 py-2 bg-amber-500 text-white rounded-xl text-xs font-bold">去充值</a>
</div>
<!-- 优质渲染模式 -->
<div id="premiumToggle" class="bg-gradient-to-r from-amber-50 to-orange-50 border border-amber-100 rounded-2xl p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-amber-100 rounded-xl flex items-center justify-center">
<i data-lucide="sparkles" class="w-5 h-5 text-amber-600"></i>
</div>
<div>
<p class="text-sm font-bold text-slate-800">优质渲染模式</p>
<p class="text-[10px] text-amber-500">专属通道 · 积分X2</p>
</div>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="isPremium" class="sr-only peer">
<div class="w-11 h-6 bg-slate-200 rounded-full peer peer-checked:bg-amber-500 after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full"></div>
</label>
</div>
</div>
<!-- 模型选择 -->
<div class="space-y-2">
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-2">
<i data-lucide="cpu" class="w-4 h-4"></i>计算模型
</label>
<select id="modelSelect" class="w-full bg-white border border-slate-200 rounded-xl p-3.5 text-sm font-bold outline-none focus:border-indigo-500 transition-all appearance-none bg-[url('data:image/svg+xml;charset=US-ASCII,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"%2394a3b8\" stroke-width=\"2\"><polyline points=\"6 9 12 15 18 9\"></polyline></svg>')] bg-no-repeat bg-[right_1rem_center] bg-[length:1rem]">
</select>
</div>
<!-- 比例和数量 -->
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-2">
<i data-lucide="layout" class="w-4 h-4"></i>画面比例
</label>
<select id="ratioSelect" class="w-full bg-white border border-slate-200 rounded-xl p-3.5 text-sm font-bold outline-none focus:border-indigo-500 transition-all appearance-none bg-[url('data:image/svg+xml;charset=US-ASCII,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"%2394a3b8\" stroke-width=\"2\"><polyline points=\"6 9 12 15 18 9\"></polyline></svg>')] bg-no-repeat bg-[right_0.5rem_center] bg-[length:1rem]">
</select>
</div>
<div class="space-y-2">
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-2">
<i data-lucide="copy" class="w-4 h-4"></i>生成数量
</label>
<select id="numSelect" class="w-full bg-white border border-slate-200 rounded-xl p-3.5 text-sm font-bold outline-none focus:border-indigo-500 transition-all appearance-none bg-[url('data:image/svg+xml;charset=US-ASCII,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"%2394a3b8\" stroke-width=\"2\"><polyline points=\"6 9 12 15 18 9\"></polyline></svg>')] bg-no-repeat bg-[right_0.5rem_center] bg-[length:1rem]">
<option value="1">1 张</option>
<option value="2">2 张</option>
<option value="3">3 张</option>
<option value="4">4 张</option>
</select>
</div>
</div>
<!-- 输出尺寸 (仅特定模型显示) -->
<div id="sizeGroup" class="hidden space-y-2">
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-2">
<i data-lucide="maximize" class="w-4 h-4"></i>输出尺寸
</label>
<select id="sizeSelect" class="w-full bg-white border border-slate-200 rounded-xl p-3.5 text-sm font-bold outline-none focus:border-indigo-500 transition-all appearance-none bg-[url('data:image/svg+xml;charset=US-ASCII,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"%2394a3b8\" stroke-width=\"2\"><polyline points=\"6 9 12 15 18 9\"></polyline></svg>')] bg-no-repeat bg-[right_1rem_center] bg-[length:1rem]">
</select>
</div>
<!-- 提示词 -->
<div class="space-y-2">
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-2">
<i data-lucide="pen-tool" class="w-4 h-4"></i>提示词
</label>
<select id="promptTpl" class="w-full bg-slate-50 border border-slate-200 rounded-xl p-3 text-xs font-bold text-indigo-600 outline-none mb-2">
<option value="manual">自定义创作</option>
</select>
<textarea id="manualPrompt" rows="3" class="w-full bg-white border border-slate-200 rounded-xl p-3.5 text-sm outline-none focus:border-indigo-500 transition-all resize-none" placeholder="描述您想要生成的图片..."></textarea>
</div>
<!-- 参考图片 -->
<div class="space-y-2">
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-2">
<i data-lucide="image-plus" class="w-4 h-4"></i>参考底图 (可选)
</label>
<div id="dropZone" class="border-2 border-dashed border-slate-200 rounded-2xl p-6 text-center bg-slate-50 active:bg-slate-100 transition-colors">
<input id="fileInput" type="file" multiple accept="image/*" class="hidden">
<i data-lucide="image-plus" class="w-8 h-8 mx-auto mb-2 text-slate-300"></i>
<p class="text-xs text-slate-400 font-bold">点击上传参考图片</p>
</div>
<div id="imagePreview" class="flex flex-wrap gap-2 mt-2"></div>
</div>
<!-- 积分消耗预览 -->
<p id="costPreview" class="text-center text-xs text-amber-600 font-bold hidden"></p>
</div>
<!-- 底部按钮 -->
<div class="p-4 border-t border-slate-100 bg-white safe-area-bottom">
<button id="applySettingsBtn" class="w-full bg-indigo-600 text-white py-4 rounded-2xl font-bold text-sm shadow-lg shadow-indigo-200 active:scale-[0.98] transition-transform">
应用设置
</button>
</div>
</div>
</div>
<!-- 历史记录面板 -->
<div id="historyOverlay" class="fixed inset-0 bg-slate-900/40 z-40 hidden opacity-0 transition-opacity duration-300"></div>
<div id="historySheet" class="fixed inset-x-0 bottom-0 bg-white rounded-t-3xl z-50 bottom-sheet max-h-[85vh] overflow-hidden">
<div class="flex flex-col h-full max-h-[85vh]">
<!-- 拖动条 -->
<div class="flex justify-center py-3">
<div class="w-10 h-1 bg-slate-200 rounded-full"></div>
</div>
<!-- 标题 -->
<div class="flex items-center justify-between px-5 pb-4 border-b border-slate-100">
<h3 class="text-lg font-black text-slate-900">创作历史</h3>
<button id="closeHistoryBtn" class="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center">
<i data-lucide="x" class="w-4 h-4 text-slate-500"></i>
</button>
</div>
<!-- 历史列表 -->
<div id="historyList" class="flex-1 overflow-y-auto hide-scrollbar p-4 space-y-3">
<div class="text-center py-10 text-slate-400">
<i data-lucide="inbox" class="w-12 h-12 mx-auto mb-2 opacity-50"></i>
<p class="text-sm font-bold">暂无历史记录</p>
</div>
</div>
</div>
</div>
<!-- 积分中心弹窗 -->
<div id="pointsModal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[60] flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
<div class="bg-white w-[90%] max-w-md rounded-3xl shadow-2xl overflow-hidden transform scale-95 transition-transform duration-300">
<!-- Header -->
<div class="p-6 border-b border-slate-100">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-amber-100 rounded-xl flex items-center justify-center">
<i data-lucide="wallet" class="w-5 h-5 text-amber-600"></i>
</div>
<div>
<h2 class="text-lg font-black text-slate-900">积分中心</h2>
<p class="text-[10px] text-slate-400 font-bold">Points Center</p>
</div>
</div>
<button onclick="closePointsModal()" class="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center">
<i data-lucide="x" class="w-4 h-4 text-slate-500"></i>
</button>
</div>
</div>
<!-- Content -->
<div class="p-6 space-y-4">
<div class="bg-gradient-to-br from-indigo-500 to-violet-600 rounded-2xl p-5 text-white">
<p class="text-xs font-bold opacity-80">当前可用积分</p>
<p id="modalPointsDisplay" class="text-4xl font-black mt-1">0</p>
</div>
<a href="/buy" class="block w-full bg-indigo-600 text-white text-center py-4 rounded-2xl font-bold text-sm">
前往充值
</a>
</div>
</div>
</div>
<!-- 图片预览弹窗 -->
<div id="imagePreviewModal" class="fixed inset-0 bg-black z-[70] hidden flex items-center justify-center">
<img id="previewImage" src="" class="max-w-full max-h-full object-contain">
<button id="closeImagePreview" class="absolute top-4 right-4 w-10 h-10 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
<i data-lucide="x" class="w-6 h-6 text-white"></i>
</button>
<div class="absolute bottom-6 left-0 right-0 flex justify-center gap-4 safe-area-bottom">
<button id="downloadImageBtn" class="bg-white/20 backdrop-blur-sm text-white px-6 py-3 rounded-2xl font-bold text-sm flex items-center gap-2">
<i data-lucide="download" class="w-5 h-5"></i>
保存图片
</button>
</div>
</div>
<script>
lucide.createIcons();
// 全局状态
let authData = { logged_in: false, points: 0 };
let config = {};
let uploadedFiles = [];
let currentMode = 'trial';
let generatedResults = [];
let currentPreviewUrl = '';
// Toast 通知
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const colors = { success: 'text-emerald-500', error: 'text-rose-500', warning: 'text-amber-500', info: 'text-indigo-500' };
const icons = { success: 'check-circle', error: 'x-circle', warning: 'alert-triangle', info: 'info' };
toast.innerHTML = `<i data-lucide="${icons[type]}" class="w-5 h-5 ${colors[type]} flex-shrink-0"></i><span class="text-sm font-medium text-slate-700">${message}</span>`;
container.appendChild(toast);
lucide.createIcons();
setTimeout(() => { toast.style.animation = 'slideOutRight 0.3s ease-in'; setTimeout(() => toast.remove(), 300); }, 3000);
}
// 填充下拉选择框
function fillSelect(id, list) {
const el = document.getElementById(id);
if (!el || !list) 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 => {
const dataIdAttr = i.id ? ` data-id="${i.id}"` : '';
const disabledAttr = i.disabled ? ' disabled' : '';
return `<option value="${i.value}"${dataIdAttr}${disabledAttr}>${i.label}</option>`;
}).join('');
}
}
// 加载用户收藏的提示词
async function loadUserPrompts() {
try {
const r = await fetch('/api/prompts');
const d = await r.json();
return d.error ? [] : d;
} catch (e) {
return [];
}
}
// 检查登录状态
async function checkAuth() {
const r = await fetch('/api/auth/me');
const d = await r.json();
authData = d;
const profile = document.getElementById('userProfile');
const entry = document.getElementById('loginEntryBtn');
const pointsBadge = document.getElementById('pointsBadge');
const pointsDisplay = document.getElementById('pointsDisplay');
const modalPoints = document.getElementById('modalPointsDisplay');
const headerPoints = document.getElementById('headerPoints');
if (d.logged_in) {
profile.classList.remove('hidden');
entry.classList.add('hidden');
pointsBadge.classList.remove('hidden');
pointsDisplay.innerText = d.points;
headerPoints.innerText = d.points;
modalPoints.innerText = d.points;
} else {
profile.classList.add('hidden');
entry.classList.remove('hidden');
pointsBadge.classList.add('hidden');
}
}
// 加载配置
async function loadConfig() {
try {
const r = await fetch('/api/config');
config = await r.json();
// 填充模型选择
fillSelect('modelSelect', config.models);
// 填充比例选择
fillSelect('ratioSelect', config.ratios);
// 填充尺寸选择
fillSelect('sizeSelect', config.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 },
...config.prompts
];
fillSelect('promptTpl', mergedPrompts);
// 初始化尺寸选择器显示状态
updateSizeVisibility();
} catch (e) {
console.error('加载配置失败', e);
}
}
// 尺寸模型常量 (与PC端一致)
const SIZE_MODELS = ['nano-banana-2', 'gemini-3.1-flash-image-preview'];
// 更新尺寸选择器显示状态
function updateSizeVisibility() {
const modelSelect = document.getElementById('modelSelect');
const sizeGroup = document.getElementById('sizeGroup');
if (modelSelect && sizeGroup) {
sizeGroup.classList.toggle('hidden', !SIZE_MODELS.includes(modelSelect.value));
}
}
// 设置面板控制
const settingsSheet = document.getElementById('settingsSheet');
const settingsOverlay = document.getElementById('settingsOverlay');
function openSettings() {
settingsOverlay.classList.remove('hidden');
setTimeout(() => {
settingsOverlay.classList.add('opacity-100');
settingsSheet.classList.add('active');
}, 10);
}
function closeSettings() {
settingsOverlay.classList.remove('opacity-100');
settingsSheet.classList.remove('active');
setTimeout(() => {
settingsOverlay.classList.add('hidden');
}, 300);
}
document.getElementById('settingsBtn').onclick = openSettings;
document.getElementById('closeSettingsBtn').onclick = closeSettings;
document.getElementById('applySettingsBtn').onclick = closeSettings;
settingsOverlay.onclick = closeSettings;
// 历史记录面板控制
const historySheet = document.getElementById('historySheet');
const historyOverlay = document.getElementById('historyOverlay');
function openHistory() {
loadHistory();
historyOverlay.classList.remove('hidden');
setTimeout(() => {
historyOverlay.classList.add('opacity-100');
historySheet.classList.add('active');
}, 10);
}
function closeHistory() {
historyOverlay.classList.remove('opacity-100');
historySheet.classList.remove('active');
setTimeout(() => {
historyOverlay.classList.add('hidden');
}, 300);
}
document.getElementById('historyBtn').onclick = openHistory;
document.getElementById('closeHistoryBtn').onclick = closeHistory;
historyOverlay.onclick = closeHistory;
// 积分中心弹窗
function openPointsModal() {
const modal = document.getElementById('pointsModal');
modal.classList.remove('hidden');
setTimeout(() => {
modal.classList.add('opacity-100');
modal.querySelector('div').classList.remove('scale-95');
}, 10);
}
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);
}
// 图片预览弹窗
function openImagePreview(url) {
currentPreviewUrl = url;
document.getElementById('previewImage').src = url;
document.getElementById('imagePreviewModal').classList.remove('hidden');
}
document.getElementById('closeImagePreview').onclick = () => {
document.getElementById('imagePreviewModal').classList.add('hidden');
};
document.getElementById('downloadImageBtn').onclick = () => {
if (currentPreviewUrl) {
const a = document.createElement('a');
a.href = currentPreviewUrl;
a.download = currentPreviewUrl.split('/').pop() || 'image.png';
a.click();
}
};
// 文件上传处理
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const imagePreview = document.getElementById('imagePreview');
dropZone.onclick = () => fileInput.click();
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('border-indigo-300', 'bg-indigo-50');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-indigo-300', 'bg-indigo-50');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('border-indigo-300', 'bg-indigo-50');
handleFiles(e.dataTransfer.files);
});
fileInput.onchange = (e) => handleFiles(e.target.files);
function handleFiles(files) {
for (const file of files) {
if (file.type.startsWith('image/')) {
uploadedFiles.push(file);
renderImagePreviews();
}
}
}
function renderImagePreviews() {
imagePreview.innerHTML = '';
uploadedFiles.forEach((file, idx) => {
const div = document.createElement('div');
div.className = 'relative w-20 h-20 rounded-xl overflow-hidden bg-slate-100';
div.innerHTML = `
<img src="${URL.createObjectURL(file)}" class="w-full h-full object-cover">
<button class="absolute top-1 right-1 w-5 h-5 bg-rose-500 text-white rounded-full flex items-center justify-center" onclick="removeImage(${idx})">
<i data-lucide="x" class="w-3 h-3"></i>
</button>
`;
imagePreview.appendChild(div);
});
lucide.createIcons();
}
function removeImage(idx) {
uploadedFiles.splice(idx, 1);
renderImagePreviews();
}
// 提示词模板选择
document.getElementById('promptTpl').onchange = function() {
const manualPrompt = document.getElementById('manualPrompt');
if (this.value === 'manual') {
manualPrompt.value = '';
} else {
manualPrompt.value = this.value;
}
};
// 图片生成
async function generateImage() {
if (!authData.logged_in) {
showToast('请先登录', 'error');
return;
}
if (currentMode === 'trial' && authData.points <= 0) {
showToast('积分不足', 'error');
return;
}
const prompt = document.getElementById('manualPrompt').value.trim();
if (!prompt) {
showToast('请输入提示词', 'warning');
return;
}
const model = document.getElementById('modelSelect').value;
const ratio = document.getElementById('ratioSelect').value;
const size = document.getElementById('sizeSelect').value || '';
const num = parseInt(document.getElementById('numSelect').value);
const isPremium = document.getElementById('isPremium').checked;
// 显示加载状态
document.getElementById('placeholder').classList.add('hidden');
document.getElementById('resultGrid').classList.add('hidden');
document.getElementById('loadingState').classList.remove('hidden');
generatedResults = [];
try {
// 处理参考图片
let imageData = [];
if (uploadedFiles.length > 0) {
imageData = await Promise.all(uploadedFiles.map(f => processImageFile(f)));
}
// 并行生成多张
const tasks = [];
for (let i = 0; i < num; i++) {
tasks.push(startGenerationTask(prompt, model, ratio, size, imageData, isPremium, i));
}
await Promise.all(tasks);
showToast(`成功生成 ${generatedResults.length} 张图片`, 'success');
} catch (err) {
showToast('生成失败: ' + err.message, 'error');
}
// 隐藏加载状态
document.getElementById('loadingState').classList.add('hidden');
document.getElementById('resultGrid').classList.remove('hidden');
}
async function startGenerationTask(prompt, model, ratio, size, imageData, isPremium, index) {
const payload = {
mode: currentMode,
is_premium: isPremium,
prompt,
model,
ratio,
size,
image_data: imageData.length > 0 ? imageData : undefined
};
const r = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const d = await r.json();
if (d.error) throw new Error(d.error);
const taskId = d.task_id;
let pollCount = 0;
const maxPolls = 600;
while (pollCount < maxPolls) {
await new Promise(res => setTimeout(res, 3000));
pollCount++;
const statusR = await fetch(`/api/task_status/${taskId}`);
const statusD = await statusR.json();
if (statusD.status === 'complete' && statusD.urls?.length > 0) {
for (const url of statusD.urls) {
addResult(url, prompt);
}
return;
}
if (statusD.status === 'error') {
throw new Error(statusD.message || '生成失败');
}
document.getElementById('loadingProgress').innerText = `正在生成中... (${pollCount}/${maxPolls})`;
}
throw new Error('生成超时');
}
function addResult(url, prompt) {
generatedResults.push({ url, prompt });
const grid = document.getElementById('resultGrid');
const card = document.createElement('div');
card.className = 'bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden';
card.innerHTML = `
<div class="aspect-square bg-slate-100 cursor-pointer" onclick="openImagePreview('${url}')">
<img src="${url}" class="w-full h-full object-cover" loading="lazy">
</div>
<div class="p-3 flex items-center justify-between">
<p class="text-xs text-slate-400 truncate flex-1 mr-2">${prompt}</p>
<button class="w-8 h-8 bg-indigo-50 text-indigo-600 rounded-lg flex items-center justify-center flex-shrink-0" onclick="downloadImage('${url}')">
<i data-lucide="download" class="w-4 h-4"></i>
</button>
</div>
`;
grid.appendChild(card);
lucide.createIcons();
}
async function processImageFile(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const maxSize = 2048;
let w = img.width, h = img.height;
if (w > maxSize || h > maxSize) {
if (w > h) {
h = Math.round(h * maxSize / w);
w = maxSize;
} else {
w = Math.round(w * maxSize / h);
h = maxSize;
}
}
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', 0.9));
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
}
function downloadImage(url) {
const a = document.createElement('a');
a.href = url;
a.download = url.split('/').pop() || 'image.png';
a.target = '_blank';
a.click();
}
// 加载历史记录
async function loadHistory() {
try {
const r = await fetch('/api/history?page=1&per_page=20');
const d = await r.json();
const list = document.getElementById('historyList');
if (!d.history || d.history.length === 0) {
list.innerHTML = `
<div class="text-center py-10 text-slate-400">
<i data-lucide="inbox" class="w-12 h-12 mx-auto mb-2 opacity-50"></i>
<p class="text-sm font-bold">暂无历史记录</p>
</div>
`;
lucide.createIcons();
return;
}
list.innerHTML = '';
for (const record of d.history) {
const item = document.createElement('div');
item.className = 'bg-white rounded-2xl border border-slate-100 overflow-hidden';
// 获取第一张图片的缩略图
const firstImg = record.urls?.[0];
const img = firstImg?.thumb || firstImg?.url || '';
item.innerHTML = `
<div class="flex gap-3 p-3">
${img ? `<img src="${img}" class="w-16 h-16 rounded-xl object-cover cursor-pointer" onclick="openImagePreview('${img}')">` : ''}
<div class="flex-1 min-w-0">
<p class="text-xs font-bold text-slate-700 truncate">${record.prompt || '无提示词'}</p>
<p class="text-[10px] text-slate-400 mt-1">${record.created_at || ''}</p>
<div class="flex gap-1 mt-2">
<span class="text-[9px] bg-indigo-50 text-indigo-600 px-2 py-0.5 rounded-full font-bold">${record.model || ''}</span>
</div>
</div>
</div>
`;
list.appendChild(item);
}
lucide.createIcons();
} catch (e) {
console.error('加载历史记录失败', e);
}
}
// 更新积分消耗预览
function updateCostPreview() {
const modelSelect = document.getElementById('modelSelect');
const numSelect = document.getElementById('numSelect');
const isPremium = document.getElementById('isPremium');
const costPreview = document.getElementById('costPreview');
if (!modelSelect || !numSelect || !isPremium || !costPreview) return;
const selectedOption = modelSelect.options[modelSelect.selectedIndex];
let baseCost = parseInt(selectedOption?.getAttribute('data-cost') || 0);
let num = parseInt(numSelect.value || 1);
let totalCost = baseCost * num;
if (isPremium.checked) totalCost *= 2;
costPreview.innerText = `本次生成将消耗 ${totalCost} 积分`;
costPreview.classList.remove('hidden');
}
// 事件绑定
document.getElementById('generateBtn').onclick = generateImage;
document.getElementById('modelSelect').onchange = () => {
updateSizeVisibility();
updateCostPreview();
};
document.getElementById('numSelect').onchange = updateCostPreview;
document.getElementById('isPremium').onchange = updateCostPreview;
// 初始化
checkAuth();
loadConfig();
</script>
</body>
</html>