feat: awesome index 全功能实现

- M1-M2: 数据层与核心API(DuckDB, entries/tags/comments/search服务)
- M3: SSR页面(EJS模板,接真实数据)
- M4: 管理后台(登录/用户/内容源/AI ingest)
- M5: 采集层(scheduler+rss/html/eryajf适配器)
- M6: MCP只读接口(Streamable HTTP, 4工具)
- M7: Docker部署(Dockerfile+docker-compose)
- 测试: 10个用例全部通过
- 修复: DuckDB连接参数绑定、状态机迁移、标签树操作
This commit is contained in:
cheney
2026-08-28 09:32:48 +08:00
parent c47bb15d8c
commit dec103546e
51 changed files with 7298 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
<%- 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>
<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 e of entries) { %>
<tr>
<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">
<% 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>
</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)">(#ID 用于「移动」时填写目标)</span></h3>
<ul class="tag-tree">
<% for (const root of tagTreeAdmin) { %><%- include('partials/tag-node', { node: root }) %><% } %>
</ul>
</div>
<div class="panel-box">
<h3>规则说明</h3>
<p style="font-size:13.5px;color:var(--ink-dim)">
标签可无限嵌套,列表页按组过滤时会自动包含所有后代标签的条目。
「移动」填写目标标签 ID;移动到自身子级会被拒绝以防止成环。
删除父标签时其子标签自动上移一级。
</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) { %>
<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 === 'human') { %>
<p style="font-size:13.5px;color:var(--ink-dim)">管理员与编辑在条目接口直接写入,始终可用,改动写入审计日志。</p>
<% } else if (s.kind === 'ai') { %>
<div class="switchline">
AI 录入接口启用
<span class="mono"><%= s.enabled ? 'ON' : 'OFF' %></span>
</div>
<form id="ai-key-form">
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">设置新的 X-Ingest-Key(留空 = 关闭通道)</p>
<div style="display:flex;gap:10px;max-width:420px">
<input class="input" name="key" type="text" placeholder="sk-…" />
<button class="btn btn-sm" type="submit">保存密钥</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">
启用定时拉取
<input type="checkbox" name="enabled" <%= s.enabled ? 'checked' : '' %> style="accent-color:var(--riso-blue)" />
</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') %>