NewHome/admin/nav.php

561 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* admin/nav.php —— 导航管理(块/链接增删改查 + 顺序)
* 完整重同步保存:提交的 JSON 中不存在的老块/老链接将被删除。
*/
$P = '../';
require_once __DIR__ . '/_guard.php';
$pdo = db();
$catKeys = ['popular', 'red', 'blue'];
$cat = isset($_GET['c']) ? (string) $_GET['c'] : 'popular';
if (!in_array($cat, $catKeys, true)) {
$cat = 'popular';
}
$stCat = $pdo->prepare('SELECT * FROM categories WHERE key = ? LIMIT 1');
$stCat->execute([$cat]);
$catRow = $stCat->fetch();
if (!$catRow) {
http_response_code(404);
die('分类不存在');
}
$saved = isset($_GET['ok']);
$errorMsg = '';
// ---------- 保存处理 ----------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save') {
if (!csrf_verify()) {
$errorMsg = '安全校验失败,请刷新页面重试。';
} else {
$raw = (string) ($_POST['data'] ?? '');
$data = json_decode($raw, true);
if (!is_array($data)) {
$errorMsg = '提交数据格式错误。';
} else {
try {
$pdo->beginTransaction();
// 现有块
$st = $pdo->prepare('SELECT id FROM nav_blocks WHERE cat_id = ?');
$st->execute([(int) $catRow['id']]);
$oldBlocks = array_map('intval', array_column($st->fetchAll(), 'id'));
// 现有链接(按块分组)
$oldItemsByBlock = [];
foreach ($oldBlocks as $bid) {
$st = $pdo->prepare('SELECT id FROM nav_items WHERE block_id = ?');
$st->execute([$bid]);
$oldItemsByBlock[$bid] = array_map('intval', array_column($st->fetchAll(), 'id'));
}
// 本轮保留的块 id
$keepBlocks = [];
$keepItems = [];
foreach ($data as $b) {
if (is_array($b) && isset($b['title'])) {
$id = (int) ($b['id'] ?? 0);
if ($id > 0)
$keepBlocks[] = $id;
foreach (($b['items'] ?? []) as $it) {
if (is_array($it) && (int) ($it['id'] ?? 0) > 0) {
$keepItems[] = (int) $it['id'];
}
}
}
}
// 删除被移除的块及其链接
$delBlocks = array_values(array_diff($oldBlocks, $keepBlocks));
foreach ($delBlocks as $dbid) {
$pdo->prepare('DELETE FROM nav_items WHERE block_id = ?')->execute([$dbid]);
$pdo->prepare('DELETE FROM nav_blocks WHERE id = ?')->execute([$dbid]);
unset($oldItemsByBlock[$dbid]);
}
// 删除被移除的链接
foreach ($oldItemsByBlock as $bid => $ids) {
$del = array_values(array_diff($ids, $keepItems));
foreach ($del as $did) {
$pdo->prepare('DELETE FROM nav_items WHERE id = ?')->execute([$did]);
}
}
// 写回块与链接
$stInsBlock = $pdo->prepare('INSERT INTO nav_blocks (cat_id, title, sort) VALUES (?,?,?)');
$stUpdBlock = $pdo->prepare('UPDATE nav_blocks SET title = ?, sort = ? WHERE id = ?');
$stInsItem = $pdo->prepare('INSERT INTO nav_items (block_id, label, url, note, sort) VALUES (?,?,?,?,?)');
$stUpdItem = $pdo->prepare('UPDATE nav_items SET label = ?, url = ?, note = ?, sort = ? WHERE id = ? AND block_id = ?');
$blockSort = 0;
foreach ($data as $b) {
if (!is_array($b) || !isset($b['title']))
continue;
$title = trim((string) $b['title']);
if ($title === '')
continue;
$bid = (int) ($b['id'] ?? 0);
$blockBelongs = in_array($bid, $oldBlocks, true);
if ($bid > 0 && $blockBelongs) {
$stUpdBlock->execute([$title, $blockSort, $bid]);
} else {
$stInsBlock->execute([(int) $catRow['id'], $title, $blockSort]);
$bid = (int) $pdo->lastInsertId();
}
$itemSort = 0;
foreach (($b['items'] ?? []) as $it) {
if (!is_array($it))
continue;
$label = trim((string) ($it['label'] ?? ''));
$url = trim((string) ($it['url'] ?? ''));
$note = trim((string) ($it['note'] ?? ''));
if ($label === '' && $url === '')
continue;
$iid = (int) ($it['id'] ?? 0);
if ($iid > 0 && in_array($iid, $oldItemsByBlock[$bid] ?? [], true)) {
$stUpdItem->execute([$label, $url, $note, $itemSort, $iid, $bid]);
} else {
$stInsItem->execute([$bid, $label, $url, $note, $itemSort]);
}
$itemSort++;
}
$blockSort++;
}
$pdo->commit();
touch_last_updated();
header('Location: nav.php?c=' . urlencode($cat) . '&ok=1');
exit;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$errorMsg = '保存失败:' . $e->getMessage();
}
}
}
}
// ---------- 读取现有数据用于展示 ----------
$st = $pdo->prepare('SELECT * FROM nav_blocks WHERE cat_id = ? ORDER BY sort ASC, id ASC');
$st->execute([(int) $catRow['id']]);
$blocks = $st->fetchAll();
$itemsMap = [];
if ($blocks) {
$ids = array_map('intval', array_column($blocks, 'id'));
$in = implode(',', array_fill(0, count($ids), '?'));
$st = $pdo->prepare("SELECT * FROM nav_items WHERE block_id IN ($in) ORDER BY sort ASC, id ASC");
$st->execute($ids);
foreach ($st->fetchAll() as $it) {
$itemsMap[(int) $it['block_id']][] = $it;
}
}
$catNames = [];
foreach ($catKeys as $ck) {
$row = $pdo->query("SELECT key, name FROM categories WHERE key = '" . $ck . "'")->fetch();
$catNames[$ck] = $row['name'] ?? $ck;
}
$csrfVal = csrf_token();
layout_head('导航管理');
admin_topbar('nav');
?>
<main class="page-main">
<div class="wrap page-body">
<h2 style="margin-bottom:6px">导航管理</h2>
<p class="tip">管理 <?= he($catNames[$cat]) ?> 的导航块与链接;块内最多 15 个链接3 小列 × 5 行),超出部分请拆分到新块。</p>
<!-- 分类切换 -->
<div class="field-row" style="margin:12px 0">
<?php foreach ($catKeys as $ck): ?>
<a class="btn <?= $cat === $ck ? 'btn-primary' : '' ?>"
href="nav.php?c=<?= he($ck) ?>"><?= he($catNames[$ck]) ?></a>
<?php endforeach; ?>
</div>
<?php if ($saved): ?>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,前台页面已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<?php if ($errorMsg !== ''): ?>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'err', 'text' => $errorMsg], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<div class="fieldset-card" style="margin-bottom:16px">
<div class="fs-title">批量导入</div>
<p class="tip">在下面一次粘贴多行内容,点击「解析导入」后会按格式自动生成导航块,并<b>追加到下方编辑器末尾</b>,可再编辑确认后统一点「保存全部修改」。<br>
每行一条,支持几种写法:
<code>名称 | 网址 [ | 备注]</code>(也兼容 <code>网址 | 名称</code>)或 <code>[名称](网址)</code> 或
<code>名称 https://网址</code>;以 <code>## 块名</code> 开头的行开启一个新导航块;未写块名则归入“批量导入”。
单个块最多 15 个链接,超出会自动拆分为 <code>块名 2</code>、<code>块名 3</code> …
</p>
<textarea id="importText" rows="7" spellcheck="false" placeholder="例如:
## 安全研究
看雪 | https://www.kanxue.com | 逆向/安全社区
先知社区 https://xz.aliyun.com
[FreeBuf](https://www.freebuf.com)
## 资讯
https://www.ithome.com | IT之家"></textarea>
<div class="field-row" style="margin-top:8px">
<button type="button" class="btn btn-primary" id="btnParseImport">解析并导入</button>
<button type="button" class="btn" id="btnClearImport">清空输入框</button>
<span class="tip" style="align-self:center">导入仅是“填入编辑区”,不会立即生效,请务必点击下方的「保存全部修改」。</span>
</div>
<div id="importMsg"></div>
</div>
<form method="post" id="navForm" autocomplete="off">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save">
<input type="hidden" name="cat" value="<?= he($cat) ?>">
<textarea name="data" id="jsonData" hidden></textarea>
<div id="blocksWrap">
<?php foreach ($blocks as $b):
$items = $itemsMap[(int) $b['id']] ?? []; ?>
<div class="m-block is-fold" data-id="<?= (int) $b['id'] ?>">
<div class="m-block-head">
<button type="button" class="c-arr" title="展开 / 收起该块链接" aria-hidden="true">▾</button>
<button type="button" class="drag-h" title="按住拖动,调整该块先后顺序" draggable="true">⠿</button>
<input type="text" class="b-title" value="<?= he((string) $b['title']) ?>"
placeholder="导航块标题(必填)">
<span class="ops">
<button type="button" class="btn btn-sm b-up" title="上移">↑</button>
<button type="button" class="btn btn-sm b-down" title="下移">↓</button>
<button type="button" class="btn btn-sm btn-danger b-del" title="删除该块及其链接">删除块</button>
</span>
</div>
<div class="m-items">
<?php foreach (array_slice($items, 0, 15) as $it): ?>
<div class="m-item">
<input type="text" class="i-label" value="<?= he((string) $it['label']) ?>"
placeholder="文字(必填)">
<input type="text" class="i-url" value="<?= he((string) $it['url']) ?>"
placeholder="链接,如 https://…(必填)">
<input type="text" class="i-note" value="<?= he((string) $it['note']) ?>"
placeholder="备注(可选,前台悬浮提示)">
<button type="button" class="btn btn-sm btn-danger i-del" title="删除该链接">✕</button>
</div>
<?php endforeach; ?>
<button type="button" class="btn btn-sm b-add-item" style="margin-top:8px">+ 添加链接</button>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="field-row" style="margin:8px 0 16px">
<button type="button" class="btn btn-sm" id="btnAddBlock">+ 新建导航块</button>
<button type="button" class="btn btn-sm" id="btnNavFoldAll" title="折叠 / 展开所有块">展开全部</button>
</div>
<div>
<button type="button" class="btn btn-primary" id="btnSave">保存全部修改</button>
<span class="tip" id="saveTip"></span>
</div>
</form>
</div>
</main>
<?php layout_theme_fab(); ?>
<script src="../assets/js/common.js"></script>
<script>
(function () {
'use strict';
var wrap = document.getElementById('blocksWrap');
var jsonData = document.getElementById('jsonData');
/* ---------- 模板 ---------- */
function itemHTML(label, url, note) {
return '<div class="m-item">' +
'<input type="text" class="i-label" value="' + escAttr(label) + '" placeholder="文字(必填)">' +
'<input type="text" class="i-url" value="' + escAttr(url) + '" placeholder="链接,如 https://…(必填)">' +
'<input type="text" class="i-note" value="' + escAttr(note) + '" placeholder="备注(可选,前台悬浮提示)">' +
'<button type="button" class="btn btn-sm btn-danger i-del" title="删除该链接">✕</button>' +
'</div>';
}
function blockHTML(id, title, items) {
var inner = items.map(function (it) {
return itemHTML(it.label || '', it.url || '', it.note || '');
}).join('');
return '<div class="m-block is-fold" data-id="' + (id || 0) + '">' +
'<div class="m-block-head">' +
'<button type="button" class="c-arr" title="展开 / 收起该块链接" aria-hidden="true">▾</button>' +
'<button type="button" class="drag-h" title="按住拖动,调整该块先后顺序" draggable="true">⠿</button>' +
'<input type="text" class="b-title" value="' + escAttr(title) + '" placeholder="导航块标题(必填)">' +
'<span class="ops">' +
'<button type="button" class="btn btn-sm b-up" title="上移">↑</button>' +
'<button type="button" class="btn btn-sm b-down" title="下移">↓</button>' +
'<button type="button" class="btn btn-sm btn-danger b-del" title="删除该块及其链接">删除块</button>' +
'</span></div>' +
'<div class="m-items">' + inner + '<button type="button" class="btn btn-sm b-add-item" style="margin-top:8px">+ 添加链接</button></div>' +
'</div>';
}
function escAttr(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/* ---------- 行内事件代理 ---------- */
wrap.addEventListener('click', function (ev) {
var target = ev.target;
var block = target.closest('.m-block');
if (!block) return;
if (target.classList.contains('c-arr')) {
block.classList.toggle('is-fold');
updateNavFold();
return;
}
if (target.classList.contains('b-del')) {
(window.HomePage && HomePage.ask ? HomePage.ask('确认删除该导航块及其全部链接?', { okText: '删除块' }) : Promise.resolve(window.confirm('确认删除该导航块及其全部链接?')))
.then(function (ok) { if (ok) block.remove(); });
return;
}
if (target.classList.contains('b-up') || target.classList.contains('b-down')) {
var sib = target.classList.contains('b-up') ? block.previousElementSibling : block.nextElementSibling;
if (sib && sib.classList.contains('m-block')) {
if (target.classList.contains('b-up')) wrap.insertBefore(block, sib);
else wrap.insertBefore(sib, block);
}
return;
}
if (target.classList.contains('b-add-item')) {
var list = block.querySelector('.m-items');
var count = list.querySelectorAll('.m-item').length;
if (count >= 15) { alert('单个导航块最多 15 个链接,请新建导航块。'); return; }
list.insertBefore(createElFromHTML(itemHTML('', '', '')), target);
return;
}
if (target.classList.contains('i-del')) {
target.closest('.m-item').remove();
}
});
function updateNavFold() {
var btn = document.getElementById('btnNavFoldAll');
if (!btn) return;
var bls = wrap.querySelectorAll('.m-block');
var anyOpen = false;
for (var i = 0; i < bls.length; i++) {
if (!bls[i].classList.contains('is-fold')) { anyOpen = true; break; }
}
btn.textContent = anyOpen ? '收起全部' : '展开全部';
}
var foldAllBtn = document.getElementById('btnNavFoldAll');
if (foldAllBtn) {
foldAllBtn.addEventListener('click', function () {
var bls = wrap.querySelectorAll('.m-block');
var anyOpen = false;
for (var i = 0; i < bls.length; i++) {
if (!bls[i].classList.contains('is-fold')) { anyOpen = true; break; }
}
for (var j = 0; j < bls.length; j++) { bls[j].classList.toggle('is-fold', anyOpen); }
updateNavFold();
});
}
// 整块拖动排序(拖动结果随「保存全部修改」一并持久化)
var dragBlock = null;
function clearBlockDrop() {
var bls = wrap.querySelectorAll('.m-block');
for (var i = 0; i < bls.length; i++) { bls[i].classList.remove('drag-src', 'drop-target'); }
}
wrap.addEventListener('dragstart', function (ev) {
var h = ev.target.closest ? ev.target.closest('.drag-h') : null;
if (!h || !wrap.contains(h)) return;
var bl = h.closest('.m-block');
if (!bl) return;
dragBlock = bl;
ev.dataTransfer.effectAllowed = 'move';
try { ev.dataTransfer.setData('text/plain', bl.getAttribute('data-id') || ''); } catch (e) { }
setTimeout(function () { bl.classList.add('drag-src'); }, 0);
});
wrap.addEventListener('dragover', function (ev) {
if (!dragBlock) return;
var bl = ev.target.closest ? ev.target.closest('.m-block') : null;
if (!bl || bl === dragBlock) { clearBlockDrop(); return; }
ev.preventDefault();
ev.dataTransfer.dropEffect = 'move';
clearBlockDrop();
bl.classList.add('drop-target');
});
wrap.addEventListener('drop', function (ev) {
if (!dragBlock) return;
ev.preventDefault();
var bl = ev.target.closest ? ev.target.closest('.m-block') : null;
if (bl && bl !== dragBlock) {
var rect = bl.getBoundingClientRect();
var after = ev.clientY > rect.top + rect.height / 2;
wrap.insertBefore(dragBlock, after ? bl.nextSibling : bl);
}
});
wrap.addEventListener('dragend', function () {
clearBlockDrop();
dragBlock = null;
});
document.getElementById('btnAddBlock').addEventListener('click', function () {
wrap.insertAdjacentHTML('beforeend', blockHTML(0, '', []));
var last = wrap.lastElementChild;
if (last && last.classList.contains('m-block')) { last.classList.remove('is-fold'); }
updateNavFold();
});
/* ---------- 批量导入解析 ---------- */
var MAX_ITEMS = 15;
var importTextEl = document.getElementById('importText');
var importMsg = document.getElementById('importMsg');
// 单行 -> item无法识别返回 null
function parseImportLine(raw) {
var s = String(raw).trim();
if (s === '') return null;
// 1) | 分隔:名称 | 网址 [ | 备注](也兼容 网址 | 名称)
if (s.indexOf('|') !== -1) {
var p = s.split('|').map(function (x) { return x.trim(); });
var isUrl = function (x) { return /^https?:\/\//i.test(x); };
var label1 = p[0], url1 = p[1], note1 = p[2] || '';
if (isUrl(p[0]) && p[1] !== '' && !isUrl(p[1])) {
// 网址在前、名称在后
label1 = p[1]; url1 = p[0]; note1 = p[2] || '';
}
if (url1 !== '') return { label: label1, url: url1, note: note1 };
}
// 2) Markdown[名称](网址 ["备注"])
var m = s.match(/^\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+["“]([^"”]*)["”])?\s*\)$/);
if (m) return { label: m[1].trim(), url: m[2].trim(), note: (m[3] || '').trim() };
// 3) 名称 https://… 或 名称 https://… 备注
var m2 = s.match(/^(.*?)(https?:\/\/\S+)(?:\s+(.*))?$/i);
if (m2 && m2[2]) {
return { label: (m2[1] || '').trim(), url: m2[2].replace(/[),,。]+$/, ''), note: (m2[3] || '').trim() };
}
return null;
}
// host 作为缺省名称
function hostOf(url) {
try {
var u = new URL(url);
return u.hostname.replace(/^www\./, '');
} catch (e) {
return url;
}
}
// 全文 -> [{title, items}]
function importTextToBlocks(text) {
var lines = String(text).split(/\r?\n/);
var blocks = [];
var cur = null; // 当前块
var used = {}; // 已使用的标题(同名自动加序号)
var parsed = 0, skipped = 0;
function makeTitle(base) {
var t = base;
var n = 1;
while (used[t]) {
n++;
t = base + ' ' + n;
}
used[t] = true;
return t;
}
function ensureBlock() {
if (cur && cur.items.length < MAX_ITEMS) return;
var title = makeTitle(cur ? cur.title : '批量导入');
cur = { title: title, items: [] };
blocks.push(cur);
}
lines.forEach(function (ln) {
var s = String(ln).trim();
if (s === '') return;
// 块标题行
var hm = s.match(/^#{1,6}\s+(.+)$/);
if (hm) {
var t = hm[1].trim();
if (t !== '') {
var t2 = makeTitle(t);
cur = { title: t2, items: [] };
blocks.push(cur);
}
return;
}
var it = parseImportLine(s);
if (!it) { skipped++; return; }
if (it.url === '') { skipped++; return; }
if (it.label === '') it.label = hostOf(it.url);
ensureBlock();
cur.items.push(it);
parsed++;
});
return { blocks: blocks, parsed: parsed, skipped: skipped };
}
function bindImport() {
var btn = document.getElementById('btnParseImport');
var clearBtn = document.getElementById('btnClearImport');
if (!btn || !importTextEl || !importMsg) return;
btn.addEventListener('click', function () {
var r = importTextToBlocks(importTextEl.value);
if (!r.parsed) {
importMsg.innerHTML = '<div class="err-msg">未解析到任何链接。请检查格式,每行至少包含“名称 网址”或“名称 | 网址”。</div>';
return;
}
var frag = document.createDocumentFragment();
r.blocks.forEach(function (b) {
var div = createElFromHTML(blockHTML(0, b.title, b.items));
if (div.classList) { div.classList.remove('is-fold'); }
frag.appendChild(div);
});
wrap.appendChild(frag);
updateNavFold();
var parts = [];
parts.push('<div class="ok-msg">已解析 ' + r.parsed + ' 条链接,生成 ' + r.blocks.length + ' 个导航块并追加到下方。</div>');
if (r.skipped) parts.push('<p class="tip" style="margin-top:6px">另有 ' + r.skipped + ' 行未能识别,已跳过。</p>');
parts.push('<p class="tip">请在下方向下核对;如需调整可修改标题/链接后,统一点击「保存全部修改」生效。</p>');
importMsg.innerHTML = parts.join('');
importTextEl.value = '';
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
clearBtn.addEventListener('click', function () {
importTextEl.value = '';
importMsg.innerHTML = '';
importTextEl.focus();
});
}
bindImport();
function createElFromHTML(html) {
var t = document.createElement('template');
t.innerHTML = html.trim();
return t.content.firstChild;
}
/* ---------- 收集并保存 ---------- */
document.getElementById('btnSave').addEventListener('click', function () {
var blocks = Array.prototype.slice.call(wrap.querySelectorAll('.m-block'));
var data = [];
var hasInvalid = false;
blocks.forEach(function (block) {
if (hasInvalid) return;
var title = block.querySelector('.b-title').value.trim();
if (!title) { alert('存在未填写标题的导航块,请填写或删除。'); hasInvalid = true; return; }
var items = [];
Array.prototype.slice.call(block.querySelectorAll('.m-item')).forEach(function (row) {
var label = row.querySelector('.i-label').value.trim();
var url = row.querySelector('.i-url').value.trim();
var note = row.querySelector('.i-note').value.trim();
if (label === '' && url === '') return; // 空行忽略
if (label === '' || url === '') {
alert('链接的“文字”与“链接”不能为空。');
hasInvalid = true;
return;
}
items.push({ id: 0, label: label, url: url, note: note });
});
if (hasInvalid) return;
data.push({ id: parseInt(block.getAttribute('data-id'), 10) || 0, title: title, items: items });
});
if (hasInvalid) return;
jsonData.value = JSON.stringify(data);
document.getElementById('navForm').submit();
});
})();
</script>
</body>
</html>