新增后台通知中心和产品审核发布功能
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user