新增后台通知中心和产品审核发布功能

This commit is contained in:
2026-07-11 01:55:01 +08:00
parent e03e514b70
commit 836acf1aa8
18 changed files with 703 additions and 18 deletions
+307
View File
@@ -41,6 +41,21 @@
<!-- 概览 -->
<section id="section-overview">
<h1 class="text-2xl font-bold text-gray-800 mb-6">管理概览</h1>
<!-- 待办提醒 -->
<div id="todoAlert" class="mb-6 hidden">
<div class="bg-orange-50 border border-orange-200 rounded-lg p-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<i class="ri-notification-badge-line text-2xl text-orange-600"></i>
<div>
<div class="font-medium text-orange-800"><span id="todoCount">0</span> 条待处理事项</div>
<div class="text-sm text-orange-600" id="todoDetail"></div>
</div>
</div>
<button onclick="showSection('reviews')" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700">立即处理</button>
</div>
</div>
<div class="grid grid-cols-5 gap-4 mb-8" id="statsCards"><div class="text-center text-gray-400 py-4">加载中...</div></div>
<div class="bg-white rounded-xl p-6 shadow-sm mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4">快捷操作</h2>
@@ -275,6 +290,49 @@
</table>
</div>
</section>
<!-- 通知中心 -->
<section id="section-notifications" class="hidden">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">通知中心</h1>
<div class="flex gap-2">
<button onclick="markAllRead()" class="px-4 py-2 bg-gray-200 text-gray-600 rounded-lg hover:bg-gray-300"><i class="ri-check-double-line mr-1"></i>全部已读</button>
</div>
</div>
<div id="notificationsList" class="space-y-3">
<div class="text-center text-gray-400 py-8">加载中...</div>
</div>
</section>
<!-- 审核管理 -->
<section id="section-reviews" class="hidden">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">审核管理</h1>
<div class="flex gap-2">
<select id="reviewStatusFilter" onchange="loadReviews()" class="px-4 py-2 border rounded-lg">
<option value="pending">待审核</option>
<option value="approved">已通过</option>
<option value="rejected">已拒绝</option>
<option value="all">全部</option>
</select>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">产品名称</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">分类</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">来源</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">提交时间</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">状态</th>
<th class="px-4 py-3 text-center text-sm font-medium text-gray-600">操作</th>
</tr>
</thead>
<tbody id="reviewsTable"><tr><td colspan="6" class="text-center text-gray-400 py-8">加载中...</td></tr></tbody>
</table>
</div>
</section>
</main>
<!-- 编辑弹窗 -->
@@ -766,6 +824,24 @@
</a>
`;
// 通知中心(显示未读数)
html += `
<a href="#notifications" onclick="showSection('notifications')" class="sidebar-link flex items-center gap-2 px-3 py-2 rounded-lg text-gray-300" data-section="notifications">
<i class="ri-notification-line"></i>
<span>通知中心</span>
<span id="navNotificationBadge" class="ml-auto px-1.5 py-0.5 bg-red-500 text-white text-xs rounded-full hidden">0</span>
</a>
`;
// 审核管理(显示待审核数)
html += `
<a href="#reviews" onclick="showSection('reviews')" class="sidebar-link flex items-center gap-2 px-3 py-2 rounded-lg text-gray-300" data-section="reviews">
<i class="ri-checkbox-circle-line"></i>
<span>审核管理</span>
<span id="navReviewBadge" class="ml-auto px-1.5 py-0.5 bg-orange-500 text-white text-xs rounded-full hidden">0</span>
</a>
`;
document.getElementById('sidebarNav').innerHTML = html;
}
@@ -882,6 +958,8 @@
if (section === 'gpus') loadAdminGpus();
if (section === 'cpus') loadAdminCpus();
if (section === 'knowledge') loadAdminKnowledge();
if (section === 'notifications') loadNotifications();
if (section === 'reviews') loadReviews();
}
// 加载网站配置
@@ -990,6 +1068,9 @@
document.getElementById('recent-models').innerHTML = models.length > 0
? models.map(m => `<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"><div><span class="font-medium text-gray-800">${m.name}</span><span class="text-sm text-gray-500 ml-2">${m.organization}</span></div><div class="text-sm text-gray-400">${m.is_open_source ? '开源' : '商业'}</div></div>`).join('')
: '<div class="text-gray-400">暂无数据</div>';
// 加载通知计数
loadNotificationCounts();
}
// 内置分类列表
@@ -2993,6 +3074,232 @@
} catch (e) { alert('导入失败: ' + e.message); }
}
// ─── 通知和审核功能 ─────────────────────────────────────────────────
// 加载未读通知和待审核数
async function loadNotificationCounts() {
try {
const [notifRes, reviewRes] = await Promise.all([
fetch('/api/notifications/unread-count'),
fetch('/api/reviews/count')
]);
const notifData = await notifRes.json();
const reviewData = await reviewRes.json();
// 更新导航栏徽章
const notifBadge = document.getElementById('navNotificationBadge');
const reviewBadge = document.getElementById('navReviewBadge');
if (notifData.count > 0) {
notifBadge.textContent = notifData.count > 99 ? '99+' : notifData.count;
notifBadge.classList.remove('hidden');
} else {
notifBadge.classList.add('hidden');
}
if (reviewData.count > 0) {
reviewBadge.textContent = reviewData.count > 99 ? '99+' : reviewData.count;
reviewBadge.classList.remove('hidden');
} else {
reviewBadge.classList.add('hidden');
}
// 更新概览页的待办提醒
const todoAlert = document.getElementById('todoAlert');
const totalCount = notifData.count + reviewData.count;
if (totalCount > 0) {
todoAlert.classList.remove('hidden');
document.getElementById('todoCount').textContent = totalCount;
document.getElementById('todoDetail').textContent =
`${notifData.count} 条未读通知,${reviewData.count} 条待审核产品`;
} else {
todoAlert.classList.add('hidden');
}
} catch (e) {
console.error('加载通知数失败:', e);
}
}
// 加载通知列表
async function loadNotifications() {
try {
const res = await fetch('/api/notifications?limit=50');
const notifications = await res.json();
if (notifications.length === 0) {
document.getElementById('notificationsList').innerHTML = `
<div class="bg-white rounded-xl p-8 text-center text-gray-400">
<i class="ri-notification-off-line text-4xl mb-2"></i>
<div>暂无通知</div>
</div>
`;
return;
}
const levelColors = {
info: 'bg-blue-50 border-blue-200',
warning: 'bg-orange-50 border-orange-200',
error: 'bg-red-50 border-red-200',
success: 'bg-green-50 border-green-200'
};
const levelIcons = {
info: 'ri-information-line text-blue-600',
warning: 'ri-alert-line text-orange-600',
error: 'ri-error-warning-line text-red-600',
success: 'ri-checkbox-circle-line text-green-600'
};
const html = notifications.map(n => `
<div class="bg-white rounded-xl p-4 border ${n.read ? 'opacity-60' : ''} ${levelColors[n.level] || 'bg-gray-50 border-gray-200'}" id="notif-${n.id}">
<div class="flex items-start gap-3">
<i class="${levelIcons[n.level] || 'ri-notification-line text-gray-600'} text-xl mt-0.5"></i>
<div class="flex-1">
<div class="font-medium text-gray-800">${n.title}</div>
<div class="text-sm text-gray-600 mt-1">${n.message}</div>
<div class="text-xs text-gray-400 mt-2">${n.created_at}</div>
</div>
${!n.read ? `<button onclick="markRead('${n.id}')" class="text-xs text-indigo-600 hover:text-indigo-800">标为已读</button>` : ''}
</div>
</div>
`).join('');
document.getElementById('notificationsList').innerHTML = html;
} catch (e) {
document.getElementById('notificationsList').innerHTML = `
<div class="bg-white rounded-xl p-8 text-center text-red-500">加载失败: ${e.message}</div>
`;
}
}
// 标记单个通知已读
async function markRead(notifId) {
await fetch(`/api/notifications/${notifId}/read`, {method: 'POST'});
loadNotifications();
loadNotificationCounts();
}
// 标记全部已读
async function markAllRead() {
await fetch('/api/notifications/read-all', {method: 'POST'});
loadNotifications();
loadNotificationCounts();
}
// 加载审核列表
async function loadReviews() {
const status = document.getElementById('reviewStatusFilter').value;
try {
const res = await fetch(`/api/reviews?status=${status}`);
const reviews = await res.json();
if (reviews.length === 0) {
document.getElementById('reviewsTable').innerHTML = `
<tr><td colspan="6" class="text-center text-gray-400 py-8">暂无数据</td></tr>
`;
return;
}
const statusLabels = {
pending: '<span class="px-2 py-1 bg-orange-100 text-orange-700 rounded text-xs">待审核</span>',
approved: '<span class="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">已通过</span>',
rejected: '<span class="px-2 py-1 bg-red-100 text-red-700 rounded text-xs">已拒绝</span>'
};
const html = reviews.map(r => {
const catName = categories.find(c => c.id === r.category_id)?.name || r.category_id;
return `
<tr class="border-b hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-800">${r.data?.name || '-'}</td>
<td class="px-4 py-3 text-gray-600">${catName}</td>
<td class="px-4 py-3 text-sm">
${r.source === 'api' ? '<span class="text-indigo-600">API</span>' : '<span class="text-gray-500">网页</span>'}
</td>
<td class="px-4 py-3 text-sm text-gray-500">${r.created_at}</td>
<td class="px-4 py-3">${statusLabels[r.status] || r.status}</td>
<td class="px-4 py-3 text-center">
${r.status === 'pending' ? `
<button onclick="approveReview('${r.id}')" class="text-green-600 hover:text-green-800 mr-2" title="通过"><i class="ri-check-line"></i></button>
<button onclick="rejectReview('${r.id}')" class="text-red-600 hover:text-red-800" title="拒绝"><i class="ri-close-line"></i></button>
<button onclick="viewReviewDetail('${r.id}')" class="text-blue-600 hover:text-blue-800 ml-2" title="查看详情"><i class="ri-eye-line"></i></button>
` : `
<button onclick="viewReviewDetail('${r.id}')" class="text-blue-600 hover:text-blue-800" title="查看详情"><i class="ri-eye-line"></i></button>
`}
</td>
</tr>
`;
}).join('');
document.getElementById('reviewsTable').innerHTML = html;
} catch (e) {
document.getElementById('reviewsTable').innerHTML = `
<tr><td colspan="6" class="text-center text-red-500 py-8">加载失败: ${e.message}</td></tr>
`;
}
}
// 通过审核
async function approveReview(reviewId) {
if (!confirm('确认通过该产品审核?')) return;
try {
const res = await fetch(`/api/reviews/${reviewId}/approve`, {method: 'POST'});
const data = await res.json();
if (data.error) {
alert('操作失败: ' + data.error);
} else {
alert('审核通过,产品已发布!');
loadReviews();
loadNotificationCounts();
loadOverview();
}
} catch (e) {
alert('操作失败: ' + e.message);
}
}
// 拒绝审核
async function rejectReview(reviewId) {
const reason = prompt('请输入拒绝原因(可选):');
if (reason === null) return; // 用户取消
try {
const res = await fetch(`/api/reviews/${reviewId}/reject`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({reason})
});
const data = await res.json();
if (data.error) {
alert('操作失败: ' + data.error);
} else {
alert('已拒绝该产品!');
loadReviews();
loadNotificationCounts();
}
} catch (e) {
alert('操作失败: ' + e.message);
}
}
// 查看审核详情
async function viewReviewDetail(reviewId) {
try {
const res = await fetch(`/api/reviews/${reviewId}`);
const review = await res.json();
let html = '<div class="space-y-4">';
html += `<div><span class="text-gray-500">状态:</span> ${review.status}</div>`;
html += `<div><span class="text-gray-500">分类:</span> ${review.category_id}</div>`;
html += `<div><span class="text-gray-500">来源:</span> ${review.source}</div>`;
html += `<div><span class="text-gray-500">提交时间:</span> ${review.created_at}</div>`;
html += '<div class="border-t pt-4"><h3 class="font-medium mb-2">产品数据:</h3>';
html += '<pre class="bg-gray-50 p-3 rounded text-sm overflow-auto max-h-60">' + JSON.stringify(review.data, null, 2) + '</pre>';
html += '</div></div>';
alert(html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim());
} catch (e) {
alert('加载失败: ' + e.message);
}
}
init();
</script>
</body>