feat: 管理台内容状态过滤+预览、批量上架/灰掉、标签拖拽调整层级
Build and Deploy / build (push) Successful in 1m37s
Build and Deploy / deploy (push) Successful in 31s

This commit is contained in:
cheney
2026-09-21 11:15:26 +08:00
parent 7e62db31b8
commit 3f7b48325f
7 changed files with 366 additions and 30 deletions
+84
View File
@@ -1532,6 +1532,90 @@ button.chip:hover {
font-weight: 700; font-weight: 700;
margin-bottom: 14px; margin-bottom: 14px;
} }
/* ---------- 管理台:内容筛选 / 批量 / 分页 ---------- */
.status-chips {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-top: 18px;
}
.status-chips .spacer {
flex: 1;
}
.status-chips .page-hint {
font-size: 12px;
color: var(--ink-dim);
}
.batch-bar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-top: 18px;
}
.batch-bar .batch-count {
font-size: 12px;
color: var(--ink-dim);
}
.data-table .entry-check,
.data-table .entry-check-all {
accent-color: var(--riso-blue);
width: 15px;
height: 15px;
cursor: pointer;
}
.pager {
display: flex;
gap: 10px;
margin-top: 16px;
}
.btn-disabled {
opacity: 0.45;
pointer-events: none;
}
/* ---------- 管理台:标签树 拖拽 ---------- */
.tagmgr-tree {
border: 1.5px dashed var(--ink-faint);
border-radius: var(--r-sm);
padding: 12px;
transition:
background 0.15s ease,
box-shadow 0.15s ease;
}
.tagmgr-tree .tag-tree {
font-size: 13.5px;
}
.tagmgr-tree .tag-node {
cursor: grab;
}
.tagmgr-tree .tag-node:active {
cursor: grabbing;
}
.tagmgr-tree .tag-node.drag-src {
opacity: 0.45;
}
.tagmgr-tree .tag-node.drag-over {
background: var(--riso-blue-soft);
box-shadow: inset 0 0 0 1.5px var(--riso-blue);
}
.tagmgr-tree.drag-over {
background: var(--riso-blue-soft);
box-shadow: inset 0 0 0 1.5px var(--riso-blue);
}
.tagmgr-tree .nameleaf {
flex: 1;
padding-left: 17px;
color: var(--ink);
}
.tagmgr-tree .toggler {
flex: 1;
font: inherit;
}
.dnd-hint {
font-size: 12px;
margin-top: 12px;
}
.switchline { .switchline {
display: flex; display: flex;
align-items: center; align-items: center;
+168 -13
View File
@@ -26,7 +26,13 @@
} }
function refresh() { function refresh() {
setTimeout(function () { location.reload(); }, 500); location.reload();
}
function esc(v) {
return String(v == null ? "" : v).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
} }
document.querySelectorAll(".admin-nav button[data-section]").forEach(function (btn) { document.querySelectorAll(".admin-nav button[data-section]").forEach(function (btn) {
@@ -42,14 +48,141 @@
}); });
}); });
document.querySelectorAll(".tree-row[data-tree]").forEach(function (row) { /* ============ 标签树:客户端渲染 + 拖拽调整层级 ============ */
row.addEventListener("click", function (e) { var tagRoot = document.getElementById("admin-tag-tree");
if (e.target.closest("button[data-tag-action]")) return; var tagDraggingId = null;
var target = document.getElementById(row.getAttribute("data-tree"));
if (!target) return; function tagRowHtml(n) {
var open = target.classList.toggle("open"); var kids = n.children && n.children.length;
row.setAttribute("aria-expanded", open ? "true" : "false"); var label = kids
? '<button type="button" class="tree-row toggler" aria-expanded="true"><span class="tw">▶</span> <span class="name">' + esc(n.name) + "</span></button>"
: '<span class="nameleaf"><span class="name">' + esc(n.name) + "</span></span>";
return (
"<li>" +
'<div class="tree-row tag-node" draggable="true" data-id="' + n.id + '" data-drop-id="' + n.id + '">' +
label +
'<span class="mono cnt">' + n.count + " · #" + n.id + "</span>" +
'<span class="row-actions" style="margin-left:8px">' +
'<button class="btn btn-sm" type="button" data-action="tag-add" data-parent-id="' + n.id + '">+子级</button>' +
'<button class="btn btn-sm" type="button" data-action="tag-rename" data-id="' + n.id + '" data-name="' + esc(n.name) + '">改名</button>' +
'<button class="btn btn-sm" type="button" data-action="tag-move" data-id="' + n.id + '">移动</button>' +
'<button class="btn btn-sm btn-danger" type="button" data-action="tag-delete" data-id="' + n.id + '">删</button>' +
"</span>" +
"</div>" +
(kids ? '<ul class="tag-children open">' + n.children.map(tagRowHtml).join("") + "</ul>" : "") +
"</li>"
);
}
function renderTagAdminTree(tree) {
if (!tagRoot) return;
tagRoot.innerHTML = tree && tree.length
? '<ul class="tag-tree">' + tree.map(tagRowHtml).join("") + "</ul>"
: '<p class="mono dim">还没有标签。</p>';
}
async function refreshTags() {
if (!tagRoot) return;
var json = await api("/api/tags/tree", undefined, "GET");
renderTagAdminTree(json.data);
}
function clearTagDnd() {
tagDraggingId = null;
if (!tagRoot) return;
tagRoot.classList.remove("drag-over");
tagRoot.querySelectorAll(".drag-src,.drag-over").forEach(function (el) {
el.classList.remove("drag-src", "drag-over");
}); });
}
if (tagRoot) {
if (Array.isArray(window.__ADMIN_TAG_TREE__)) renderTagAdminTree(window.__ADMIN_TAG_TREE__);
else refreshTags();
tagRoot.addEventListener("click", function (e) {
var tog = e.target.closest(".toggler");
if (!tog) return;
var li = tog.closest("li");
var ul = null;
if (li) {
for (var i = 0; i < li.children.length; i++) {
if (li.children[i].tagName === "UL") ul = li.children[i];
}
}
if (!ul) return;
var open = ul.classList.toggle("open");
tog.setAttribute("aria-expanded", open ? "true" : "false");
});
tagRoot.addEventListener("dragstart", function (e) {
if (e.target.closest("button")) return;
var row = e.target.closest(".tag-node");
if (!row) return;
tagDraggingId = row.getAttribute("data-id");
e.dataTransfer.setData("text/plain", tagDraggingId);
e.dataTransfer.effectAllowed = "move";
row.classList.add("drag-src");
});
tagRoot.addEventListener("dragend", clearTagDnd);
tagRoot.addEventListener("dragover", function (e) {
if (!tagDraggingId) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
var row = e.target.closest(".tag-node[data-drop-id]");
tagRoot.querySelectorAll(".drag-over").forEach(function (el) {
el.classList.remove("drag-over");
});
if (row) row.classList.add("drag-over");
else tagRoot.classList.add("drag-over");
});
tagRoot.addEventListener("dragleave", function (e) {
if (e.target === tagRoot && !e.relatedTarget) tagRoot.classList.remove("drag-over");
});
tagRoot.addEventListener("drop", async function (e) {
var id = tagDraggingId;
var row = e.target.closest(".tag-node[data-drop-id]");
var dropTarget = row ? row.getAttribute("data-drop-id") : null;
clearTagDnd();
if (!id || dropTarget === id) return;
try {
await api("/api/tags/" + id, { parentId: dropTarget ? Number(dropTarget) : null }, "PATCH");
toast(dropTarget ? "已移动到目标标签下" : "已移回顶层");
await refreshTags();
} catch (err) {
toast(err.message);
}
});
}
/* ============ 批量选择 / 批量状态 ============ */
function selectedEntryIds() {
var ids = [];
document.querySelectorAll(".entry-check:checked").forEach(function (c) {
ids.push(Number(c.getAttribute("data-id")));
});
return ids;
}
function updateBatchCount() {
var el = document.getElementById("batch-count");
if (el) el.textContent = "已选 " + selectedEntryIds().length + " 项";
}
document.addEventListener("change", function (e) {
var ck = e.target;
if (ck.classList && ck.classList.contains("entry-check-all")) {
document.querySelectorAll(".entry-check").forEach(function (c) {
c.checked = ck.checked;
});
updateBatchCount();
} else if (ck.classList && ck.classList.contains("entry-check")) {
updateBatchCount();
}
}); });
document.addEventListener("click", async function (e) { document.addEventListener("click", async function (e) {
@@ -63,28 +196,32 @@
var name = prompt(parentId ? "新增子标签名称:" : "新增顶层标签组名称:"); var name = prompt(parentId ? "新增子标签名称:" : "新增顶层标签组名称:");
if (!name) return; if (!name) return;
await api("/api/tags", { name: name, parentId: parentId ? Number(parentId) : null }); await api("/api/tags", { name: name, parentId: parentId ? Number(parentId) : null });
toast("标签已创建"); refresh(); await refreshTags();
toast("标签已创建");
} }
if (action === "tag-rename") { if (action === "tag-rename") {
var name2 = prompt("重命名为:", btn.getAttribute("data-name")); var name2 = prompt("重命名为:", btn.getAttribute("data-name"));
if (!name2 || name2 === btn.getAttribute("data-name")) return; if (!name2 || name2 === btn.getAttribute("data-name")) return;
await api("/api/tags/" + btn.getAttribute("data-id"), { name: name2 }, "PATCH"); await api("/api/tags/" + btn.getAttribute("data-id"), { name: name2 }, "PATCH");
toast("已重命名"); refresh(); await refreshTags();
toast("已重命名");
} }
if (action === "tag-move") { if (action === "tag-move") {
var target3 = prompt("移动到哪个标签之下?填其 ID(留空移到顶层):\n" + btn.getAttribute("data-ids")); var target3 = prompt("移动到哪个标签之下?填其 ID(留空移到顶层):");
if (target3 === null) return; if (target3 === null) return;
await api("/api/tags/" + btn.getAttribute("data-id"), await api("/api/tags/" + btn.getAttribute("data-id"),
{ parentId: target3.trim() ? Number(target3.trim()) : null }, "PATCH"); { parentId: target3.trim() ? Number(target3.trim()) : null }, "PATCH");
toast("已移动"); refresh(); await refreshTags();
toast("已移动");
} }
if (action === "tag-delete") { if (action === "tag-delete") {
if (!confirm("删除该标签?其子标签将上移一级,关联关系解除。")) return; if (!confirm("删除该标签?其子标签将上移一级,关联关系解除。")) return;
await api("/api/tags/" + btn.getAttribute("data-id"), undefined, "DELETE"); await api("/api/tags/" + btn.getAttribute("data-id"), undefined, "DELETE");
toast("已删除"); refresh(); await refreshTags();
toast("已删除");
} }
if (action === "entry-status") { if (action === "entry-status") {
@@ -99,6 +236,24 @@
toast("已删除"); refresh(); toast("已删除"); refresh();
} }
if (action === "entry-select-all") {
var checks = Array.prototype.slice.call(document.querySelectorAll(".entry-check"));
var allOn = checks.length > 0 && checks.every(function (c) { return c.checked; });
checks.forEach(function (c) { c.checked = !allOn; });
var allEl = document.querySelector(".entry-check-all");
if (allEl) allEl.checked = !allOn;
updateBatchCount();
}
if (action === "entry-batch") {
var ids = selectedEntryIds();
if (!ids.length) { toast("请先勾选条目"); return; }
var status = btn.getAttribute("data-status");
await api("/api/admin/entries/status", { ids: ids, status: status }, "POST");
toast(status === "active" ? "已上架 " + ids.length + " 条" : "已灰掉 " + ids.length + " 条");
refresh();
}
if (action === "comment-delete") { if (action === "comment-delete") {
if (!confirm("删除该评论?")) return; if (!confirm("删除该评论?")) return;
await api("/api/comments/" + btn.getAttribute("data-id"), undefined, "DELETE"); await api("/api/comments/" + btn.getAttribute("data-id"), undefined, "DELETE");
+14
View File
@@ -2,6 +2,7 @@ import { Router } from "express";
import * as userService from "../services/user.service.js"; import * as userService from "../services/user.service.js";
import * as sourceService from "../services/source.service.js"; import * as sourceService from "../services/source.service.js";
import * as commentService from "../services/comment.service.js"; import * as commentService from "../services/comment.service.js";
import * as entryService from "../services/entry.service.js";
import * as searchService from "../services/search.service.js"; import * as searchService from "../services/search.service.js";
import * as audit from "../services/audit.service.js"; import * as audit from "../services/audit.service.js";
import { requireAdmin } from "../middleware/auth.js"; import { requireAdmin } from "../middleware/auth.js";
@@ -134,3 +135,16 @@ adminRouter.get("/api/admin/entries", requireAdmin, async (req, res) => {
}); });
res.json({ ok: true, data: result }); res.json({ ok: true, data: result });
}); });
adminRouter.post("/api/admin/entries/status", requireAdmin, async (req, res) => {
const ids = Array.isArray(req.body.ids) ? req.body.ids : [];
const status = String(req.body.status || "");
const updated = await entryService.setStatusBulk(ids, status);
await audit.log({
actor: actor(req),
action: "entries.status",
target: "",
detail: `${status} × ${updated}`,
});
res.json({ ok: true, data: { updated } });
});
+21 -3
View File
@@ -73,7 +73,8 @@ pagesRouter.get("/entries", async (req, res, next) => {
pagesRouter.get("/entries/:slug", async (req, res, next) => { pagesRouter.get("/entries/:slug", async (req, res, next) => {
try { try {
const entry = await entryService.getBySlug(req.params.slug); const entry = await entryService.getBySlug(req.params.slug);
if (!entry || entry.status === "greyed") { const preview = req.query.preview === "1" && Boolean(req.session.userId);
if (!entry || (entry.status === "greyed" && !preview)) {
return res.status(404).send("条目不存在或已下架"); return res.status(404).send("条目不存在或已下架");
} }
const comments = await commentService.listForEntry(entry.id); const comments = await commentService.listForEntry(entry.id);
@@ -119,12 +120,25 @@ pagesRouter.get("/admin", async (req, res, next) => {
try { try {
if (!req.session.userId) return res.redirect("/login"); if (!req.session.userId) return res.redirect("/login");
const isAdmin = req.session.role === "admin"; const isAdmin = req.session.role === "admin";
const [sources, users, comments, entries, tagTreeAdmin] = await Promise.all([ const status = ["active", "greyed", "pending", "all"].includes(req.query.status)
? req.query.status
: "all";
const page = Math.max(1, Number(req.query.page) || 1);
const perPage = 50;
const [
sources,
users,
comments,
entries,
tagTreeAdmin,
statusCounts,
] = await Promise.all([
sourceService.listSources(), sourceService.listSources(),
isAdmin ? userService.listUsers() : Promise.resolve([]), isAdmin ? userService.listUsers() : Promise.resolve([]),
commentService.listAll({}), commentService.listAll({}),
searchService.searchEntries({ status: "all", perPage: 50 }), searchService.searchEntries({ status, perPage, page }),
tagService.attachCounts(await tagService.buildTree()), tagService.attachCounts(await tagService.buildTree()),
searchService.statusCounts(),
]); ]);
res.render("admin", { res.render("admin", {
title: "管理台 · Awesome Index", title: "管理台 · Awesome Index",
@@ -133,6 +147,10 @@ pagesRouter.get("/admin", async (req, res, next) => {
users, users,
comments, comments,
entries: entries.items, entries: entries.items,
status,
statusCounts,
page,
totalPages: Math.max(1, Math.ceil(entries.total / entries.perPage)),
isAdmin, isAdmin,
tagTreeAdmin, tagTreeAdmin,
}); });
+19
View File
@@ -158,6 +158,25 @@ export async function setStatusDirect(id, status) {
return getById(id); return getById(id);
} }
export async function setStatusBulk(ids, status) {
if (!STATUSES.includes(status)) throw httpError(400, `未知状态:${status}`);
const clean = [...new Set(ids.map(Number).filter(Number.isFinite))];
if (!clean.length) throw httpError(400, "没有选中条目");
const existing = await all(
`SELECT id FROM entries WHERE id IN (${clean.map(() => "?").join(",")})`,
clean
);
const realIds = existing.map((r) => Number(r.id));
if (!realIds.length) throw httpError(400, "没有选中条目");
await run(
`UPDATE entries SET status = ?, updated_at = now(),
verified_at = CASE WHEN ? = 'active' THEN now() ELSE verified_at END
WHERE id IN (${realIds.map(() => "?").join(",")})`,
[status, status, ...realIds]
);
return realIds.length;
}
export async function deleteEntry(id) { export async function deleteEntry(id) {
await run(`DELETE FROM entry_tags WHERE entry_id = ?`, [id]); await run(`DELETE FROM entry_tags WHERE entry_id = ?`, [id]);
await run(`DELETE FROM comments WHERE entry_id = ?`, [id]); await run(`DELETE FROM comments WHERE entry_id = ?`, [id]);
+10
View File
@@ -90,6 +90,16 @@ export async function typeCounts() {
return map; return map;
} }
export async function statusCounts() {
const rows = await all(`SELECT status, COUNT(*) AS c FROM entries GROUP BY status`);
const map = { all: 0, active: 0, pending: 0, greyed: 0 };
for (const r of rows) {
map[r.status] = Number(r.c);
map.all += Number(r.c);
}
return map;
}
export async function randomActive() { export async function randomActive() {
return get(`SELECT * FROM entries WHERE status = 'active' ORDER BY random() LIMIT 1`); return get(`SELECT * FROM entries WHERE status = 'active' ORDER BY random() LIMIT 1`);
} }
+49 -13
View File
@@ -16,14 +16,44 @@
<main class="admin-main"> <main class="admin-main">
<!-- 内容管理 --> <!-- 内容管理 -->
<section id="sec-content" class="active"> <section id="sec-content" class="active">
<p class="anno">// table: entries · 状态 active | greyed | pending</p> <p class="anno">// table: entries · 状态 active | greyed | pending · 预览 = 按当前状态模拟前台展示</p>
<h1>内容管理</h1> <h1>内容管理</h1>
<p class="admin-desc">全部收录条目。灰掉 = 暂不可见但保留数据;删除为物理删除,需确认。</p> <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> <div class="status-chips">
<tbody> <a class="chip <%= status === 'all' ? 'is-active' : '' %>" href="/admin?status=all">全部 <span class="mono"><%= statusCounts.all %></span></a>
<% for (const e of entries) { %> <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> <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> <td>
<b><%= e.title %></b><br /> <b><%= e.title %></b><br />
<span class="mono" style="font-size:11px;color:var(--ink-dim)"><%= (e.tags || []).join(' · ') || '—' %></span> <span class="mono" style="font-size:11px;color:var(--ink-dim)"><%= (e.tags || []).join(' · ') || '—' %></span>
@@ -38,6 +68,7 @@
<td> <td>
<div class="row-actions"> <div class="row-actions">
<a class="btn btn-sm" href="/admin/entries/<%= e.id %>/edit">编辑</a> <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') { %> <% if (e.status !== 'active') { %>
<button class="btn btn-sm" type="button" data-action="entry-status" data-id="<%= e.id %>" data-status="active">上架</button> <button class="btn btn-sm" type="button" data-action="entry-status" data-id="<%= e.id %>" data-status="active">上架</button>
<% } else { %> <% } else { %>
@@ -52,11 +83,15 @@
<% } %> <% } %>
</tbody> </tbody>
</table> </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>
<!-- 标签管理 --> <!-- 标签管理 -->
<section id="sec-tags"> <section id="sec-tags">
<p class="anno">// tree: tags · 删除父级时子级自动上移;移动到自己的子级会被拒绝</p> <p class="anno">// tree: tags · 拖到某个标签上 = 移为其子级;拖到下方空白处 = 移回顶层标签组</p>
<h1>标签管理</h1> <h1>标签管理</h1>
<div style="display:flex;gap:10px;margin-top:18px"> <div style="display:flex;gap:10px;margin-top:18px">
<button class="btn btn-primary btn-sm" type="button" data-action="tag-add">新增顶层标签组</button> <button class="btn btn-primary btn-sm" type="button" data-action="tag-add">新增顶层标签组</button>
@@ -64,17 +99,18 @@
</div> </div>
<div class="tagmgr"> <div class="tagmgr">
<div class="panel-box"> <div class="panel-box">
<h3>标签树 <span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">(#ID 用于「移动」时填写目标)</span></h3> <h3>标签树 <span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">(可直接拖动节点调整层级,变更即时生效)</span></h3>
<ul class="tag-tree"> <div class="tagmgr-tree" id="admin-tag-tree" style="min-height:120px">
<% for (const root of tagTreeAdmin) { %><%- include('partials/tag-node', { node: root }) %><% } %> <p class="mono dim">加载中…</p>
</ul> </div>
<p class="dnd-hint mono dim">按住节点拖动:落到某个标签上 = 变为它的子级 · 落到下方空白处 = 移回顶层</p>
</div> </div>
<div class="panel-box"> <div class="panel-box">
<h3>规则说明</h3> <h3>规则说明</h3>
<p style="font-size:13.5px;color:var(--ink-dim)"> <p style="font-size:13.5px;color:var(--ink-dim)">
标签可无限嵌套,列表页按组过滤时会自动包含所有后代标签的条目。 标签可无限嵌套,列表页按组过滤时会自动包含所有后代标签的条目。<br /><br />
「移动」填写目标标签 ID;移动到自身子级会被拒绝以防止成环。 拖动标签到另一标签上可快速调整层级;拖到空白处可移回顶层。<br />
删除父标签时其子标签自动上移一级。 删除父标签时其子标签自动上移一级,关联关系解除。
</p> </p>
</div> </div>
</div> </div>