feat(app): 添加移动端检测和配置优化

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

7
app.py
View File

@ -1,4 +1,4 @@
from flask import Flask, render_template, jsonify, Response, stream_with_context from flask import Flask, render_template, jsonify, Response, stream_with_context, request, redirect
from config import Config from config import Config
from extensions import db, redis_client, migrate, s3_client from extensions import db, redis_client, migrate, s3_client
from blueprints.auth import auth_bp from blueprints.auth import auth_bp
@ -142,6 +142,11 @@ def create_app():
@app.route('/') @app.route('/')
def index(): def index():
# 检测是否为移动端访问
user_agent = request.headers.get('User-Agent', '').lower()
mobile_keywords = ['mobile', 'android', 'iphone', 'ipad', 'ipod', 'windows phone', 'blackberry']
if any(keyword in user_agent for keyword in mobile_keywords):
return redirect('/mobile')
return render_template('index.html') return render_template('index.html')
@app.route('/ocr') @app.route('/ocr')

View File

@ -189,7 +189,7 @@
</select> </select>
</div> </div>
<!-- 比例和尺寸 --> <!-- 比例和数量 -->
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-2"> <label class="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-2">
@ -198,13 +198,6 @@
<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 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> </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_0.5rem_center] bg-[length:1rem]">
</select>
</div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-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>生成数量 <i data-lucide="copy" class="w-4 h-4"></i>生成数量
@ -218,6 +211,15 @@
</div> </div>
</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"> <div class="space-y-2">
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-2"> <label class="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-2">
@ -355,6 +357,33 @@
setTimeout(() => { toast.style.animation = 'slideOutRight 0.3s ease-in'; setTimeout(() => toast.remove(), 300); }, 3000); 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() { async function checkAuth() {
const r = await fetch('/api/auth/me'); const r = await fetch('/api/auth/me');
@ -384,40 +413,49 @@
// 加载配置 // 加载配置
async function loadConfig() { async function loadConfig() {
const r = await fetch('/api/config'); try {
config = await r.json(); 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 modelSelect = document.getElementById('modelSelect');
modelSelect.innerHTML = ''; const sizeGroup = document.getElementById('sizeGroup');
for (const [name, info] of Object.entries(config.models || {})) { if (modelSelect && sizeGroup) {
const opt = document.createElement('option'); sizeGroup.classList.toggle('hidden', !SIZE_MODELS.includes(modelSelect.value));
opt.value = name;
opt.innerText = info.display_name || name;
if (info.default) opt.selected = true;
modelSelect.appendChild(opt);
}
// 填充比例选择
const ratioSelect = document.getElementById('ratioSelect');
ratioSelect.innerHTML = '';
for (const ratio of (config.ratios || ['1:1', '16:9', '9:16', '4:3', '3:4'])) {
const opt = document.createElement('option');
opt.value = ratio;
opt.innerText = ratio;
if (ratio === '1:1') opt.selected = true;
ratioSelect.appendChild(opt);
}
// 填充提示词模板
const promptTpl = document.getElementById('promptTpl');
if (config.prompt_templates) {
for (const tpl of config.prompt_templates) {
const opt = document.createElement('option');
opt.value = tpl.content;
opt.innerText = tpl.name;
promptTpl.appendChild(opt);
}
} }
} }
@ -566,7 +604,12 @@
// 提示词模板选择 // 提示词模板选择
document.getElementById('promptTpl').onchange = function() { document.getElementById('promptTpl').onchange = function() {
document.getElementById('manualPrompt').value = this.value === 'manual' ? '' : this.value; const manualPrompt = document.getElementById('manualPrompt');
if (this.value === 'manual') {
manualPrompt.value = '';
} else {
manualPrompt.value = this.value;
}
}; };
// 图片生成 // 图片生成
@ -734,64 +777,77 @@
// 加载历史记录 // 加载历史记录
async function loadHistory() { async function loadHistory() {
const r = await fetch('/api/history?page=1&per_page=20'); try {
const d = await r.json(); const r = await fetch('/api/history?page=1&per_page=20');
const list = document.getElementById('historyList'); const d = await r.json();
const list = document.getElementById('historyList');
if (!d.records || d.records.length === 0) { if (!d.history || d.history.length === 0) {
list.innerHTML = ` list.innerHTML = `
<div class="text-center py-10 text-slate-400"> <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> <i data-lucide="inbox" class="w-12 h-12 mx-auto mb-2 opacity-50"></i>
<p class="text-sm font-bold">暂无历史记录</p> <p class="text-sm font-bold">暂无历史记录</p>
</div> </div>
`; `;
lucide.createIcons(); lucide.createIcons();
return; return;
} }
list.innerHTML = ''; list.innerHTML = '';
for (const record of d.records) { for (const record of d.history) {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'bg-white rounded-2xl border border-slate-100 overflow-hidden'; item.className = 'bg-white rounded-2xl border border-slate-100 overflow-hidden';
const img = record.thumbnail_url || (record.image_urls?.[0]) || '';
item.innerHTML = ` // 获取第一张图片的缩略图
<div class="flex gap-3 p-3"> const firstImg = record.urls?.[0];
${img ? `<img src="${img}" class="w-16 h-16 rounded-xl object-cover cursor-pointer" onclick="openImagePreview('${img}')">` : ''} const img = firstImg?.thumb || firstImg?.url || '';
<div class="flex-1 min-w-0">
<p class="text-xs font-bold text-slate-700 truncate">${record.prompt || '无提示词'}</p> item.innerHTML = `
<p class="text-[10px] text-slate-400 mt-1">${record.created_at || ''}</p> <div class="flex gap-3 p-3">
<div class="flex gap-1 mt-2"> ${img ? `<img src="${img}" class="w-16 h-16 rounded-xl object-cover cursor-pointer" onclick="openImagePreview('${img}')">` : ''}
<span class="text-[9px] bg-indigo-50 text-indigo-600 px-2 py-0.5 rounded-full font-bold">${record.model || ''}</span> <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>
</div> </div>
</div> `;
`; list.appendChild(item);
list.appendChild(item); }
lucide.createIcons();
} catch (e) {
console.error('加载历史记录失败', e);
} }
lucide.createIcons();
} }
// 更新积分消耗预览 // 更新积分消耗预览
function updateCostPreview() { function updateCostPreview() {
const model = document.getElementById('modelSelect').value; const modelSelect = document.getElementById('modelSelect');
const num = parseInt(document.getElementById('numSelect').value); const numSelect = document.getElementById('numSelect');
const isPremium = document.getElementById('isPremium').checked; const isPremium = document.getElementById('isPremium');
const costPreview = document.getElementById('costPreview');
let cost = 1; if (!modelSelect || !numSelect || !isPremium || !costPreview) return;
const modelInfo = config.models?.[model];
if (modelInfo?.cost) cost = modelInfo.cost;
if (isPremium) cost *= 2; const selectedOption = modelSelect.options[modelSelect.selectedIndex];
cost *= num; let baseCost = parseInt(selectedOption?.getAttribute('data-cost') || 0);
let num = parseInt(numSelect.value || 1);
let totalCost = baseCost * num;
const preview = document.getElementById('costPreview'); if (isPremium.checked) totalCost *= 2;
preview.innerText = `预计消耗: ${cost} 积分`;
preview.classList.remove('hidden'); costPreview.innerText = `本次生成将消耗 ${totalCost} 积分`;
costPreview.classList.remove('hidden');
} }
// 事件绑定 // 事件绑定
document.getElementById('generateBtn').onclick = generateImage; document.getElementById('generateBtn').onclick = generateImage;
document.getElementById('modelSelect').onchange = updateCostPreview; document.getElementById('modelSelect').onchange = () => {
updateSizeVisibility();
updateCostPreview();
};
document.getElementById('numSelect').onchange = updateCostPreview; document.getElementById('numSelect').onchange = updateCostPreview;
document.getElementById('isPremium').onchange = updateCostPreview; document.getElementById('isPremium').onchange = updateCostPreview;