269 lines
15 KiB
Plaintext
269 lines
15 KiB
Plaintext
<%- 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') %>
|