Files
awesome/src/views/admin.ejs
T
cheney 3f7b48325f
Build and Deploy / build (push) Successful in 1m37s
Build and Deploy / deploy (push) Successful in 31s
feat: 管理台内容状态过滤+预览、批量上架/灰掉、标签拖拽调整层级
2026-09-21 11:15:26 +08:00

269 lines
15 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<%- include('partials/head') %>
<%- include('partials/topbar', { active: 'admin' }) %>
<div class="admin-shell">
<nav class="admin-nav" aria-label="管理分区">
<h4>管理台 · <%= user.username %></h4>
<button type="button" data-section="sec-content" aria-current="true">内容管理</button>
<button type="button" data-section="sec-tags" aria-current="false">标签管理</button>
<button type="button" data-section="sec-comments" aria-current="false">评论管理</button>
<button type="button" data-section="sec-sources" aria-current="false">内容源</button>
<% if (isAdmin) { %>
<button type="button" data-section="sec-accounts" aria-current="false">账号管理</button>
<% } %>
</nav>
<main class="admin-main">
<!-- 内容管理 -->
<section id="sec-content" class="active">
<p class="anno">// table: entries · 状态 active | greyed | pending · 预览 = 按当前状态模拟前台展示</p>
<h1>内容管理</h1>
<p class="admin-desc">全部收录条目。灰掉 = 暂不可见但保留数据;删除为物理删除,需确认。</p>
<div class="status-chips">
<a class="chip <%= status === 'all' ? 'is-active' : '' %>" href="/admin?status=all">全部 <span class="mono"><%= statusCounts.all %></span></a>
<a class="chip <%= status === 'active' ? 'is-active' : '' %>" href="/admin?status=active">上线 <span class="mono"><%= statusCounts.active %></span></a>
<a class="chip <%= status === 'pending' ? 'is-active' : '' %>" href="/admin?status=pending">待核验 <span class="mono"><%= statusCounts.pending %></span></a>
<a class="chip <%= status === 'greyed' ? 'is-active' : '' %>" href="/admin?status=greyed">灰掉 <span class="mono"><%= statusCounts.greyed %></span></a>
<span class="spacer"></span>
<span class="mono page-hint">第 <%= page %> / <%= totalPages %> 页</span>
</div>
<div class="batch-bar">
<button class="btn btn-sm" type="button" data-action="entry-select-all">全选本页</button>
<button class="btn btn-sm" type="button" data-action="entry-batch" data-status="active">批量上架</button>
<button class="btn btn-sm" type="button" data-action="entry-batch" data-status="greyed">批量灰掉</button>
<span class="mono batch-count" id="batch-count">已选 0 项</span>
</div>
<table class="data-table" style="margin-top:14px">
<thead>
<tr>
<th style="width:36px"><input type="checkbox" class="entry-check-all" title="全选本页" /></th>
<th>标题</th>
<th>类型</th>
<th>状态</th>
<th>更新时间</th>
<th style="text-align:right">操作</th>
</tr>
</thead>
<tbody>
<% if (entries.length === 0) { %>
<tr><td colspan="6" class="mono" style="text-align:center;color:var(--ink-dim);padding:28px">当前筛选下没有条目</td></tr>
<% } %>
<% for (const e of entries) { %>
<tr data-id="<%= e.id %>">
<td style="text-align:center"><input type="checkbox" class="entry-check" data-id="<%= e.id %>" /></td>
<td>
<b><%= e.title %></b><br />
<span class="mono" style="font-size:11px;color:var(--ink-dim)"><%= (e.tags || []).join(' · ') || '—' %></span>
</td>
<td><%= e.type %></td>
<td>
<% if (e.status === 'active') { %><span class="status-pill status-ok">active</span>
<% } else if (e.status === 'pending') { %><span class="status-pill status-warn">pending</span>
<% } else { %><span class="status-pill status-dim"><%= e.status %></span><% } %>
</td>
<td class="mono"><%= fmtDate(e.updated_at) %></td>
<td>
<div class="row-actions">
<a class="btn btn-sm" href="/admin/entries/<%= e.id %>/edit">编辑</a>
<a class="btn btn-sm" href="/entries/<%= e.slug %>?preview=1" target="_blank" rel="noopener">预览</a>
<% if (e.status !== 'active') { %>
<button class="btn btn-sm" type="button" data-action="entry-status" data-id="<%= e.id %>" data-status="active">上架</button>
<% } else { %>
<button class="btn btn-sm" type="button" data-action="entry-status" data-id="<%= e.id %>" data-status="greyed">灰掉</button>
<% } %>
<% if (isAdmin) { %>
<button class="btn btn-sm btn-danger" type="button" data-action="entry-delete" data-id="<%= e.id %>">删除</button>
<% } %>
</div>
</td>
</tr>
<% } %>
</tbody>
</table>
<div class="pager">
<a class="btn btn-sm <%= page <= 1 ? 'btn-disabled' : '' %>" href="/admin?status=<%= status %>&page=<%= Math.max(1, page - 1) %>">上一页</a>
<a class="btn btn-sm <%= page >= totalPages ? 'btn-disabled' : '' %>" href="/admin?status=<%= status %>&page=<%= page + 1 %>">下一页</a>
</div>
</section>
<!-- 标签管理 -->
<section id="sec-tags">
<p class="anno">// tree: tags · 拖到某个标签上 = 移为其子级;拖到下方空白处 = 移回顶层标签组</p>
<h1>标签管理</h1>
<div style="display:flex;gap:10px;margin-top:18px">
<button class="btn btn-primary btn-sm" type="button" data-action="tag-add">新增顶层标签组</button>
<a class="btn btn-sm" href="/entries">前台预览</a>
</div>
<div class="tagmgr">
<div class="panel-box">
<h3>标签树 <span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">(可直接拖动节点调整层级,变更即时生效)</span></h3>
<div class="tagmgr-tree" id="admin-tag-tree" style="min-height:120px">
<p class="mono dim">加载中…</p>
</div>
<p class="dnd-hint mono dim">按住节点拖动:落到某个标签上 = 变为它的子级 · 落到下方空白处 = 移回顶层</p>
</div>
<div class="panel-box">
<h3>规则说明</h3>
<p style="font-size:13.5px;color:var(--ink-dim)">
标签可无限嵌套,列表页按组过滤时会自动包含所有后代标签的条目。<br /><br />
拖动标签到另一标签上可快速调整层级;拖到空白处可移回顶层。<br />
删除父标签时其子标签自动上移一级,关联关系解除。
</p>
</div>
</div>
</section>
<!-- 评论管理 -->
<section id="sec-comments">
<p class="anno">// table: comments</p>
<h1>评论管理</h1>
<table class="data-table" style="margin-top:22px">
<thead><tr><th>评论者</th><th>内容</th><th>所属条目</th><th>时间</th><th style="text-align:right">操作</th></tr></thead>
<tbody>
<% for (const c of comments) { %>
<tr>
<td><b><%= c.author_name %></b><% if (c.username) { %> <span class="mono" style="font-size:11px;color:var(--ink-dim)">@<%= c.username %></span><% } %></td>
<td style="max-width:280px"><%= c.body.slice(0, 80) %><%= c.body.length > 80 ? '…' : '' %></td>
<td><a href="/entries/<%= c.entry_slug %>" target="_blank"><%= c.entry_title %></a></td>
<td class="mono"><%= fmtDate(c.created_at) %></td>
<td><div class="row-actions">
<% if (isAdmin) { %><button class="btn btn-sm btn-danger" type="button" data-action="comment-delete" data-id="<%= c.id %>">删除</button><% } %>
</div></td>
</tr>
<% } %>
</tbody>
</table>
</section>
<!-- 内容源 -->
<section id="sec-sources">
<p class="anno">// sources: human + ai + feed · 自动拉取经 ingest/ 采集层进入待核验队列</p>
<h1>内容源管理</h1>
<% for (const s of sources) { if (s.kind === 'human') continue; %>
<div class="panel-box" style="margin-top:20px">
<h3 style="display:flex;align-items:center;gap:10px">
<span class="dot <%= s.enabled ? '' : 'dot-off' %>"></span> <%= s.name %>
<span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">kind: <%= s.kind %> · 已收录 <%= Number(s.entry_count) %> 条</span>
</h3>
<% if (s.kind === 'ai') { %>
<div class="switchline">
AI 录入接口启用
<span class="mono"><%= s.enabled ? 'ON' : 'OFF' %></span>
</div>
<form id="ai-key-form" style="margin-top:12px">
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">设置新的 X-Ingest-Key(留空 = 关闭通道)</p>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:nowrap">
<div class="ai-key-field">
<input class="input" name="key" type="password" value="<%= s.api_key || '' %>" placeholder="sk-…" autocomplete="off" />
<button type="button" class="eye-toggle" data-eye-toggle aria-label="显示密钥">
<svg class="eye-open" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="eye-closed" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
</button>
</div>
<button class="btn btn-sm" type="submit" style="flex:none">保存密钥</button>
</div>
</form>
<p class="mono" style="margin-top:12px;font-size:12px;color:var(--ink-dim)">
调用方式:POST /api/ingest/entry · header X-Ingest-Key · 新条目默认 pending
</p>
<% } else { %>
<form class="source-config" data-id="<%= s.id %>">
<div class="switchline">
启用定时拉取
<label class="toggle"><input type="checkbox" name="enabled" <%= s.enabled ? 'checked' : '' %> /><i></i></label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px">
<div>
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">名称</p>
<input class="input" name="name" value="<%= s.name %>" />
</div>
<div>
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">拉取地址 / 计划(cron)</p>
<div style="display:flex;gap:8px">
<input class="input" name="url" value="<%= s.url %>" />
<input class="input mono" name="cron_expr" value="<%= s.cron_expr %>" style="max-width:130px" />
</div>
</div>
<div>
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">适配器</p>
<select class="select" name="adapter">
<% for (const a of ['rss', 'html', 'eryajf-weekly']) { %>
<option value="<%= a %>" <%= s.adapter === a ? 'selected' : '' %>><%= a %></option>
<% } %>
</select>
</div>
<div style="display:flex;align-items:flex-end;gap:8px">
<button class="btn btn-primary btn-sm" type="submit">保存配置</button>
<button class="btn btn-sm" type="button" data-source-run="<%= s.id %>">立即拉取</button>
<button class="btn btn-sm" type="button" data-source-runs="<%= s.id %>">运行记录</button>
</div>
</div>
</form>
<pre class="code-block mono" id="runs-<%= s.id %>" hidden style="margin-top:12px"></pre>
<% } %>
</div>
<% } %>
<div class="panel-box" style="margin-top:20px">
<h3>新增拉取源</h3>
<form id="source-add-form" style="display:grid;grid-template-columns:1fr 1fr auto auto;gap:10px;align-items:end">
<div><input class="input" name="name" placeholder="源名称,如:阮一峰周刊" required /></div>
<div><input class="input" name="url" placeholder="https://…" required /></div>
<select class="select" name="adapter">
<option value="rss">rss</option>
<option value="html">html</option>
<option value="eryajf-weekly">eryajf-weekly</option>
</select>
<button class="btn btn-primary btn-sm" type="submit">创建</button>
</form>
</div>
</section>
<!-- 账号管理 -->
<% if (isAdmin) { %>
<section id="sec-accounts">
<p class="anno">// table: accounts · role: admin | editor</p>
<h1>账号管理</h1>
<table class="data-table" style="margin-top:22px">
<thead><tr><th>ID</th><th>用户名</th><th>角色</th><th>状态</th><th>最后登录</th><th style="text-align:right">操作</th></tr></thead>
<tbody>
<% for (const u of users) { %>
<tr>
<td class="mono">#<%= u.id %></td>
<td><b><%= u.username %></b></td>
<td><%= u.role %></td>
<td><span class="status-pill <%= u.status === 'active' ? 'status-ok' : 'status-warn' %>"><%= u.status %></span></td>
<td class="mono"><%= u.last_login_at ? fmtDate(u.last_login_at) : '从未登录' %></td>
<td><div class="row-actions">
<% if (u.id !== user.id) { %>
<button class="btn btn-sm" type="button" data-action="user-disable" data-id="<%= u.id %>" data-status="<%= u.status %>">
<%= u.status === 'active' ? '停用' : '启用' %>
</button>
<% } %>
</div></td>
</tr>
<% } %>
</tbody>
</table>
<form id="user-add-form" style="display:flex;gap:10px;align-items:end;margin-top:18px;flex-wrap:wrap">
<div><p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">用户名</p><input class="input" name="username" required /></div>
<div><p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">初始密码</p><input class="input" name="password" type="password" required minlength="6" /></div>
<div>
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">角色</p>
<select class="select" name="role"><option value="editor">editor</option><option value="admin">admin</option></select>
</div>
<button class="btn btn-primary btn-sm" type="submit">新增账号</button>
</form>
</section>
<% } %>
</main>
</div>
<script>window.__ADMIN_TAG_TREE__ = <%- JSON.stringify(tagTreeAdmin) %>;</script>
<%- include('partials/footer-bar') %>