feat: add admin dashboard, pipeline service, lightbox, and update dependencies

This commit is contained in:
2026-06-09 09:32:10 +08:00
parent 0d293422ac
commit 32978b3fc5
50 changed files with 4054 additions and 1618 deletions
+89 -319
View File
@@ -1,68 +1,43 @@
{% extends "base.html" %} {% block title %}管理日志 — HF Daily Papers{% endblock
%} {% block content %}
<div class="admin-logs-page">
{% extends "base.html" %}
{% block title %}管理日志 — HF Daily Papers{% endblock %}
{% block content %}
<div class="admin-page">
{% set active = "logs" %}{% include "partials/admin_subnav.html" %}
<h1 class="page-heading">📋 管理日志</h1>
<!-- Tab 切换 -->
<div class="admin-tabs">
<button class="admin-tab active" data-tab="crawl-logs">抓取日志</button>
<button class="admin-tab" data-tab="delete-jobs">删除记录</button>
<button class="admin-tab" data-tab="summary-status">总结状态</button>
</div>
<!-- 抓取日志 Tab -->
<!-- 抓取日志 -->
<div class="admin-tab-content active" id="crawl-logs">
{% if crawl_logs %}
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>任务</th>
<th>状态</th>
<th>日期</th>
<th>发现</th>
<th>新增</th>
<th>开始时间</th>
<th>完成时间</th>
<th>错误</th>
</tr>
<tr><th>ID</th><th>任务</th><th>状态</th><th>日期</th><th>发现</th><th>新增</th><th>开始时间</th><th>完成时间</th><th>错误</th></tr>
</thead>
<tbody>
{% for log in crawl_logs %}
<tr>
<td>{{ log.id }}</td>
<td>
<span class="task-badge task-{{ log.task }}">{{ log.task }}</span>
</td>
<td>
<span class="status-badge status-{{ log.status }}">
{# djlint:off #}
{% if log.status == 'success' %}
✓ 成功
{% elif log.status == 'running' %}
⟳ 运行中
{% elif log.status == 'failed' %}
✗ 失败
{% else %}
{{ log.status }}
{% endif %}
{# djlint:on #}
</span>
</td>
<td><span class="task-badge task-{{ log.task }}">{{ log.task }}</span></td>
<td><span class="status-badge status-{{ log.status }}">
{# djlint:off #}
{% if log.status == 'success' %}✓ 成功{% elif log.status == 'running' %}⟳ 运行中{% elif log.status == 'failed' %}✗ 失败{% else %}{{ log.status }}{% endif %}
{# djlint:on #}
</span></td>
<td>{{ log.date or '-' }}</td>
<td>{{ log.papers_found or 0 }}</td>
<td>{{ log.papers_new or 0 }}</td>
<td class="time-cell">
{{ log.started_at.strftime('%m-%d %H:%M') if log.started_at else
'-' }}
</td>
<td class="time-cell">
{{ log.completed_at.strftime('%m-%d %H:%M') if log.completed_at
else '-' }}
</td>
<td class="time-cell">{{ log.started_at.strftime('%m-%d %H:%M') if log.started_at else '-' }}</td>
<td class="time-cell">{{ log.completed_at.strftime('%m-%d %H:%M') if log.completed_at else '-' }}</td>
<td class="error-cell" title="{{ log.error or '' }}">
{{ log.error[:80] + '...' if log.error and log.error|length > 80
else (log.error or '-') }}
{{ log.error[:80] + '...' if log.error and log.error|length > 80 else (log.error or '-') }}
</td>
</tr>
{% endfor %}
@@ -77,23 +52,13 @@
{% endif %}
</div>
<!-- 删除记录 Tab -->
<!-- 删除记录 -->
<div class="admin-tab-content" id="delete-jobs">
{% if delete_jobs %}
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>起始日期</th>
<th>结束日期</th>
<th>包含笔记</th>
<th>论文数</th>
<th>状态</th>
<th>开始时间</th>
<th>完成时间</th>
<th>错误</th>
</tr>
<tr><th>ID</th><th>起始日期</th><th>结束日期</th><th>包含笔记</th><th>论文数</th><th>状态</th><th>开始时间</th><th>完成时间</th><th>错误</th></tr>
</thead>
<tbody>
{% for job in delete_jobs %}
@@ -103,32 +68,15 @@
<td>{{ job.date_end }}</td>
<td>{{ '是' if job.include_notes else '否' }}</td>
<td>{{ job.paper_count or 0 }}</td>
<td>
<span class="status-badge status-{{ job.status }}">
{# djlint:off #}
{% if job.status == 'success' %}
✓ 成功
{% elif job.status == 'running' %}
⟳ 运行中
{% elif job.status == 'failed' %}
✗ 失败
{% else %}
{{ job.status }}
{% endif %}
{# djlint:on #}
</span>
</td>
<td class="time-cell">
{{ job.started_at.strftime('%m-%d %H:%M') if job.started_at else
'-' }}
</td>
<td class="time-cell">
{{ job.completed_at.strftime('%m-%d %H:%M') if job.completed_at
else '-' }}
</td>
<td><span class="status-badge status-{{ job.status }}">
{# djlint:off #}
{% if job.status == 'success' %}✓ 成功{% elif job.status == 'running' %}⟳ 运行中{% elif job.status == 'failed' %}✗ 失败{% else %}{{ job.status }}{% endif %}
{# djlint:on #}
</span></td>
<td class="time-cell">{{ job.started_at.strftime('%m-%d %H:%M') if job.started_at else '-' }}</td>
<td class="time-cell">{{ job.completed_at.strftime('%m-%d %H:%M') if job.completed_at else '-' }}</td>
<td class="error-cell" title="{{ job.error or '' }}">
{{ job.error[:80] + '...' if job.error and job.error|length > 80
else (job.error or '-') }}
{{ job.error[:80] + '...' if job.error and job.error|length > 80 else (job.error or '-') }}
</td>
</tr>
{% endfor %}
@@ -143,259 +91,81 @@
{% endif %}
</div>
<!-- 总结状态 -->
<div class="admin-tab-content" id="summary-status">
<div class="summary-filters">
<span class="summary-filter-label">筛选:</span>
<button class="filter-chip active" data-status="all">全部</button>
<button class="filter-chip" data-status="none">未开始</button>
<button class="filter-chip" data-status="pending">待总结</button>
<button class="filter-chip" data-status="processing">运行中</button>
<button class="filter-chip" data-status="failed">失败</button>
<button class="filter-chip" data-status="permanent_failure">永久失败</button>
<button class="filter-chip" data-status="done">已完成</button>
</div>
<div class="summary-stats-row">
<span class="summary-stat">全部 <strong>{{ summary_total or 0 }}</strong></span>
<span class="summary-stat summary-stat-pending">待总结 <strong>{{ summary_pending or 0 }}</strong></span>
<span class="summary-stat summary-stat-failed">失败 <strong>{{ summary_failed or 0 }}</strong></span>
<span class="summary-stat summary-stat-done">已完成 <strong>{{ summary_done or 0 }}</strong></span>
</div>
<div id="summary-list"
hx-get="/admin/summary-status"
hx-trigger="load"
hx-target="#summary-list"
hx-swap="innerHTML">
<div class="empty-state"><p>加载中...</p></div>
</div>
<div class="summary-batch-actions">
<button class="admin-action-btn" onclick="retryAllFailed()">🔄 重试所有失败</button>
</div>
</div>
<!-- 管理操作区 -->
<div class="admin-actions">
<h2 class="admin-actions-title">管理操作</h2>
<div class="admin-action-buttons">
<button class="admin-action-btn" onclick="adminAction('crawl')">
🔄 抓取今天
</button>
<button class="admin-action-btn" onclick="adminAction('summarize')">
📝 批量总结
</button>
<button class="admin-action-btn" onclick="adminAction('cleanup')">
🧹 清理临时文件
</button>
<button class="admin-action-btn" onclick="adminAction('crawl')">🔄 抓取今天</button>
<button class="admin-action-btn" onclick="adminAction('summarize')">📝 批量总结</button>
<button class="admin-action-btn" onclick="adminAction('cleanup')">🧹 清理临时文件</button>
</div>
</div>
</div>
{% endblock %}
<style>
/* ── Admin Logs ────────────────────────────────────────────────── */
.admin-logs-page {
max-width: 100%;
}
.admin-tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border);
margin-bottom: 20px;
}
.admin-tab {
padding: 10px 24px;
border: none;
background: none;
font-size: 0.9rem;
font-weight: 500;
color: var(--ink-light);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition:
color 0.2s,
border-color 0.2s;
font-family: var(--font-sans);
}
.admin-tab:hover {
color: var(--accent);
}
.admin-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.admin-tab-content {
display: none;
}
.admin-tab-content.active {
display: block;
}
/* ── Table ─────────────────────────────────────────────────────── */
.admin-table-wrap {
overflow-x: auto;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.admin-table th {
padding: 10px 12px;
text-align: left;
font-weight: 600;
color: var(--ink-light);
background: var(--bg);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.admin-table td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
color: var(--ink);
vertical-align: middle;
}
.admin-table tbody tr:hover {
background: var(--bg);
}
.admin-table tbody tr:last-child td {
border-bottom: none;
}
.time-cell {
white-space: nowrap;
color: var(--ink-light);
}
.error-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #c62828;
font-size: 0.8rem;
}
/* ── Badges ────────────────────────────────────────────────────── */
.task-badge,
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
}
.task-crawl {
background: #e3f2fd;
color: #1565c0;
}
.task-summarize {
background: #f3e5f5;
color: #7b1fa2;
}
.task-cleanup {
background: #e8f5e9;
color: #2e7d32;
}
.task-delete {
background: #fce4ec;
color: #c62828;
}
.task-scheduler {
background: #fff3e0;
color: #e65100;
}
.status-success {
background: #e8f5e9;
color: #388e3c;
}
.status-running {
background: #e3f2fd;
color: #1976d2;
}
.status-failed {
background: #fce4ec;
color: #c62828;
}
/* ── Admin Actions ─────────────────────────────────────────────── */
.admin-actions {
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.admin-actions-title {
font-family: var(--font-body);
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 12px;
color: var(--ink);
}
.admin-action-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.admin-action-btn {
padding: 8px 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.85rem;
font-weight: 500;
color: var(--ink);
cursor: pointer;
transition: all 0.2s;
font-family: var(--font-sans);
}
.admin-action-btn:hover {
border-color: var(--accent);
color: var(--accent);
box-shadow: 0 2px 8px var(--shadow);
}
/* ── Responsive ────────────────────────────────────────────────── */
@media (max-width: 640px) {
.admin-table {
font-size: 0.8rem;
}
.admin-table th,
.admin-table td {
padding: 6px 8px;
}
.admin-action-buttons {
flex-direction: column;
}
.admin-action-btn {
width: 100%;
text-align: center;
}
}
</style>
{% endblock %} {% block scripts %}
{% block scripts %}
<script>
function adminAction(action) {
const url = "/admin/" + action;
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
})
.then((r) => {
if (r.status === 303 || r.status === 401) {
window.location.href = "/admin/login";
return;
}
return r.json();
})
.then((data) => {
if (data) {
alert(JSON.stringify(data, null, 2));
location.reload();
}
})
.catch((err) => {
alert("请求失败: " + err.message);
});
function retrySummary(arxivId, btn) {
btn.disabled=true; btn.textContent="处理中...";
fetch("/admin/summarize/"+arxivId,{method:"POST",headers:{"Content-Type":"application/json"}})
.then(r=>{if(r.status===303||r.status===401){window.location.href="/admin/login";return;}return r.json();})
.then(data=>{if(data){showToast(data.error?"❌ "+data.error.substring(0,200):"✅ 已提交重试");setTimeout(()=>htmx.trigger("#summary-list","reloadSummary"),1000);}})
.catch(err=>showToast("❌ 请求失败"))
.finally(()=>{btn.disabled=false;btn.textContent="重试";});
}
function retryAllFailed() {
if(!confirm("确定重试所有失败的总结任务?"))return;
fetch("/admin/summary-retry-failed",{method:"POST",headers:{"Content-Type":"application/json"}})
.then(r=>{if(r.status===303||r.status===401){window.location.href="/admin/login";return;}return r.json();})
.then(data=>{if(data){showToast(data.error?"❌ "+data.error.substring(0,200):"✅ "+(data.message||"已提交"));setTimeout(()=>htmx.trigger("#summary-list","reloadSummary"),1500);}})
.catch(err=>showToast("❌ 请求失败"));
}
// Tab 切换
document.querySelectorAll(".admin-tab").forEach((tab) => {
tab.addEventListener("click", () => {
document
.querySelectorAll(".admin-tab")
.forEach((t) => t.classList.remove("active"));
document
.querySelectorAll(".admin-tab-content")
.forEach((c) => c.classList.remove("active"));
document.querySelectorAll(".admin-tab").forEach(tab=>{
tab.addEventListener("click",()=>{
document.querySelectorAll(".admin-tab").forEach(t=>t.classList.remove("active"));
document.querySelectorAll(".admin-tab-content").forEach(c=>c.classList.remove("active"));
tab.classList.add("active");
document.getElementById(tab.dataset.tab).classList.add("active");
});
});
// 总结状态筛选
document.querySelectorAll(".summary-filters .filter-chip").forEach(chip=>{
chip.addEventListener("click",()=>{
document.querySelectorAll(".summary-filters .filter-chip").forEach(c=>c.classList.remove("active"));
chip.classList.add("active");
htmx.ajax("GET","/admin/summary-status?status="+chip.dataset.status,"#summary-list");
});
});
</script>
{% endblock %}