feat: 管理台内容状态过滤+预览、批量上架/灰掉、标签拖拽调整层级
This commit is contained in:
@@ -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;
|
||||
|
||||
+169
-14
@@ -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
|
||||
? '<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) {
|
||||
@@ -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); });
|
||||
});
|
||||
}
|
||||
})();
|
||||
})();
|
||||
@@ -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 } });
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
+49
-13
@@ -16,14 +16,44 @@
|
||||
<main class="admin-main">
|
||||
<!-- 内容管理 -->
|
||||
<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>
|
||||
<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) { %>
|
||||
|
||||
<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>
|
||||
@@ -38,6 +68,7 @@
|
||||
<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 { %>
|
||||
@@ -52,11 +83,15 @@
|
||||
<% } %>
|
||||
</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>
|
||||
<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>
|
||||
@@ -64,17 +99,18 @@
|
||||
</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>
|
||||
<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)">
|
||||
标签可无限嵌套,列表页按组过滤时会自动包含所有后代标签的条目。
|
||||
「移动」填写目标标签 ID;移动到自身子级会被拒绝以防止成环。
|
||||
删除父标签时其子标签自动上移一级。
|
||||
标签可无限嵌套,列表页按组过滤时会自动包含所有后代标签的条目。<br /><br />
|
||||
拖动标签到另一标签上可快速调整层级;拖到空白处可移回顶层。<br />
|
||||
删除父标签时其子标签自动上移一级,关联关系解除。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user