237 lines
14 KiB
HTML
237 lines
14 KiB
HTML
{% 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 %}
|