feat: 管理台内容状态过滤+预览、批量上架/灰掉、标签拖拽调整层级
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
+169
-14
@@ -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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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");
|
||||||
@@ -203,4 +358,4 @@
|
|||||||
.catch(function (err) { toast(err.message); });
|
.catch(function (err) { toast(err.message); });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -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 } });
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user