Add admin usage audit for generation records
This commit is contained in:
parent
c8d9a7b51c
commit
c0a0db4142
14
.codex/environments/environment.toml
Normal file
14
.codex/environments/environment.toml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||||
|
version = 1
|
||||||
|
name = "google"
|
||||||
|
|
||||||
|
[setup]
|
||||||
|
script = ""
|
||||||
|
|
||||||
|
[[actions]]
|
||||||
|
name = "运行"
|
||||||
|
icon = "run"
|
||||||
|
command = '''
|
||||||
|
.venv\Scripts\Activate.ps1
|
||||||
|
python app.py
|
||||||
|
'''
|
||||||
@ -2,7 +2,7 @@ from flask import Blueprint, request, jsonify, g
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import json
|
import json
|
||||||
from extensions import db
|
from extensions import db
|
||||||
from models import User, Role, Permission, SystemDict, SystemNotification, Order, PointsGrant, InviteReward, to_bj_time, get_bj_now
|
from models import User, Role, Permission, SystemDict, SystemNotification, Order, PointsGrant, InviteReward, GenerationRecord, to_bj_time, get_bj_now
|
||||||
from middlewares.auth import permission_required, login_required
|
from middlewares.auth import permission_required, login_required
|
||||||
from services.logger import system_logger
|
from services.logger import system_logger
|
||||||
|
|
||||||
@ -343,6 +343,143 @@ def get_order_detail(order_id):
|
|||||||
"current_is_admin": g.user.has_permission('manage_system')
|
"current_is_admin": g.user.has_permission('manage_system')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# --- 用户积分消耗与生成内容管理 ---
|
||||||
|
def _fix_minio_url(url):
|
||||||
|
if url and ":9000/images/" in url:
|
||||||
|
return "/files/" + url.split(":9000/images/")[-1]
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_generation_outputs(raw_image_urls):
|
||||||
|
"""兼容历史字符串 URL、新对象格式、文本与视频生成记录。"""
|
||||||
|
if not raw_image_urls:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_outputs = json.loads(raw_image_urls)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(raw_outputs, list):
|
||||||
|
raw_outputs = [raw_outputs]
|
||||||
|
|
||||||
|
outputs = []
|
||||||
|
for item in raw_outputs:
|
||||||
|
if isinstance(item, str):
|
||||||
|
outputs.append({
|
||||||
|
"type": "image",
|
||||||
|
"url": _fix_minio_url(item),
|
||||||
|
"thumb": _fix_minio_url(item)
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
output_type = item.get("type") or "image"
|
||||||
|
output = {"type": output_type}
|
||||||
|
|
||||||
|
if output_type == "text":
|
||||||
|
output["content"] = item.get("content", "")
|
||||||
|
else:
|
||||||
|
url = _fix_minio_url(item.get("url"))
|
||||||
|
thumb = _fix_minio_url(item.get("thumb") or item.get("thumbnail") or url)
|
||||||
|
output["url"] = url
|
||||||
|
output["thumb"] = thumb
|
||||||
|
if output_type == "video" and not thumb:
|
||||||
|
output["thumb"] = "https://img.icons8.com/flat-round/64/000000/play--v1.png"
|
||||||
|
|
||||||
|
outputs.append(output)
|
||||||
|
|
||||||
|
return outputs
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_generation_type(outputs):
|
||||||
|
output_types = {item.get("type") for item in outputs}
|
||||||
|
if "video" in output_types:
|
||||||
|
return "video"
|
||||||
|
if "text" in output_types:
|
||||||
|
return "text"
|
||||||
|
return "image"
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/usage/stats', methods=['GET'])
|
||||||
|
@permission_required('manage_system')
|
||||||
|
def get_usage_stats():
|
||||||
|
"""获取全站积分消耗与生成内容统计。"""
|
||||||
|
today = get_bj_now().date()
|
||||||
|
|
||||||
|
total_cost = db.session.query(db.func.sum(GenerationRecord.cost)).scalar() or 0
|
||||||
|
today_cost = db.session.query(db.func.sum(GenerationRecord.cost)).filter(
|
||||||
|
db.func.date(GenerationRecord.created_at) == today
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"total_records": GenerationRecord.query.count(),
|
||||||
|
"total_cost": int(total_cost),
|
||||||
|
"today_records": GenerationRecord.query.filter(
|
||||||
|
db.func.date(GenerationRecord.created_at) == today
|
||||||
|
).count(),
|
||||||
|
"today_cost": int(today_cost),
|
||||||
|
"active_users": db.session.query(db.func.count(db.func.distinct(GenerationRecord.user_id))).scalar() or 0
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/usage/records', methods=['GET'])
|
||||||
|
@permission_required('manage_system')
|
||||||
|
def get_usage_records():
|
||||||
|
"""分页获取用户积分消耗和生成内容。"""
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = min(request.args.get('per_page', 20, type=int), 100)
|
||||||
|
search = request.args.get('q', '').strip()
|
||||||
|
content_type = request.args.get('type', 'all')
|
||||||
|
|
||||||
|
query = db.session.query(GenerationRecord).outerjoin(User, GenerationRecord.user_id == User.id)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
keyword = f"%{search}%"
|
||||||
|
query = query.filter(db.or_(
|
||||||
|
User.phone.like(keyword),
|
||||||
|
GenerationRecord.prompt.like(keyword),
|
||||||
|
GenerationRecord.model.like(keyword)
|
||||||
|
))
|
||||||
|
|
||||||
|
if content_type == 'video':
|
||||||
|
query = query.filter(GenerationRecord.image_urls.like('%"type": "video"%'))
|
||||||
|
elif content_type == 'text':
|
||||||
|
query = query.filter(GenerationRecord.image_urls.like('%"type": "text"%'))
|
||||||
|
elif content_type == 'image':
|
||||||
|
query = query.filter(
|
||||||
|
~GenerationRecord.image_urls.like('%"type": "video"%'),
|
||||||
|
~GenerationRecord.image_urls.like('%"type": "text"%')
|
||||||
|
)
|
||||||
|
|
||||||
|
pagination = query.order_by(GenerationRecord.created_at.desc()).paginate(
|
||||||
|
page=page, per_page=per_page, error_out=False
|
||||||
|
)
|
||||||
|
|
||||||
|
records = []
|
||||||
|
for record in pagination.items:
|
||||||
|
outputs = _normalize_generation_outputs(record.image_urls)
|
||||||
|
records.append({
|
||||||
|
"id": record.id,
|
||||||
|
"user_id": record.user_id,
|
||||||
|
"user_phone": record.user.phone if record.user else "未知",
|
||||||
|
"prompt": record.prompt or "",
|
||||||
|
"model": record.model or "Default",
|
||||||
|
"cost": record.cost or 0,
|
||||||
|
"type": _detect_generation_type(outputs),
|
||||||
|
"outputs": outputs,
|
||||||
|
"created_at": record.created_at_bj.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"records": records,
|
||||||
|
"total": pagination.total,
|
||||||
|
"pages": pagination.pages,
|
||||||
|
"current_page": pagination.page
|
||||||
|
})
|
||||||
|
|
||||||
# --- 积分发放管理 ---
|
# --- 积分发放管理 ---
|
||||||
@admin_bp.route('/points/grant', methods=['POST'])
|
@admin_bp.route('/points/grant', methods=['POST'])
|
||||||
@permission_required('manage_system')
|
@permission_required('manage_system')
|
||||||
|
|||||||
@ -53,6 +53,12 @@ def admin_points_page():
|
|||||||
"""积分发放管理页面"""
|
"""积分发放管理页面"""
|
||||||
return render_template('points.html')
|
return render_template('points.html')
|
||||||
|
|
||||||
|
@auth_bp.route('/admin/usage')
|
||||||
|
@admin_required
|
||||||
|
def admin_usage_page():
|
||||||
|
"""全站积分消耗与生成内容审计页面"""
|
||||||
|
return render_template('usage.html')
|
||||||
|
|
||||||
@auth_bp.route('/buy')
|
@auth_bp.route('/buy')
|
||||||
def buy_page():
|
def buy_page():
|
||||||
"""购买积分页面"""
|
"""购买积分页面"""
|
||||||
@ -617,6 +623,7 @@ def get_user_menu(user):
|
|||||||
{"name": "验光单助手", "icon": "scan-eye", "url": "/ocr", "perm": None},
|
{"name": "验光单助手", "icon": "scan-eye", "url": "/ocr", "perm": None},
|
||||||
{"name": "购买积分", "icon": "shopping-cart", "url": "/buy", "perm": None},
|
{"name": "购买积分", "icon": "shopping-cart", "url": "/buy", "perm": None},
|
||||||
{"name": "积分发放管理", "icon": "gift", "url": "/admin/points", "perm": "manage_system"},
|
{"name": "积分发放管理", "icon": "gift", "url": "/admin/points", "perm": "manage_system"},
|
||||||
|
{"name": "生成内容审计", "icon": "shield-alert", "url": "/admin/usage", "perm": "manage_system"},
|
||||||
{"name": "权限管理中心", "icon": "shield-check", "url": "/rbac", "perm": "manage_rbac"},
|
{"name": "权限管理中心", "icon": "shield-check", "url": "/rbac", "perm": "manage_rbac"},
|
||||||
{"name": "系统字典管理", "icon": "book-open", "url": "/dicts", "perm": "manage_dicts"},
|
{"name": "系统字典管理", "icon": "book-open", "url": "/dicts", "perm": "manage_dicts"},
|
||||||
{"name": "系统通知管理", "icon": "megaphone", "url": "/notifications", "perm": "manage_notifications"},
|
{"name": "系统通知管理", "icon": "megaphone", "url": "/notifications", "perm": "manage_notifications"},
|
||||||
|
|||||||
236
templates/usage.html
Normal file
236
templates/usage.html
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}生成内容审计 - AI 视界{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="w-full h-full overflow-hidden flex flex-col p-6 lg:p-10 bg-slate-50/60">
|
||||||
|
<div class="max-w-7xl w-full mx-auto flex flex-col h-full space-y-6">
|
||||||
|
<div class="flex items-center justify-between shrink-0">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 bg-rose-600 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-rose-200">
|
||||||
|
<i data-lucide="shield-alert" class="w-7 h-7"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-black text-slate-900 tracking-tight">生成内容审计</h1>
|
||||||
|
<p class="text-slate-400 text-sm font-medium">全站用户积分消耗与生成内容留痕</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="refreshUsage()"
|
||||||
|
class="w-11 h-11 bg-white border border-slate-200 rounded-2xl flex items-center justify-center text-slate-500 hover:text-rose-600 transition-colors shadow-sm">
|
||||||
|
<i data-lucide="refresh-cw" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-5 shrink-0">
|
||||||
|
<div class="bg-white p-5 rounded-2xl border border-slate-100 shadow-sm">
|
||||||
|
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">生成记录总数</p>
|
||||||
|
<h3 id="statTotalRecords" class="text-3xl font-black text-slate-900 mt-2">0</h3>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white p-5 rounded-2xl border border-slate-100 shadow-sm">
|
||||||
|
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">累计消耗积分</p>
|
||||||
|
<h3 id="statTotalCost" class="text-3xl font-black text-amber-600 mt-2">0</h3>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white p-5 rounded-2xl border border-slate-100 shadow-sm">
|
||||||
|
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">今日生成 / 消耗</p>
|
||||||
|
<h3 class="text-3xl font-black text-slate-900 mt-2"><span id="statTodayRecords">0</span><span class="text-base text-slate-300 mx-1">/</span><span id="statTodayCost">0</span></h3>
|
||||||
|
</div>
|
||||||
|
<div class="bg-slate-900 p-5 rounded-2xl shadow-sm text-white">
|
||||||
|
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">生成用户数</p>
|
||||||
|
<h3 id="statActiveUsers" class="text-3xl font-black mt-2">0</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4 bg-white p-4 rounded-2xl border border-slate-100 shadow-sm shrink-0">
|
||||||
|
<div class="relative w-full max-w-xl">
|
||||||
|
<i data-lucide="search" class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400"></i>
|
||||||
|
<input type="text" id="usageSearch" onkeyup="if(event.key === 'Enter') loadRecords(1)"
|
||||||
|
placeholder="搜索手机号、提示词或模型"
|
||||||
|
class="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm font-bold outline-none focus:border-rose-500 transition-all">
|
||||||
|
</div>
|
||||||
|
<div class="bg-slate-100 p-1 rounded-xl flex text-xs font-black shrink-0">
|
||||||
|
<button id="filter-all" onclick="setFilter('all')" class="px-4 py-2 rounded-lg transition-all">全部</button>
|
||||||
|
<button id="filter-image" onclick="setFilter('image')" class="px-4 py-2 rounded-lg transition-all">图片</button>
|
||||||
|
<button id="filter-video" onclick="setFilter('video')" class="px-4 py-2 rounded-lg transition-all">视频</button>
|
||||||
|
<button id="filter-text" onclick="setFilter('text')" class="px-4 py-2 rounded-lg transition-all">文本</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
|
<div class="flex-1 overflow-auto custom-scrollbar">
|
||||||
|
<table class="w-full text-left border-collapse">
|
||||||
|
<thead class="sticky top-0 bg-white z-10">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">用户</th>
|
||||||
|
<th class="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">内容</th>
|
||||||
|
<th class="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">输出预览</th>
|
||||||
|
<th class="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">消耗</th>
|
||||||
|
<th class="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100 text-right">时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="recordTable" class="divide-y divide-slate-100">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-8 py-20 text-center text-slate-400 font-bold">正在加载审计记录...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 border-t border-slate-100 flex items-center justify-between bg-slate-50/50 shrink-0">
|
||||||
|
<span class="text-xs font-bold text-slate-400" id="pageInfo">共 0 条记录</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button onclick="changePage(-1)" class="p-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-all">
|
||||||
|
<i data-lucide="chevron-left" class="w-4 h-4 text-slate-600"></i>
|
||||||
|
</button>
|
||||||
|
<button onclick="changePage(1)" class="p-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-all">
|
||||||
|
<i data-lucide="chevron-right" class="w-4 h-4 text-slate-600"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="previewModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-slate-900/80 backdrop-blur-md p-6">
|
||||||
|
<button onclick="closePreview()" class="absolute top-6 right-6 w-11 h-11 rounded-full bg-white/10 text-white hover:bg-white/20 flex items-center justify-center">
|
||||||
|
<i data-lucide="x" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
<div id="previewContent" class="max-w-[92vw] max-h-[88vh]"></div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let currentPage = 1;
|
||||||
|
let totalPages = 1;
|
||||||
|
let currentFilter = 'all';
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || '').replace(/[&<>"']/g, char => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
}[char]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
const res = await fetch('/api/admin/usage/stats');
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('statTotalRecords').innerText = data.total_records || 0;
|
||||||
|
document.getElementById('statTotalCost').innerText = data.total_cost || 0;
|
||||||
|
document.getElementById('statTodayRecords').innerText = data.today_records || 0;
|
||||||
|
document.getElementById('statTodayCost').innerText = data.today_cost || 0;
|
||||||
|
document.getElementById('statActiveUsers').innerText = data.active_users || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecords(page = currentPage) {
|
||||||
|
currentPage = page;
|
||||||
|
const q = encodeURIComponent(document.getElementById('usageSearch').value.trim());
|
||||||
|
const res = await fetch(`/api/admin/usage/records?page=${currentPage}&per_page=20&type=${currentFilter}&q=${q}`);
|
||||||
|
const data = await res.json();
|
||||||
|
totalPages = data.pages || 1;
|
||||||
|
renderRecords(data.records || []);
|
||||||
|
document.getElementById('pageInfo').innerText = `第 ${data.current_page || 1} / ${totalPages} 页,共 ${data.total || 0} 条记录`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFilter(type) {
|
||||||
|
currentFilter = type;
|
||||||
|
['all', 'image', 'video', 'text'].forEach(name => {
|
||||||
|
const btn = document.getElementById(`filter-${name}`);
|
||||||
|
btn.classList.toggle('bg-white', name === type);
|
||||||
|
btn.classList.toggle('text-slate-900', name === type);
|
||||||
|
btn.classList.toggle('shadow-sm', name === type);
|
||||||
|
btn.classList.toggle('text-slate-500', name !== type);
|
||||||
|
});
|
||||||
|
loadRecords(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecords(records) {
|
||||||
|
const tbody = document.getElementById('recordTable');
|
||||||
|
if (!records.length) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="5" class="px-8 py-20 text-center text-slate-400 font-bold">暂无生成记录</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = records.map(record => `
|
||||||
|
<tr class="hover:bg-slate-50/80 transition-colors align-top">
|
||||||
|
<td class="px-6 py-5">
|
||||||
|
<div class="font-black text-slate-800">${escapeHtml(record.user_phone)}</div>
|
||||||
|
<div class="text-[10px] text-slate-400 font-mono mt-1">UID: ${record.user_id}</div>
|
||||||
|
<span class="inline-flex mt-3 px-2 py-1 rounded-lg bg-slate-100 text-slate-500 text-[10px] font-black uppercase">${record.type}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-5 max-w-md">
|
||||||
|
<div class="font-black text-slate-900 text-sm">${escapeHtml(record.model)}</div>
|
||||||
|
<div class="mt-2 text-xs font-medium text-slate-500 leading-relaxed max-h-24 overflow-auto whitespace-pre-wrap">${escapeHtml(record.prompt || '无提示词')}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-5 min-w-64">${renderOutputs(record.outputs || [])}</td>
|
||||||
|
<td class="px-6 py-5">
|
||||||
|
<span class="inline-flex items-center gap-1 px-3 py-1.5 rounded-xl bg-amber-50 text-amber-700 text-xs font-black">
|
||||||
|
<i data-lucide="zap" class="w-3.5 h-3.5"></i>
|
||||||
|
-${record.cost}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-5 text-right text-[10px] font-black text-slate-400 whitespace-nowrap">${record.created_at}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOutputs(outputs) {
|
||||||
|
if (!outputs.length) return '<span class="text-xs font-bold text-slate-300">无输出记录</span>';
|
||||||
|
|
||||||
|
return `<div class="flex flex-wrap gap-3">${outputs.map(output => {
|
||||||
|
if (output.type === 'text') {
|
||||||
|
return `<button onclick="openTextPreview('${encodeURIComponent(output.content || '')}')" class="w-28 h-20 rounded-xl border border-slate-200 bg-slate-50 p-3 text-left text-[10px] font-bold text-slate-500 overflow-hidden hover:border-rose-300 transition-colors">${escapeHtml(output.content || '文本内容')}</button>`;
|
||||||
|
}
|
||||||
|
if (output.type === 'video') {
|
||||||
|
return `<button onclick="openMediaPreview('${encodeURIComponent(output.url || '')}', 'video')" class="w-28 h-20 rounded-xl bg-slate-900 text-white flex items-center justify-center hover:scale-[1.02] transition-transform"><i data-lucide="play" class="w-7 h-7"></i></button>`;
|
||||||
|
}
|
||||||
|
return `<button onclick="openMediaPreview('${encodeURIComponent(output.url || output.thumb || '')}', 'image')" class="w-20 h-20 rounded-xl overflow-hidden border border-slate-200 bg-slate-100 hover:scale-[1.02] transition-transform"><img src="${escapeHtml(output.thumb || output.url || '')}" class="w-full h-full object-cover" loading="lazy"></button>`;
|
||||||
|
}).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMediaPreview(encodedUrl, type) {
|
||||||
|
const url = decodeURIComponent(encodedUrl || '');
|
||||||
|
if (!url) return;
|
||||||
|
const modal = document.getElementById('previewModal');
|
||||||
|
const content = document.getElementById('previewContent');
|
||||||
|
content.innerHTML = type === 'video'
|
||||||
|
? `<video src="${url}" controls autoplay class="max-w-[92vw] max-h-[88vh] rounded-2xl bg-black"></video>`
|
||||||
|
: `<img src="${url}" class="max-w-[92vw] max-h-[88vh] rounded-2xl object-contain bg-white">`;
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTextPreview(encodedText) {
|
||||||
|
const text = decodeURIComponent(encodedText || '');
|
||||||
|
const modal = document.getElementById('previewModal');
|
||||||
|
const content = document.getElementById('previewContent');
|
||||||
|
content.innerHTML = `<div class="w-[min(760px,92vw)] max-h-[80vh] overflow-auto rounded-2xl bg-white p-8 text-slate-700 text-sm leading-7 whitespace-pre-wrap">${escapeHtml(text)}</div>`;
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePreview() {
|
||||||
|
const modal = document.getElementById('previewModal');
|
||||||
|
document.getElementById('previewContent').innerHTML = '';
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(delta) {
|
||||||
|
const next = Math.min(Math.max(currentPage + delta, 1), totalPages);
|
||||||
|
if (next !== currentPage) loadRecords(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshUsage() {
|
||||||
|
loadStats();
|
||||||
|
loadRecords(currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('previewModal').onclick = (event) => {
|
||||||
|
if (event.target.id === 'previewModal') closePreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
setFilter('all');
|
||||||
|
loadStats();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in New Issue
Block a user