ai_v/templates/usage.html

237 lines
14 KiB
HTML
Raw Normal View History

{% 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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
}[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 %}