feat: Implement a new API blueprint for core application functionalities including generation, history, notifications, user stats, and saved prompts, along with a new SavedPrompt model and supporting frontend.

This commit is contained in:
24024 2026-01-21 20:43:46 +08:00
parent 455a63f20f
commit 2ef673d0d6
4 changed files with 318 additions and 12 deletions

View File

@ -1,6 +1,6 @@
from flask import Blueprint, request, jsonify, session, current_app from flask import Blueprint, request, jsonify, session, current_app
from extensions import db, redis_client from extensions import db, redis_client
from models import User from models import User, SavedPrompt
from middlewares.auth import login_required from middlewares.auth import login_required
from services.logger import system_logger from services.logger import system_logger
import json import json
@ -223,3 +223,55 @@ def download_proxy():
except Exception as e: except Exception as e:
system_logger.error(f"代理下载失败: {str(e)}") system_logger.error(f"代理下载失败: {str(e)}")
return jsonify({"error": "下载失败"}), 500 return jsonify({"error": "下载失败"}), 500
@api_bp.route('/api/prompts', methods=['GET'])
@login_required
def list_prompts():
try:
user_id = session.get('user_id')
prompts = SavedPrompt.query.filter_by(user_id=user_id).order_by(SavedPrompt.created_at.desc()).all()
return jsonify([{
"id": p.id,
"label": p.title, # Use label/value structure for frontend select
"value": p.prompt
} for p in prompts])
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/prompts', methods=['POST'])
@login_required
def save_prompt():
try:
user_id = session.get('user_id')
data = request.json
title = data.get('title')
prompt_text = data.get('prompt')
if not title or not prompt_text:
return jsonify({"error": "标题和内容不能为空"}), 400
# Limit to 50 saved prompts
count = SavedPrompt.query.filter_by(user_id=user_id).count()
if count >= 50:
return jsonify({"error": "收藏数量已达上限 (50)"}), 400
p = SavedPrompt(user_id=user_id, title=title, prompt=prompt_text)
db.session.add(p)
db.session.commit()
return jsonify({"message": "保存成功", "id": p.id})
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/prompts/<int:id>', methods=['DELETE'])
@login_required
def delete_prompt(id):
try:
user_id = session.get('user_id')
p = SavedPrompt.query.filter_by(id=id, user_id=user_id).first()
if p:
db.session.delete(p)
db.session.commit()
return jsonify({"message": "删除成功"})
except Exception as e:
return jsonify({"error": str(e)}), 500

View File

@ -187,3 +187,19 @@ class SystemLog(db.Model):
return to_bj_time(self.created_at) return to_bj_time(self.created_at)
user = db.relationship('User', backref=db.backref('logs', lazy='dynamic', order_by='SystemLog.created_at.desc()')) user = db.relationship('User', backref=db.backref('logs', lazy='dynamic', order_by='SystemLog.created_at.desc()'))
class SavedPrompt(db.Model):
"""用户收藏的提示词"""
__tablename__ = 'saved_prompts'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
title = db.Column(db.String(100), nullable=False)
prompt = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=get_bj_now)
@property
def created_at_bj(self):
return to_bj_time(self.created_at)
user = db.relationship('User', backref=db.backref('saved_prompts', lazy='dynamic', order_by='SavedPrompt.created_at.desc()'))

View File

@ -209,6 +209,8 @@ async function init() {
const closeVisualizerBtn = document.getElementById('closeVisualizerBtn'); const closeVisualizerBtn = document.getElementById('closeVisualizerBtn');
if (openVisualizerBtn) openVisualizerBtn.onclick = openVisualizerModal; if (openVisualizerBtn) openVisualizerBtn.onclick = openVisualizerModal;
if (closeVisualizerBtn) closeVisualizerBtn.onclick = closeVisualizerModal; if (closeVisualizerBtn) closeVisualizerBtn.onclick = closeVisualizerModal;
const openSavePromptBtn = document.getElementById('openSavePromptBtn');
if (openSavePromptBtn) openSavePromptBtn.onclick = openSavePromptModal;
if (showHistoryBtn) { if (showHistoryBtn) {
showHistoryBtn.onclick = () => { showHistoryBtn.onclick = () => {
@ -264,12 +266,7 @@ async function init() {
} }
try { try {
const r = await fetch('/api/config'); await refreshPromptsList();
const d = await r.json();
fillSelect('modelSelect', d.models);
fillSelect('ratioSelect', d.ratios);
fillSelect('sizeSelect', d.sizes);
fillSelect('promptTpl', [{ label: '✨ 自定义创作', value: 'manual' }, ...d.prompts]);
updateCostPreview(); // 初始化时显示默认模型的积分 updateCostPreview(); // 初始化时显示默认模型的积分
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
@ -295,11 +292,15 @@ async function init() {
function fillSelect(id, list) { function fillSelect(id, list) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (!el) return; if (!el) return;
// 如果是模型选择,增加积分显示
if (id === 'modelSelect') { if (id === 'modelSelect') {
el.innerHTML = list.map(i => `<option value="${i.value}" data-cost="${i.cost || 0}">${i.label} (${i.cost || 0}积分)</option>`).join(''); el.innerHTML = list.map(i => `<option value="${i.value}" data-cost="${i.cost || 0}">${i.label} (${i.cost || 0}积分)</option>`).join('');
} else { } else {
el.innerHTML = list.map(i => `<option value="${i.value}">${i.label}</option>`).join(''); 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('');
} }
} }
@ -441,6 +442,19 @@ document.getElementById('modelSelect').onchange = (e) => {
document.getElementById('promptTpl').onchange = (e) => { document.getElementById('promptTpl').onchange = (e) => {
const area = document.getElementById('manualPrompt'); 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') { if (e.target.value !== 'manual') {
area.value = e.target.value; area.value = e.target.value;
area.classList.add('hidden'); area.classList.add('hidden');
@ -798,6 +812,7 @@ async function loadPointStats() {
borderColor: '#6366f1', borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)', backgroundColor: 'rgba(99, 102, 241, 0.1)',
borderWidth: 3, borderWidth: 3,
fill: true, fill: true,
tension: 0.4, tension: 0.4,
pointRadius: 4, pointRadius: 4,
@ -868,3 +883,166 @@ async function loadPointDetails() {
body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-rose-500">加载失败</td></tr>'; 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;
}
}

View File

@ -113,8 +113,23 @@
class="w-full bg-slate-50 border-b border-slate-100 p-3 text-[10px] font-bold text-indigo-600 outline-none cursor-pointer"> class="w-full bg-slate-50 border-b border-slate-100 p-3 text-[10px] font-bold text-indigo-600 outline-none cursor-pointer">
<option value="manual">✨ 自定义创作</option> <option value="manual">✨ 自定义创作</option>
</select> </select>
<textarea id="manualPrompt" rows="4" class="w-full p-3 text-xs outline-none resize-none leading-relaxed" <div id="deletePromptContainer" class="hidden relative h-0">
placeholder="描述您的需求..."></textarea> <button id="deletePromptBtn"
class="absolute -top-10 right-2 w-8 h-8 rounded-lg bg-rose-50 border border-rose-100 text-rose-500 hover:bg-rose-100 transition-colors flex items-center justify-center z-10"
title="删除这个收藏">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
<div class="relative group/area">
<textarea id="manualPrompt" rows="4"
class="w-full p-3 text-xs outline-none resize-none leading-relaxed"
placeholder="描述您的需求..."></textarea>
<button id="openSavePromptBtn"
class="absolute bottom-2 right-2 p-1.5 bg-white/80 backdrop-blur-sm border border-slate-100 text-slate-400 rounded-lg hover:text-indigo-600 hover:border-indigo-200 transition-all opacity-0 group-hover/area:opacity-100"
title="收藏当前提示词">
<i data-lucide="bookmark-plus" class="w-4 h-4"></i>
</button>
</div>
</div> </div>
</section> </section>
@ -308,7 +323,8 @@
<div class="relative z-10"> <div class="relative z-10">
<p class="text-xs font-bold opacity-80 mb-2">当前可用余额</p> <p class="text-xs font-bold opacity-80 mb-2">当前可用余额</p>
<h4 class="text-5xl font-black tracking-tighter mb-4"><span id="modalPointsDisplay">0</span> <h4 class="text-5xl font-black tracking-tighter mb-4"><span id="modalPointsDisplay">0</span>
<span class="text-lg opacity-60">Pts</span></h4> <span class="text-lg opacity-60">Pts</span>
</h4>
<a href="/buy" <a href="/buy"
class="inline-flex items-center gap-2 bg-white/20 hover:bg-white/30 px-5 py-2.5 rounded-xl text-xs font-bold backdrop-blur-md transition-all"> class="inline-flex items-center gap-2 bg-white/20 hover:bg-white/30 px-5 py-2.5 rounded-xl text-xs font-bold backdrop-blur-md transition-all">
<i data-lucide="plus-circle" class="w-4 h-4"></i> 立即充值 <i data-lucide="plus-circle" class="w-4 h-4"></i> 立即充值
@ -401,6 +417,50 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 保存提示词弹窗 -->
<div id="savePromptModal"
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
<div
class="bg-white w-full max-w-sm rounded-[2rem] shadow-2xl p-8 space-y-6 transform scale-95 transition-transform duration-300">
<h2 class="text-xl font-black text-slate-900">收藏提示词</h2>
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">给这个灵感起个名字</label>
<input type="text" id="promptTitleInput" placeholder="例如:赛博朋克风格"
class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
</div>
<div class="flex gap-4 pt-2">
<button onclick="closeSavePromptModal()"
class="flex-1 px-6 py-3 rounded-2xl border border-slate-100 font-bold text-slate-400 hover:bg-slate-50 transition-all">取消</button>
<button onclick="confirmSavePrompt()"
class="flex-1 btn-primary px-6 py-3 rounded-2xl font-bold shadow-lg shadow-indigo-100">保存</button>
</div>
</div>
</div>
<!-- 删除确认弹窗 -->
<div id="deleteConfirmModal"
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
<div
class="bg-white w-full max-w-sm rounded-[2rem] shadow-2xl p-8 space-y-6 transform scale-95 transition-transform duration-300">
<div class="flex flex-col items-center text-center space-y-4">
<div class="w-16 h-16 bg-rose-50 text-rose-500 rounded-2xl flex items-center justify-center mb-2">
<i data-lucide="alert-triangle" class="w-8 h-8"></i>
</div>
<h2 class="text-xl font-black text-slate-900">确认删除?</h2>
<p class="text-sm text-slate-500 font-bold leading-relaxed px-4">
您确定要删除这个收藏的提示词吗?<br>此操作无法撤销。
</p>
</div>
<div class="flex gap-4 pt-2">
<button onclick="closeDeleteConfirmModal()"
class="flex-1 px-6 py-3 rounded-2xl border border-slate-100 font-bold text-slate-400 hover:bg-slate-50 transition-all">取消</button>
<button id="confirmDeleteBtn"
class="flex-1 bg-rose-500 text-white px-6 py-3 rounded-2xl font-bold shadow-lg shadow-rose-200 hover:bg-rose-600 transition-all">确认删除</button>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}