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
+185
View File
@@ -0,0 +1,185 @@
{% extends "base.html" %}
{% block title %}管理仪表盘 — HF Daily Papers{% endblock %}
{% block content %}
<div class="admin-page">
{% set active = "dashboard" %}{% include "partials/admin_subnav.html" %}
<h1 class="page-heading">📊 系统状态</h1>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ stats.total_papers }}</div>
<div class="stat-label">论文总数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.today_papers }}</div>
<div class="stat-label">今日新增</div>
</div>
<div class="stat-card">
<div class="stat-value {% if stats.pending_count > 0 %}stat-warn{% endif %}">
{{ stats.pending_count + stats.none_count }}
</div>
<div class="stat-label">待总结</div>
</div>
<div class="stat-card">
<div class="stat-value {% if stats.failed_count > 0 %}stat-danger{% endif %}">
{{ stats.failed_count }}
</div>
<div class="stat-label">总结失败</div>
</div>
</div>
<div class="admin-quick-actions">
<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 class="admin-info-grid">
<div class="admin-info-card">
<h2 class="admin-info-title">🕐 调度器</h2>
<div class="admin-info-body">
<div class="info-row">
<span class="info-label">状态</span>
<span class="info-value">
{% if stats.scheduler_enabled %}
<span class="status-dot status-dot-on"></span> 运行中
{% else %}
<span class="status-dot status-dot-off"></span> 未启用
{% endif %}
</span>
</div>
<div class="info-row">
<span class="info-label">调度时间</span>
<span class="info-value">{{ stats.schedule_time }}{{ stats.timezone }}</span>
</div>
{% if stats.next_run %}
<div class="info-row">
<span class="info-label">下次执行</span>
<span class="info-value">{{ stats.next_run[:19] | replace('T', ' ') }}</span>
</div>
{% endif %}
{% if stats.active_locks %}
<div class="info-row">
<span class="info-label">活跃任务</span>
<span class="info-value">
{% for lock in stats.active_locks %}
<span class="task-badge task-{{ lock.task }}">{{ lock.task }}</span>
{% endfor %}
</span>
</div>
{% endif %}
<div class="info-row">
<span class="info-label"></span>
<button class="admin-action-btn admin-action-btn-sm" onclick="triggerPipeline()">
▶ 立即执行流水线
</button>
</div>
</div>
<div class="scheduler-history">
<h3 class="section-subtitle">执行历史</h3>
{% if scheduler_history %}
<div class="admin-table-wrap">
<table class="admin-table admin-table-compact">
<thead>
<tr><th>时间</th><th>状态</th><th>发现</th><th>新增</th><th>错误</th></tr>
</thead>
<tbody>
{% for log in scheduler_history %}
<tr>
<td class="time-cell">{{ log.started_at.strftime('%m-%d %H:%M') if log.started_at else '-' }}</td>
<td><span class="status-badge status-{{ log.status }}">
{% if log.status == 'success' %}✓{% elif log.status == 'running' %}⟳{% elif log.status == 'failed' %}✗{% else %}{{ log.status }}{% endif %}
</span></td>
<td>{{ log.papers_found or 0 }}</td>
<td>{{ log.papers_new or 0 }}</td>
<td class="error-cell" title="{{ log.error or '' }}">
{{ (log.error[:50] + '...') if log.error and log.error|length > 50 else (log.error or '-') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">暂无调度器执行记录。</p>
{% endif %}
</div>
</div>
<div class="admin-info-card">
<h2 class="admin-info-title">💾 存储概况</h2>
<div class="admin-info-body">
<div class="info-row"><span class="info-label">数据库</span><span class="info-value">{{ stats.db_size }}</span></div>
<div class="info-row"><span class="info-label">论文文件</span><span class="info-value">{{ stats.papers_size }}</span></div>
<div class="info-row"><span class="info-label">临时文件</span><span class="info-value">{{ stats.tmp_size }}</span></div>
</div>
<div class="summary-dist">
<h3 class="section-subtitle">总结状态分布</h3>
<div class="summary-dist-bars">
{% set total = stats.total_papers or 1 %}
{% set labels = {"done": "已完成", "pending": "待总结", "running": "运行中", "processing": "处理中", "failed": "失败", "permanent_failure": "永久失败", "none": "未开始"} %}
{% for st, cnt in stats.status_counts.items() %}
{% if cnt > 0 %}
<div class="dist-row">
<span class="dist-label">{{ labels.get(st, st) }}</span>
<div class="dist-bar-wrap"><div class="dist-bar dist-bar-{{ st }}" style="width: {{ (cnt / total * 100)|round(1) }}%"></div></div>
<span class="dist-count">{{ cnt }}</span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
</div>
<div class="admin-section">
<h2 class="admin-section-title">📋 最近活动</h2>
{% if stats.recent_logs %}
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr><th>任务</th><th>状态</th><th>日期</th><th>发现</th><th>新增</th><th>开始时间</th><th>完成时间</th><th>错误</th></tr>
</thead>
<tbody>
{% for log in stats.recent_logs %}
<tr>
<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="error-cell" title="{{ log.error or '' }}">
{{ (log.error[:60] + '...') if log.error and log.error|length > 60 else (log.error or '-') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<p>暂无活动日志</p>
<p class="hint">通过快捷操作触发任务后,日志将出现在这里。</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function triggerPipeline() {
fetch("/admin/trigger-pipeline", { 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) : "✅ 流水线已触发"); })
.catch(err => showToast("❌ 请求失败"));
}
</script>
{% endblock %}
+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 %}
+171
View File
@@ -0,0 +1,171 @@
{% extends "base.html" %}
{% block title %}论文管理 — HF Daily Papers{% endblock %}
{% block content %}
<div class="admin-page">
{% set active = "papers" %}{% include "partials/admin_subnav.html" %}
<h1 class="page-heading">📄 论文管理</h1>
<!-- 搜索和筛选 -->
<form class="paper-search-form" method="get" action="/admin/papers">
<div class="paper-search-row">
<input type="text" name="q" value="{{ request.query_params.get('q', '') }}"
placeholder="搜索标题 / 摘要..." class="paper-search-input" />
<input type="date" name="date_from" value="{{ request.query_params.get('date_from', '') }}"
class="paper-filter-input" title="起始日期" />
<input type="date" name="date_to" value="{{ request.query_params.get('date_to', '') }}"
class="paper-filter-input" title="结束日期" />
<select name="summary_status" class="paper-filter-input">
<option value="all" {% if current_status == 'all' %}selected{% endif %}>全部状态</option>
<option value="none" {% if current_status == 'none' %}selected{% endif %}>未总结</option>
<option value="done" {% if current_status == 'done' %}selected{% endif %}>已完成</option>
<option value="pending" {% if current_status == 'pending' %}selected{% endif %}>待总结</option>
<option value="failed" {% if current_status == 'failed' %}selected{% endif %}>失败</option>
</select>
<select name="sort" class="paper-filter-input">
<option value="date_desc" {% if current_sort == 'date_desc' %}selected{% endif %}>日期 ↓</option>
<option value="date_asc" {% if current_sort == 'date_asc' %}selected{% endif %}>日期 ↑</option>
<option value="upvotes_desc" {% if current_sort == 'upvotes_desc' %}selected{% endif %}>Upvotes ↓</option>
<option value="title_asc" {% if current_sort == 'title_asc' %}selected{% endif %}>标题 A→Z</option>
</select>
<button type="submit" class="paper-search-btn">搜索</button>
</div>
</form>
<!-- 批量操作栏 -->
<div class="paper-batch-bar">
<span class="paper-batch-label">批量操作</span>
<span class="paper-selected-count" id="selected-count">已选 0 篇</span>
<button class="admin-action-btn admin-action-btn-sm" onclick="batchAction('summarize')" id="batch-summarize-btn" disabled>📝 批量总结</button>
<button class="admin-action-btn admin-action-btn-sm admin-action-btn-danger" onclick="batchAction('delete')" id="batch-delete-btn" disabled>🗑 批量删除</button>
</div>
{% if papers %}
<div class="admin-table-wrap">
<table class="admin-table paper-manage-table">
<thead>
<tr>
<th class="th-check"><input type="checkbox" class="admin-check" id="select-all" onchange="toggleSelectAll(this)" /></th>
<th>标题</th>
<th>日期</th>
<th>👍</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for paper in papers %}
<tr data-arxiv="{{ paper.arxiv_id }}">
<td><input type="checkbox" class="admin-check paper-check" value="{{ paper.arxiv_id }}" onchange="updateSelectedCount()" /></td>
<td class="title-cell">
<a href="/paper/{{ paper.arxiv_id }}" target="_blank">
{{ (paper.title_zh or paper.title_en)[:70] }}{% if (paper.title_zh or paper.title_en)|length > 70 %}...{% endif %}
</a>
</td>
<td class="time-cell">{{ paper.paper_date.strftime('%m-%d') if paper.paper_date else '-' }}</td>
<td>{{ paper.upvotes or 0 }}</td>
<td>
{% set st = paper_summary_statuses.get(paper.arxiv_id, 'none') %}
<span class="status-badge status-{{ 'success' if st == 'done' else ('running' if st in ['pending', 'processing'] else 'failed') }}">
{% if st == 'done' %}✓{% elif st == 'pending' %}⏳{% elif st == 'processing' %}⟳{% elif st in ['failed', 'permanent_failure'] %}✗{% else %}○{% endif %}
</span>
</td>
<td class="action-cell">
<button class="action-btn-sm" title="重新总结" onclick="retryOne('{{ paper.arxiv_id }}', this)"></button>
<button class="action-btn-sm action-btn-danger" title="删除" onclick="confirmDeleteSingle('{{ paper.arxiv_id }}', '{{ (paper.title_zh or paper.title_en)[:40] | replace("'", "\\'") }}')">🗑</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% set total_pages = ((total + per_page - 1) // per_page) if total else 1 %}
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a class="page-btn" href="{{ pagination_url(page - 1) }}">← 上一页</a>
{% endif %}
<span class="page-info">第 {{ page }} / {{ total_pages }} 页(共 {{ total }} 篇)</span>
{% if page < total_pages %}
<a class="page-btn" href="{{ pagination_url(page + 1) }}">下一页 →</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="empty-state">
<p>没有找到匹配的论文</p>
<p class="hint">调整搜索条件或清除筛选。</p>
</div>
{% endif %}
</div>
<!-- 删除确认弹窗 -->
<div class="confirm-overlay" id="confirm-overlay" style="display:none;">
<div class="confirm-dialog">
<p class="confirm-msg" id="confirm-msg">确定删除?</p>
<div class="confirm-actions">
<button class="confirm-btn confirm-btn-cancel" onclick="closeConfirm()">取消</button>
<button class="confirm-btn confirm-btn-ok" id="confirm-ok" onclick="doConfirmAction()">确定删除</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let _confirmAction=null, _confirmTarget=null;
function toggleSelectAll(el) {
document.querySelectorAll('.paper-check').forEach(c=>{c.checked=el.checked;});
updateSelectedCount();
}
function updateSelectedCount() {
const n=document.querySelectorAll('.paper-check:checked').length;
document.getElementById('selected-count').textContent='已选 '+n+' 篇';
document.getElementById('batch-summarize-btn').disabled=n===0;
document.getElementById('batch-delete-btn').disabled=n===0;
}
function retryOne(arxivId,btn) {
btn.disabled=true;btn.textContent='...';
fetch('/admin/summarize/'+arxivId,{method:'POST',headers:{'Content-Type':'application/json'}})
.then(r=>r.json())
.then(data=>showToast(data.error?'❌ '+data.error.substring(0,100):'✅ 已提交重试'))
.catch(()=>showToast('❌ 请求失败'))
.finally(()=>{btn.disabled=false;btn.textContent='↻';});
}
function confirmDeleteSingle(arxivId,title) {
document.getElementById('confirm-msg').textContent='确定删除论文「'+title+'」?此操作不可恢复。';
_confirmAction='delete-single'; _confirmTarget=arxivId;
document.getElementById('confirm-overlay').style.display='flex';
}
function batchAction(action) {
const ids=Array.from(document.querySelectorAll('.paper-check:checked')).map(c=>c.value);
if(!ids.length)return;
if(action==='delete'){
document.getElementById('confirm-msg').textContent='确定删除 '+ids.length+' 篇论文?此操作不可恢复。';
_confirmAction='batch-delete'; _confirmTarget=ids;
document.getElementById('confirm-overlay').style.display='flex';
} else if(action==='summarize'){
fetch('/admin/papers-batch-action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'summarize',arxiv_ids:ids})})
.then(r=>r.json())
.then(data=>showToast(data.error?'❌ '+data.error.substring(0,100):'✅ 已提交批量总结'))
.catch(()=>showToast('❌ 请求失败'));
}
}
function doConfirmAction() {
if(_confirmAction==='delete-single'){
fetch('/admin/paper-delete/'+_confirmTarget,{method:'POST',headers:{'Content-Type':'application/json'}})
.then(r=>r.json()).then(data=>{showToast(data.error?'❌ '+data.error.substring(0,100):'✅ 已删除');setTimeout(()=>location.reload(),1000);})
.catch(()=>showToast('❌ 请求失败'));
} else if(_confirmAction==='batch-delete'){
fetch('/admin/papers-batch-action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'delete',arxiv_ids:_confirmTarget})})
.then(r=>r.json()).then(data=>{showToast(data.error?'❌ '+data.error.substring(0,100):'✅ 已删除');setTimeout(()=>location.reload(),1000);})
.catch(()=>showToast('❌ 请求失败'));
}
closeConfirm();
}
function closeConfirm() { document.getElementById('confirm-overlay').style.display='none'; _confirmAction=null; _confirmTarget=null; }
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeConfirm();});
</script>
{% endblock %}
+4 -2
View File
@@ -6,7 +6,9 @@
<title>{% block title %}HF Daily Papers{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="stylesheet" href="/static/css/style.css" />
{% if is_admin %}<link rel="stylesheet" href="/static/css/admin.css" />{% endif %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" />
{% block head_style %}{% endblock %}
</head>
<body>
<header class="site-header">
@@ -21,12 +23,12 @@
/>
</form>
<div class="nav-links">
<a href="/day/{{ today if today else '' }}">今日</a>
<a id="nav-today-link" href="/">今日</a>
<a href="/search">搜索</a>
<a href="/trends">趋势</a>
<a href="/reading-list">阅读列表</a>
{% if is_admin %}
<a href="/admin/logs">管理</a>
<a href="/admin/">管理</a>
<a href="/admin/logout" onclick="event.preventDefault();this.closest('form').submit()">退出</a>
<form action="/admin/logout" method="post" style="display:none"></form>
{% else %}
+29 -178
View File
@@ -57,7 +57,7 @@ endblock %} {% block content %}
<div class="quality-warning">📝 总结部分字段不完整</div>
{% endif %} {% if paper.summary.one_line %}
<section class="summary-section">
<p class="one-line">{{ paper.summary.one_line }}</p>
<p class="one-line">{{ paper.summary.one_line | safe }}</p>
</section>
{% endif %}
@@ -69,9 +69,9 @@ endblock %} {% block content %}
{% for c in prereqs.concepts %}
<div class="concept-card">
<h3>{{ c.term }}</h3>
<p>{{ c.explanation }}</p>
<p>{{ c.explanation | safe }}</p>
{% if c.why_matters %}
<p class="concept-why">{{ c.why_matters }}</p>
<p class="concept-why">{{ c.why_matters | safe }}</p>
{% endif %}
</div>
{% endfor %}
@@ -85,13 +85,13 @@ endblock %} {% block content %}
<h2>研究动机</h2>
<div class="motivation-block">
{% if paper.summary.motivation_problem %}
<p>{{ paper.summary.motivation_problem }}</p>
<p>{{ paper.summary.motivation_problem | safe }}</p>
{% endif %}
{% if paper.summary.motivation_goal %}
<p>本文的目标是{{ paper.summary.motivation_goal }}</p>
<p>本文的目标是{{ paper.summary.motivation_goal | safe }}</p>
{% endif %}
{% if paper.summary.motivation_gap %}
<p>与已有工作不同的是,{{ paper.summary.motivation_gap }}</p>
<p>与已有工作不同的是,{{ paper.summary.motivation_gap | safe }}</p>
{% endif %}
</div>
</section>
@@ -102,21 +102,21 @@ endblock %} {% block content %}
<section class="summary-section">
<h2>核心方法</h2>
{% if paper.summary.method_overview %}
<p>{{ paper.summary.method_overview }}</p>
<p>{{ paper.summary.method_overview | safe }}</p>
{% endif %}
<div class="key-idea">
<p>{{ paper.summary.method_key_idea }}</p>
<p>{{ paper.summary.method_key_idea | safe }}</p>
</div>
{% if paper.summary.method_steps_json %}
<details>
<summary>方法步骤详情</summary>
<p>{{ paper.summary.method_steps_json }}</p>
<p>{{ paper.summary.method_steps_json | safe }}</p>
</details>
{% endif %}
{% if paper.summary.method_novelty %}
<details>
<summary>技术新颖性</summary>
<p>{{ paper.summary.method_novelty }}</p>
<p>{{ paper.summary.method_novelty | safe }}</p>
</details>
{% endif %}
</section>
@@ -126,7 +126,7 @@ endblock %} {% block content %}
{% if paper.summary.results_main_json %}
<section class="summary-section">
<h2>实验结果</h2>
<p>{{ paper.summary.results_main_json }}</p>
<p>{{ paper.summary.results_main_json | safe }}</p>
{% if table_figures and table_figures|length > 0 %}
{# 优先展示原文表格截图 #}
{% for tf in table_figures %}
@@ -189,24 +189,24 @@ endblock %} {% block content %}
<section class="summary-section">
<h2>局限与改进</h2>
{% if paper.summary.limitations_json %}
<p>{{ paper.summary.limitations_json }}</p>
<p>{{ paper.summary.limitations_json | safe }}</p>
{% endif %}
{% if paper.summary.weaknesses_json %}
<details>
<summary>独立分析的弱点</summary>
<p>{{ paper.summary.weaknesses_json }}</p>
<p>{{ paper.summary.weaknesses_json | safe }}</p>
</details>
{% endif %}
{% if paper.summary.future_work_json %}
<details>
<summary>未来方向</summary>
<p>{{ paper.summary.future_work_json }}</p>
<p>{{ paper.summary.future_work_json | safe }}</p>
</details>
{% endif %}
{% if paper.summary.reproducibility %}
<details>
<summary>复现评估</summary>
<p>{{ paper.summary.reproducibility }}</p>
<p>{{ paper.summary.reproducibility | safe }}</p>
</details>
{% endif %}
</section>
@@ -290,9 +290,21 @@ endblock %} {% block content %}
{% block scripts %}
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"
onload="renderMathInElement(document.querySelector('.paper-detail'),{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}]});">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
if (typeof renderMathInElement === 'function') {
renderMathInElement(document.querySelector('.paper-detail'), {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false }
],
throwOnError: false
});
}
});
</script>
<script src="/static/js/lightbox.js"></script>
<style>
.lightbox-overlay {
position: fixed !important;
@@ -356,165 +368,4 @@ endblock %} {% block content %}
background: rgba(255,255,255,0.15);
}
</style>
<script>
(function() {
function openLightbox(src, alt) {
var existing = document.querySelector('.lightbox-overlay');
if (existing) existing.remove();
var overlay = document.createElement('div');
overlay.className = 'lightbox-overlay';
var img = document.createElement('img');
img.src = src;
img.alt = alt || '';
img.draggable = false;
// 工具栏
var toolbar = document.createElement('div');
toolbar.className = 'lightbox-toolbar';
toolbar.innerHTML =
'<button title="缩小"></button>' +
'<button title="放大">+</button>' +
'<button title="适合窗口">⊡</button>' +
'<button title="原始大小">1:1</button>' +
'<button title="关闭">✕</button>';
overlay.appendChild(img);
overlay.appendChild(toolbar);
document.body.appendChild(overlay);
// 视图状态
var scale = 1, tx = 0, ty = 0;
var baseW = 0, baseH = 0;
var dragging = false, dragStartX = 0, dragStartY = 0, startTx = 0, startTy = 0;
function apply() {
img.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
}
function fitToScreen() {
if (!baseW) return;
var sw = window.innerWidth, sh = window.innerHeight;
scale = Math.min(sw * 0.9 / baseW, sh * 0.9 / baseH, 1);
tx = (sw - baseW * scale) / 2;
ty = (sh - baseH * scale) / 2;
apply();
}
function resetOrigin() {
scale = 1;
tx = (window.innerWidth - baseW) / 2;
ty = (window.innerHeight - baseH) / 2;
apply();
}
function zoomAt(factor, cx, cy) {
var newScale = Math.max(0.1, Math.min(scale * factor, 20));
// 保持鼠标指向的图片点不变
tx = cx - (cx - tx) * (newScale / scale);
ty = cy - (ty - ty) * (newScale / scale); // 这行有误,下面修正
scale = newScale;
apply();
}
function zoomCenter(factor) {
var cx = window.innerWidth / 2;
var cy = window.innerHeight / 2;
var newScale = Math.max(0.1, Math.min(scale * factor, 20));
tx = cx - (cx - tx) * (newScale / scale);
ty = cy - (cy - ty) * (newScale / scale);
scale = newScale;
apply();
}
// 图片加载后初始化
img.onload = function() {
baseW = img.naturalWidth;
baseH = img.naturalHeight;
fitToScreen();
};
// 如果已缓存
if (img.complete && img.naturalWidth) {
baseW = img.naturalWidth;
baseH = img.naturalHeight;
fitToScreen();
}
// 工具栏按钮
var btns = toolbar.querySelectorAll('button');
// 缩小 / 放大 / 适合 / 原始 / 关闭
btns[0].onclick = function(e) { e.stopPropagation(); zoomCenter(0.7); };
btns[1].onclick = function(e) { e.stopPropagation(); zoomCenter(1.4); };
btns[2].onclick = function(e) { e.stopPropagation(); fitToScreen(); };
btns[3].onclick = function(e) { e.stopPropagation(); resetOrigin(); };
btns[4].onclick = function(e) { e.stopPropagation(); close(); };
// 滚轮缩放(以鼠标为中心)
overlay.addEventListener('wheel', function(e) {
e.preventDefault();
var factor = e.deltaY < 0 ? 1.15 : 0.87;
var rect = overlay.getBoundingClientRect();
var cx = e.clientX - rect.left;
var cy = e.clientY - rect.top;
var newScale = Math.max(0.1, Math.min(scale * factor, 20));
tx = cx - (cx - tx) * (newScale / scale);
ty = cy - (cy - ty) * (newScale / scale);
scale = newScale;
apply();
}, { passive: false });
// 拖拽平移
overlay.addEventListener('pointerdown', function(e) {
if (e.target.closest('.lightbox-toolbar')) return;
dragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
startTx = tx;
startTy = ty;
img.classList.add('dragging');
overlay.setPointerCapture(e.pointerId);
});
overlay.addEventListener('pointermove', function(e) {
if (!dragging) return;
tx = startTx + (e.clientX - dragStartX);
ty = startTy + (e.clientY - dragStartY);
apply();
});
overlay.addEventListener('pointerup', function() {
dragging = false;
img.classList.remove('dragging');
});
// ESC 关闭
function onKey(e) {
if (e.key === 'Escape') { close(); }
else if (e.key === '+' || e.key === '=') { zoomCenter(1.4); }
else if (e.key === '-') { zoomCenter(0.7); }
else if (e.key === '0') { fitToScreen(); }
}
function close() {
overlay.remove();
document.removeEventListener('keydown', onKey);
}
document.addEventListener('keydown', onKey);
// 激活动画
requestAnimationFrame(function() {
overlay.classList.add('active');
});
}
document.addEventListener('click', function(e) {
var img = e.target;
if (img.tagName !== 'IMG') return;
if (!img.closest('.inline-figure') && !img.closest('.gallery-item')) return;
if (img.closest('.lightbox-overlay')) return;
e.preventDefault();
openLightbox(img.src, img.alt);
});
})();
</script>
{% endblock %}
+4 -4
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %} {% block title %}{{ page_title }} — HF Daily Papers{%
{% extends "base.html" %}{% from "partials/paper_card.html" import render_card %}
{% block title %}{{ page_title }} — HF Daily Papers{%
endblock %} {% block content %}
<div class="date-nav">
{% if prev_day %}
@@ -8,13 +9,12 @@ endblock %} {% block content %}
{% if next_day <= today %}
<a href="/day/{{ next_day }}" class="date-nav-btn">后一天 →</a>
{% endif %}
<a href="/day/{{ today }}" class="date-nav-btn">今日</a>
<a href="/" class="date-nav-btn">今日</a>
</div>
{% if papers %}
<div class="paper-list">
{% for paper in papers %} {% include "partials/paper_card.html" %} {% endfor
%}
{% for paper in papers %}{{ render_card(paper) }}{% endfor %}
</div>
{% else %}
<div class="empty-state">
-107
View File
@@ -40,111 +40,4 @@
</form>
</div>
</div>
<style>
.login-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
padding: 40px 16px;
}
.login-card {
width: 100%;
max-width: 400px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 36px 32px;
box-shadow: 0 4px 24px var(--shadow);
}
.login-header {
text-align: center;
margin-bottom: 28px;
}
.login-title {
font-family: var(--font-body);
font-size: 1.4rem;
font-weight: 700;
color: var(--ink);
margin: 0 0 8px;
}
.login-subtitle {
font-size: 0.9rem;
color: var(--ink-light);
margin: 0;
}
.login-error {
background: #fce4ec;
color: #c62828;
padding: 10px 14px;
border-radius: var(--radius);
font-size: 0.85rem;
margin-bottom: 20px;
text-align: center;
}
.login-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.login-field label {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--ink);
margin-bottom: 6px;
}
.login-field input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.9rem;
font-family: var(--font-sans);
background: var(--bg);
color: var(--ink);
transition: border-color 0.2s;
box-sizing: border-box;
}
.login-field input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(27, 54, 93, 0.1);
}
.login-btn {
width: 100%;
padding: 12px;
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
font-family: var(--font-sans);
margin-top: 4px;
}
.login-btn:hover {
background: var(--accent-hover);
}
@media (max-width: 480px) {
.login-card {
padding: 28px 20px;
}
}
</style>
{% endblock %}
+10
View File
@@ -0,0 +1,10 @@
{# Admin subnav — 管理后台三个页面共享。active 参数: "dashboard" / "papers" / "logs" #}
<nav class="admin-subnav">
<a href="/admin/" class="admin-subnav-link {{ 'active' if active == 'dashboard' else '' }}">仪表盘</a>
<a href="/admin/papers" class="admin-subnav-link {{ 'active' if active == 'papers' else '' }}">论文管理</a>
<a href="/admin/logs" class="admin-subnav-link {{ 'active' if active == 'logs' else '' }}">日志</a>
<span class="admin-subnav-spacer"></span>
<form action="/admin/logout" method="post" class="admin-subnav-form">
<button type="submit" class="admin-subnav-link admin-subnav-logout">退出登录</button>
</form>
</nav>
+44 -7
View File
@@ -1,15 +1,45 @@
{# 论文卡片组件 — paper 变量必须在上下文中 #}
<article class="paper-card" data-arxiv="{{ paper.arxiv_id }}">
{# 论文卡片组件 — 支持普通和搜索两种模式 #}
{% macro render_card(paper, snippets=None, distances=None, variant="default") %}
<article class="paper-card {% if variant == 'search' %}search-result{% endif %}"
data-arxiv="{{ paper.arxiv_id }}">
<div class="paper-card-header">
<h2 class="paper-title">
<a href="/paper/{{ paper.arxiv_id }}">
{{ paper.title_zh or paper.title_en }}
{% if variant == 'search' and snippets %}
{% set snip = snippets.get(paper.id, {}) %}
{% if snip and snip.title_zh %}
{{ snip.title_zh | safe }}
{% elif paper.title_zh %}
{{ paper.title_zh }}
{% else %}
{{ paper.title_en }}
{% endif %}
{% else %}
{{ paper.title_zh or paper.title_en }}
{% endif %}
</a>
</h2>
<span class="paper-upvotes">👍 {{ paper.upvotes }}</span>
{% if variant == 'search' and distances and paper.arxiv_id in distances %}
<span class="similarity-score" title="语义相似度距离">
🎯 {{ "%.3f"|format(distances[paper.arxiv_id]) }}
</span>
{% endif %}
</div>
{% if paper.summary and paper.summary.one_line %}
{% if variant == 'search' and snippets %}
{% set snip = snippets.get(paper.id, {}) %}
{% if snip and snip.abstract %}
<p class="paper-snippet">{{ snip.abstract | safe }}</p>
{% elif paper.summary and paper.summary.one_line %}
<p class="paper-one-line">{{ paper.summary.one_line }}</p>
{% elif paper.abstract %}
<p class="paper-abstract-preview">
{{ paper.abstract[:200] }}{% if paper.abstract|length > 200 %}…{% endif %}
</p>
{% endif %}
{% elif paper.summary and paper.summary.one_line %}
<p class="paper-one-line">{{ paper.summary.one_line }}</p>
{% elif paper.abstract %}
<p class="paper-abstract-preview">
@@ -21,6 +51,9 @@
<span class="paper-authors">
{{ paper.authors|map(attribute='name')|join(', ')|truncate(80) }}
</span>
{% if variant == 'search' %}
<span class="paper-date">{{ paper.paper_date }}</span>
{% endif %}
</div>
<div class="paper-tags">
@@ -39,14 +72,14 @@
未总结
{% elif paper.summary_status.status == 'processing' %}
🔄 总结中
{% elif paper.summary_status.status == 'failed' or paper.summary_status.status == 'permanent_failure' %}
{% elif paper.summary_status.status in ('failed', 'permanent_failure') %}
❌ 总结失败
{% elif paper.summary_status.status == 'done' %}
✅ 已总结
{% endif %}
{# djlint:on #}
</span>
{% if paper.reading_status %}
{% if paper.reading_status and variant != 'search' %}
<span class="reading-badge reading-{{ paper.reading_status.status }}">
{# djlint:off #}
{% if paper.reading_status.status == 'unread' %}
@@ -63,6 +96,7 @@
{% endif %}
</div>
<div class="paper-footer-right">
{% if variant != 'search' %}
<button
class="btn-bookmark {% if paper.bookmark %}active{% endif %}"
hx-post="/api/bookmark/{{ paper.arxiv_id }}"
@@ -71,9 +105,12 @@
>
{% if paper.bookmark %}★{% else %}☆{% endif %}
</button>
{% endif %}
<a href="/paper/{{ paper.arxiv_id }}" class="btn-detail">详情 →</a>
</div>
</div>
{# HTMX 刷新锚点 — button swap 替换此 div #}
{% if variant != 'search' %}
<span id="user-data-{{ paper.arxiv_id }}"></span>
{% endif %}
</article>
{% endmacro %}
+81
View File
@@ -0,0 +1,81 @@
<!-- 总结状态列表(HTMX 片段) -->
{% if results %}
<div class="admin-table-wrap">
<table class="admin-table summary-table">
<thead>
<tr>
<th>标题</th>
<th>日期</th>
<th>状态</th>
<th>重试</th>
<th>错误类型</th>
<th>错误信息</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for paper, ss in results %}
<tr>
<td class="title-cell">
<a href="/paper/{{ paper.arxiv_id }}" target="_blank">
{{ (paper.title_zh or paper.title_en)[:60] }}{% if (paper.title_zh or paper.title_en)|length > 60 %}...{% endif %}
</a>
</td>
<td class="time-cell">{{ paper.paper_date.strftime('%m-%d') if paper.paper_date else '-' }}</td>
<td>
{% set st = ss.status if ss else 'none' %}
<span class="status-badge status-{{ 'success' if st == 'done' else ('running' if st in ['pending', 'processing'] else 'failed') }}">
{% if st == 'done' %}✓ 完成
{% elif st == 'pending' %}⏳ 待总结
{% elif st == 'processing' %}⟳ 运行中
{% elif st == 'failed' %}✗ 失败
{% elif st == 'permanent_failure' %}✗ 永久失败
{% else %}○ 未开始{% endif %}
</span>
</td>
<td>{{ ss.retry_count if ss else 0 }}</td>
<td>{{ (ss.error_type or '-') if ss else '-' }}</td>
<td class="error-cell" title="{{ ss.error if ss else '' }}">
{% if ss and ss.error %}
{{ ss.error[:60] + '...' if ss.error|length > 60 else ss.error }}
{% else %}-{% endif %}
</td>
<td>
{% if st in ['failed', 'permanent_failure', 'pending', 'none'] %}
<button class="retry-btn" onclick="retrySummary('{{ paper.arxiv_id }}', this)">重试</button>
{% else %}
<span style="color: var(--ink-muted); font-size: 0.75rem;">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- 分页 -->
{% set total_pages = ((total + per_page - 1) // per_page) if total else 1 %}
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<button class="page-btn" onclick="summaryPage({{ page - 1 }})">← 上一页</button>
{% endif %}
<span class="page-info">第 {{ page }} / {{ total_pages }} 页(共 {{ total }} 篇)</span>
{% if page < total_pages %}
<button class="page-btn" onclick="summaryPage({{ page + 1 }})">下一页 →</button>
{% endif %}
</div>
{% endif %}
<script>
function summaryPage(p) {
const status = document.querySelector('.summary-filters .filter-chip.active')?.dataset.status || 'all';
htmx.ajax('GET', '/admin/summary-status?status=' + status + '&page=' + p, '#summary-list');
}
</script>
{% else %}
<div class="empty-state">
<p>无匹配结果</p>
<p class="hint">调整筛选条件或触发总结任务。</p>
</div>
{% endif %}
+3 -3
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %} {% block title %}{{ page_title }} — HF Daily Papers{%
{% extends "base.html" %}{% from "partials/paper_card.html" import render_card %}
{% block title %}{{ page_title }} — HF Daily Papers{%
endblock %} {% block content %}
<section class="reading-list-page">
<h1 class="page-heading">📖 阅读列表</h1>
@@ -55,8 +56,7 @@ endblock %} {% block content %}
</div>
{% endif %} {% if papers %}
<div class="paper-list">
{% for paper in papers %} {% include "partials/paper_card.html" %} {% endfor
%}
{% for paper in papers %}{{ render_card(paper) }}{% endfor %}
</div>
{% else %}
<div class="empty-state">
+3 -62
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %} {% block title %}{{ page_title }} — HF Daily Papers{%
{% extends "base.html" %}{% from "partials/paper_card.html" import render_card %}
{% block title %}{{ page_title }} — HF Daily Papers{%
endblock %} {% block content %}
<section class="search-page">
{# 搜索表单 #}
@@ -81,67 +82,7 @@ endblock %} {% block content %}
{% if results %}
<div class="paper-list">
{% for paper in results %}
<article class="paper-card search-result" data-arxiv="{{ paper.arxiv_id }}">
<div class="paper-card-header">
<h2 class="paper-title">
<a href="/paper/{{ paper.arxiv_id }}">
{% set snippet = snippets.get(paper.id, {}) %} {% if snippet and
snippet.title_zh %} {{ snippet.title_zh | safe }} {% elif
paper.title_zh %} {{ paper.title_zh }} {% else %} {{ paper.title_en
}} {% endif %}
</a>
</h2>
<span class="paper-upvotes">👍 {{ paper.upvotes }}</span>
{% if distances and paper.arxiv_id in distances %}
<span class="similarity-score" title="语义相似度距离">
🎯 {{ "%.3f"|format(distances[paper.arxiv_id]) }}
</span>
{% endif %}
</div>
{% if snippet and snippet.abstract %}
<p class="paper-snippet">{{ snippet.abstract | safe }}</p>
{% elif paper.summary and paper.summary.one_line %}
<p class="paper-one-line">{{ paper.summary.one_line }}</p>
{% elif paper.abstract %}
<p class="paper-abstract-preview">
{{ paper.abstract[:200] }}{% if paper.abstract|length > 200 %}…{% endif
%}
</p>
{% endif %}
<div class="paper-meta">
<span class="paper-authors">
{{ paper.authors|map(attribute='name')|join(', ')|truncate(80) }}
</span>
<span class="paper-date">{{ paper.paper_date }}</span>
</div>
<div class="paper-tags">
{% for t in paper.tags[:5] %}
<span class="tag">{{ t.tag }}</span>
{% endfor %}
</div>
<div class="paper-footer">
<span
class="summary-badge summary-{{ paper.summary_status.status if paper.summary_status else 'none' }}"
>
{# djlint:off #}
{% if not paper.summary_status or paper.summary_status.status == 'pending' %}
未总结
{% elif paper.summary_status.status == 'processing' %}
🔄 总结中
{% elif paper.summary_status.status in ('failed', 'permanent_failure') %}
❌ 总结失败
{% elif paper.summary_status.status == 'done' %}
✅ 已总结
{% endif %}
{# djlint:on #}
</span>
<a href="/paper/{{ paper.arxiv_id }}" class="btn-detail">详情 →</a>
</div>
</article>
{{ render_card(paper, snippets=snippets, distances=distances, variant="search") }}
{% endfor %}
</div>