From 3f7b48325fbd84446415f3354f795b5d4910d14a Mon Sep 17 00:00:00 2001 From: cheney Date: Mon, 21 Sep 2026 11:15:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E5=8F=B0=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E7=8A=B6=E6=80=81=E8=BF=87=E6=BB=A4+=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E3=80=81=E6=89=B9=E9=87=8F=E4=B8=8A=E6=9E=B6/?= =?UTF-8?q?=E7=81=B0=E6=8E=89=E3=80=81=E6=A0=87=E7=AD=BE=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=B1=82=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/css/main.css | 84 +++++++++++++++ public/js/admin.js | 183 ++++++++++++++++++++++++++++++--- src/routes/admin.routes.js | 14 +++ src/routes/pages.routes.js | 24 ++++- src/services/entry.service.js | 19 ++++ src/services/search.service.js | 10 ++ src/views/admin.ejs | 62 ++++++++--- 7 files changed, 366 insertions(+), 30 deletions(-) diff --git a/public/css/main.css b/public/css/main.css index d097eca..35c7675 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -1532,6 +1532,90 @@ button.chip:hover { font-weight: 700; 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 { display: flex; align-items: center; diff --git a/public/js/admin.js b/public/js/admin.js index 041f1cc..df60c50 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -26,7 +26,13 @@ } 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) { @@ -42,14 +48,141 @@ }); }); - document.querySelectorAll(".tree-row[data-tree]").forEach(function (row) { - row.addEventListener("click", function (e) { - if (e.target.closest("button[data-tag-action]")) return; - var target = document.getElementById(row.getAttribute("data-tree")); - if (!target) return; - var open = target.classList.toggle("open"); - row.setAttribute("aria-expanded", open ? "true" : "false"); + /* ============ 标签树:客户端渲染 + 拖拽调整层级 ============ */ + var tagRoot = document.getElementById("admin-tag-tree"); + var tagDraggingId = null; + + function tagRowHtml(n) { + var kids = n.children && n.children.length; + var label = kids + ? '" + : '' + esc(n.name) + ""; + return ( + "
  • " + + '
    ' + + label + + '' + n.count + " · #" + n.id + "" + + '' + + '' + + '' + + '' + + '' + + "" + + "
    " + + (kids ? '" : "") + + "
  • " + ); + } + + function renderTagAdminTree(tree) { + if (!tagRoot) return; + tagRoot.innerHTML = tree && tree.length + ? '" + : '

    还没有标签。

    '; + } + + 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) { @@ -63,28 +196,32 @@ var name = prompt(parentId ? "新增子标签名称:" : "新增顶层标签组名称:"); if (!name) return; await api("/api/tags", { name: name, parentId: parentId ? Number(parentId) : null }); - toast("标签已创建"); refresh(); + await refreshTags(); + toast("标签已创建"); } if (action === "tag-rename") { var name2 = prompt("重命名为:", btn.getAttribute("data-name")); if (!name2 || name2 === btn.getAttribute("data-name")) return; await api("/api/tags/" + btn.getAttribute("data-id"), { name: name2 }, "PATCH"); - toast("已重命名"); refresh(); + await refreshTags(); + toast("已重命名"); } if (action === "tag-move") { - var target3 = prompt("移动到哪个标签之下?填其 ID(留空移到顶层):\n" + btn.getAttribute("data-ids")); + var target3 = prompt("移动到哪个标签之下?填其 ID(留空移到顶层):"); if (target3 === null) return; await api("/api/tags/" + btn.getAttribute("data-id"), { parentId: target3.trim() ? Number(target3.trim()) : null }, "PATCH"); - toast("已移动"); refresh(); + await refreshTags(); + toast("已移动"); } if (action === "tag-delete") { if (!confirm("删除该标签?其子标签将上移一级,关联关系解除。")) return; await api("/api/tags/" + btn.getAttribute("data-id"), undefined, "DELETE"); - toast("已删除"); refresh(); + await refreshTags(); + toast("已删除"); } if (action === "entry-status") { @@ -99,6 +236,24 @@ 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 (!confirm("删除该评论?")) return; await api("/api/comments/" + btn.getAttribute("data-id"), undefined, "DELETE"); @@ -203,4 +358,4 @@ .catch(function (err) { toast(err.message); }); }); } -})(); +})(); \ No newline at end of file diff --git a/src/routes/admin.routes.js b/src/routes/admin.routes.js index 4583395..2e3595d 100644 --- a/src/routes/admin.routes.js +++ b/src/routes/admin.routes.js @@ -2,6 +2,7 @@ import { Router } from "express"; import * as userService from "../services/user.service.js"; import * as sourceService from "../services/source.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 audit from "../services/audit.service.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 }); }); + +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 } }); +}); diff --git a/src/routes/pages.routes.js b/src/routes/pages.routes.js index e32f34c..d772d1c 100644 --- a/src/routes/pages.routes.js +++ b/src/routes/pages.routes.js @@ -73,7 +73,8 @@ pagesRouter.get("/entries", async (req, res, next) => { pagesRouter.get("/entries/:slug", async (req, res, next) => { try { 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("条目不存在或已下架"); } const comments = await commentService.listForEntry(entry.id); @@ -119,12 +120,25 @@ pagesRouter.get("/admin", async (req, res, next) => { try { if (!req.session.userId) return res.redirect("/login"); 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(), isAdmin ? userService.listUsers() : Promise.resolve([]), commentService.listAll({}), - searchService.searchEntries({ status: "all", perPage: 50 }), + searchService.searchEntries({ status, perPage, page }), tagService.attachCounts(await tagService.buildTree()), + searchService.statusCounts(), ]); res.render("admin", { title: "管理台 · Awesome Index", @@ -133,6 +147,10 @@ pagesRouter.get("/admin", async (req, res, next) => { users, comments, entries: entries.items, + status, + statusCounts, + page, + totalPages: Math.max(1, Math.ceil(entries.total / entries.perPage)), isAdmin, tagTreeAdmin, }); diff --git a/src/services/entry.service.js b/src/services/entry.service.js index 901ae6b..ebdd871 100644 --- a/src/services/entry.service.js +++ b/src/services/entry.service.js @@ -158,6 +158,25 @@ export async function setStatusDirect(id, status) { 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) { await run(`DELETE FROM entry_tags WHERE entry_id = ?`, [id]); await run(`DELETE FROM comments WHERE entry_id = ?`, [id]); diff --git a/src/services/search.service.js b/src/services/search.service.js index 454837a..9623e5e 100644 --- a/src/services/search.service.js +++ b/src/services/search.service.js @@ -90,6 +90,16 @@ export async function typeCounts() { 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() { return get(`SELECT * FROM entries WHERE status = 'active' ORDER BY random() LIMIT 1`); } diff --git a/src/views/admin.ejs b/src/views/admin.ejs index b9405b3..0d688ba 100644 --- a/src/views/admin.ejs +++ b/src/views/admin.ejs @@ -16,14 +16,44 @@
    -

    // table: entries · 状态 active | greyed | pending

    +

    // table: entries · 状态 active | greyed | pending · 预览 = 按当前状态模拟前台展示

    内容管理

    全部收录条目。灰掉 = 暂不可见但保留数据;删除为物理删除,需确认。

    - - - - <% for (const e of entries) { %> + + + +
    + + + + 已选 0 项 +
    + +
    标题类型状态更新时间操作
    + + + + + + + + + + + <% if (entries.length === 0) { %> + + <% } %> + <% for (const e of entries) { %> + +
    标题类型状态更新时间操作
    当前筛选下没有条目
    <%= e.title %>
    <%= (e.tags || []).join(' · ') || '—' %> @@ -38,6 +68,7 @@
    编辑 + 预览 <% if (e.status !== 'active') { %> <% } else { %> @@ -52,11 +83,15 @@ <% } %>
    +
    -

    // tree: tags · 删除父级时子级自动上移;移动到自己的子级会被拒绝

    +

    // tree: tags · 拖到某个标签上 = 移为其子级;拖到下方空白处 = 移回顶层标签组

    标签管理

    @@ -64,17 +99,18 @@
    -

    标签树 (#ID 用于「移动」时填写目标)

    -
      - <% for (const root of tagTreeAdmin) { %><%- include('partials/tag-node', { node: root }) %><% } %> -
    +

    标签树 (可直接拖动节点调整层级,变更即时生效)

    +
    +

    加载中…

    +
    +

    按住节点拖动:落到某个标签上 = 变为它的子级 · 落到下方空白处 = 移回顶层

    规则说明

    - 标签可无限嵌套,列表页按组过滤时会自动包含所有后代标签的条目。 - 「移动」填写目标标签 ID;移动到自身子级会被拒绝以防止成环。 - 删除父标签时其子标签自动上移一级。 + 标签可无限嵌套,列表页按组过滤时会自动包含所有后代标签的条目。

    + 拖动标签到另一标签上可快速调整层级;拖到空白处可移回顶层。
    + 删除父标签时其子标签自动上移一级,关联关系解除。