NewHome/admin/tools.php

390 lines
20 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/tools.php —— 功能区管理
* 功能:工具入口的增删改查 / 排序 / 启停 / 站内或外链类型。
* 站内工具页需先在 func/ 下开发对应 .php再到此处登记入口。
*/
$P = '../';
require_once __DIR__ . '/_guard.php';
$pdo = db();
$saved = isset($_GET['ok']);
$errorMsg = '';
// ---------- 保存(完整重同步) ----------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save') {
if (!csrf_verify()) {
$errorMsg = '安全校验失败,请刷新页面重试。';
} else {
$raw = (string)($_POST['data'] ?? '');
$rows = json_decode($raw, true);
if (!is_array($rows)) {
$errorMsg = '提交数据格式错误。';
} else {
// 先整体校验
$clean = [];
foreach ($rows as $i => $r) {
if (!is_array($r)) continue;
$name = trim((string)($r['name'] ?? ''));
$url = trim((string)($r['url'] ?? ''));
$icon = trim((string)($r['icon'] ?? ''));
$desc = trim((string)($r['description'] ?? ''));
$ext = ((string)($r['ext'] ?? '0') === '1') ? 1 : 0;
if ($name === '' && $url === '') continue; // 空行忽略
// 库首页“内部功能区”展示范围(空=所有库通用)
$cats = [];
if (isset($r['cats']) && is_array($r['cats'])) {
foreach ($r['cats'] as $ck) {
$s = trim((string)$ck);
if (in_array($s, ['popular', 'red', 'blue'], true) && !in_array($s, $cats, true)) {
$cats[] = $s;
}
}
}
$boardCats = implode(',', $cats);
if ($name === '' || $url === '') {
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:名称与地址均不能为空。';
break;
}
if ($ext === 1) {
if (!preg_match('#^https?://#i', $url)) {
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:外部链接需以 http(s):// 开头。';
break;
}
} else {
if (preg_match('#^https?://#i', $url)) {
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:站内页面地址请勿填写完整网址(填如 codec.php如需外链请选择“外部链接”。';
break;
}
if (preg_match('/^[a-z][a-z0-9+.-]*:/i', $url) || strpos($url, '//') === 0) {
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:站内页面仅支持相对地址(如 codec.php不允许填写协议或跨协议地址。';
break;
}
}
$clean[] = [
'id' => (int)($r['id'] ?? 0),
'icon' => ($icon !== '' ? $icon : '🧩'),
'name' => $name,
'description' => $desc,
'url' => $url,
'ext' => $ext,
'enabled' => (((string)($r['enabled'] ?? '1') === '1')) ? 1 : 0,
'board_cats' => $boardCats,
];
}
if ($errorMsg === '') {
if (!$clean) {
$errorMsg = '功能区至少需要保留一个工具。';
} else {
try {
$pdo->beginTransaction();
$oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM func_tools')->fetchAll(), 'id'));
$keep = [];
foreach ($clean as $c) {
if ($c['id'] > 0) $keep[] = $c['id'];
}
$del = array_values(array_diff($oldIds, $keep));
foreach ($del as $did) {
$pdo->prepare('DELETE FROM func_tools WHERE id = ?')->execute([$did]);
}
$stUpd = $pdo->prepare('UPDATE func_tools SET name=?, icon=?, description=?, url=?, is_external=?, enabled=?, board_cats=?, sort=? WHERE id=?');
$stIns = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, board_cats, sort) VALUES (?,?,?,?,?,?,?,?)');
$sort = 0;
foreach ($clean as $c) {
$sort++;
if ($c['id'] > 0 && in_array($c['id'], $oldIds, true)) {
$stUpd->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $c['board_cats'], $sort, $c['id']]);
} else {
$stIns->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $c['board_cats'], $sort]);
}
}
$pdo->commit();
touch_last_updated();
header('Location: tools.php?ok=1');
exit;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$errorMsg = '保存失败:' . $e->getMessage();
}
}
}
}
}
}
// ---------- 行内即时启停(单条生效,无需整表保存) ----------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'toggle') {
header('Content-Type: application/json; charset=utf-8');
if (!csrf_verify()) {
echo json_encode(['ok' => false, 'msg' => '安全校验失败,请刷新页面后重试。'], JSON_UNESCAPED_UNICODE);
} else {
$tid = (int)($_POST['id'] ?? 0);
$ten = ((string)($_POST['enabled'] ?? '') === '1') ? 1 : 0;
if ($tid > 0) {
$pdo->prepare('UPDATE func_tools SET enabled = ? WHERE id = ?')->execute([$ten, $tid]);
touch_last_updated();
echo json_encode(['ok' => true]);
} else {
echo json_encode(['ok' => false, 'msg' => '无效的记录 ID。'], JSON_UNESCAPED_UNICODE);
}
}
exit;
}
// ---------- 展示现有工具 ----------
$tools = $pdo->query('SELECT * FROM func_tools ORDER BY sort ASC, id ASC')->fetchAll();
layout_head('功能区管理');
admin_topbar('tools');
?>
<main class="page-main">
<div class="wrap page-body">
<h2 style="margin-bottom:6px">功能区管理</h2>
<p class="tip">
管理功能区首页展示的工具卡片。站内工具页需先在 <code>func/</code> 下开发对应页面,再到此处登记入口;
也可登记外部网址作为外链卡片(新窗口打开)。
</p>
<?php if ($saved): ?>
<div class="ok-msg">保存成功,功能区首页已同步更新。</div>
<?php endif; ?>
<?php if ($errorMsg !== ''): ?>
<div class="err-msg"><?= he($errorMsg) ?></div>
<?php endif; ?>
<div class="fieldset-card">
<form method="post" id="toolsForm" autocomplete="off">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save">
<textarea name="data" id="toolsData" hidden></textarea>
<div id="toolsRows">
<?php foreach ($tools as $t): ?>
<div class="t-row" data-id="<?= (int)$t['id'] ?>">
<button type="button" class="drag-h" draggable="true" title="按住拖动调整顺序">⠿</button>
<span class="icon-ctl">
<span class="icon-preview"></span>
<input type="text" class="icon-input t-icon" value="<?= he((string)$t['icon']) ?>" placeholder="图标">
<button type="button" class="btn btn-sm icon-upload" title="上传图片≤200KB">上传</button>
<input type="file" class="icon-file" accept="image/*" hidden>
</span>
<input type="text" class="t-name" value="<?= he((string)$t['name']) ?>" placeholder="名称(必填)">
<input type="text" class="t-desc" value="<?= he((string)$t['description']) ?>" placeholder="一句话说明">
<input type="text" class="t-url" value="<?= he((string)$t['url']) ?>" placeholder="站内如 codec.php / 外链 https://…">
<select class="t-ext">
<option value="0"<?= (int)$t['is_external'] === 0 ? ' selected' : '' ?>>站内页面</option>
<option value="1"<?= (int)$t['is_external'] === 1 ? ' selected' : '' ?>>外部链接</option>
</select>
<?php $bcT = array_map('trim', explode(',', trim((string)($t['board_cats'] ?? '')))); ?>
<span class="t-cats" title="在哪些库首页“内部功能区”展示(不勾选=所有库通用)">
<span class="t-cats-t">库首页</span>
<label><input type="checkbox" class="t-catc" data-k="popular"<?= in_array('popular', $bcT, true) ? ' checked' : '' ?>>科普</label>
<label><input type="checkbox" class="t-catc" data-k="red"<?= in_array('red', $bcT, true) ? ' checked' : '' ?>>红队</label>
<label><input type="checkbox" class="t-catc" data-k="blue"<?= in_array('blue', $bcT, true) ? ' checked' : '' ?>>蓝队</label>
</span>
<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled"<?= (int)$t['enabled'] === 1 ? ' checked' : '' ?>><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>
<span class="ops">
<button type="button" class="btn btn-sm t-up" title="上移">↑</button>
<button type="button" class="btn btn-sm t-down" title="下移">↓</button>
<button type="button" class="btn btn-sm btn-danger t-del" title="删除该工具">✕</button>
</span>
</div>
<?php endforeach; ?>
</div>
<div class="field-row" style="margin:12px 0">
<button type="button" class="btn btn-sm" id="toolsAdd">+ 添加工具</button>
<button type="submit" class="btn btn-primary">保存功能区</button>
<span class="tip">工具数量与展示顺序实时反映到前台功能区首页</span>
</div>
</form>
</div>
</div>
</main>
<?php layout_theme_fab(); ?>
<script src="../assets/js/common.js"></script>
<script>
(function () {
'use strict';
var rows = document.getElementById('toolsRows');
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function rowHTML() {
return '<div class="t-row" data-id="0">' +
'<button type="button" class="drag-h" draggable="true" title="按住拖动调整顺序">⠿</button>' +
'<span class="icon-ctl">' +
'<span class="icon-preview"></span>' +
'<input type="text" class="icon-input t-icon" value="" placeholder="图标">' +
'<button type="button" class="btn btn-sm icon-upload" title="上传图片≤200KB">上传</button>' +
'<input type="file" class="icon-file" accept="image/*" hidden>' +
'</span>' +
'<input type="text" class="t-name" value="" placeholder="名称(必填)">' +
'<input type="text" class="t-desc" value="" placeholder="一句话说明">' +
'<input type="text" class="t-url" value="" placeholder="站内如 codec.php / 外链 https://…">' +
'<select class="t-ext"><option value="0">站内页面</option><option value="1">外部链接</option></select>' +
'<span class="t-cats" title="在哪些库首页“内部功能区”展示(不勾选=所有库通用)">' +
'<span class="t-cats-t">库首页</span>' +
'<label><input type="checkbox" class="t-catc" data-k="popular">科普</label>' +
'<label><input type="checkbox" class="t-catc" data-k="red">红队</label>' +
'<label><input type="checkbox" class="t-catc" data-k="blue">蓝队</label>' +
'</span>' +
'<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled" checked><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>' +
'<span class="ops">' +
'<button type="button" class="btn btn-sm t-up" title="上移">↑</button>' +
'<button type="button" class="btn btn-sm t-down" title="下移">↓</button>' +
'<button type="button" class="btn btn-sm btn-danger t-del" title="删除该工具">✕</button>' +
'</span></div>';
}
function toggleEnabled(input) {
var row = input.closest('.t-row');
var id = row ? parseInt(row.getAttribute('data-id'), 10) : 0;
var csrfEl = document.querySelector('input[name="csrf"]');
if (!csrfEl) return;
if (!id) {
input.checked = !input.checked;
alert('该行尚未保存过,请先点击「保存功能区」生成记录,之后即可直接拨动开关启停。');
return;
}
var fd = new FormData();
fd.append('action', 'toggle');
fd.append('csrf', csrfEl.value);
fd.append('id', String(id));
fd.append('enabled', input.checked ? '1' : '0');
fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); })
.then(function (j) {
if (!j || j.ok !== true) {
input.checked = !input.checked;
alert('启停失败:' + ((j && j.msg) || '未知错误'));
}
})
.catch(function () {
input.checked = !input.checked;
alert('网络错误,启停未保存。');
});
}
function placeholderHint(select, urlInput) {
urlInput.placeholder = select.value === '1'
? '外部完整网址,如 https://example.com'
: '站内工具页,如 codec.php';
}
rows.addEventListener('click', function (ev) {
var row = ev.target.closest('.t-row');
if (!row) return;
if (ev.target.classList.contains('t-del')) {
(window.HomePage && HomePage.ask ? HomePage.ask('确认删除该工具入口?', { okText: '删除' }) : Promise.resolve(window.confirm('确认删除该工具入口?')))
.then(function (ok) { if (ok) row.remove(); });
} else if (ev.target.classList.contains('t-up') || ev.target.classList.contains('t-down')) {
var sib = ev.target.classList.contains('t-up') ? row.previousElementSibling : row.nextElementSibling;
if (sib && sib.classList.contains('t-row')) {
if (ev.target.classList.contains('t-up')) rows.insertBefore(row, sib);
else rows.insertBefore(sib, row);
}
}
});
rows.addEventListener('change', function (ev) {
if (ev.target.classList.contains('t-ext')) {
placeholderHint(ev.target, ev.target.parentNode.querySelector('.t-url'));
} else if (ev.target.classList.contains('t-enabled')) {
toggleEnabled(ev.target);
}
});
rows.querySelectorAll('.t-ext').forEach(function (sel) {
placeholderHint(sel, sel.parentNode.querySelector('.t-url'));
});
// 行内拖动排序(拖动结果随「保存功能区」一并持久化)
var dragRow = null;
function clearRowDrop() {
var rs = rows.querySelectorAll('.t-row');
for (var i = 0; i < rs.length; i++) rs[i].classList.remove('drag-src', 'drop-target');
}
rows.addEventListener('dragstart', function (ev) {
var h = ev.target.closest ? ev.target.closest('.drag-h') : null;
if (!h || !rows.contains(h)) return;
var row = h.closest('.t-row');
if (!row) return;
dragRow = row;
ev.dataTransfer.effectAllowed = 'move';
try { ev.dataTransfer.setData('text/plain', row.getAttribute('data-id') || ''); } catch (e) {}
setTimeout(function () { row.classList.add('drag-src'); }, 0);
});
rows.addEventListener('dragover', function (ev) {
if (!dragRow) return;
var row = ev.target.closest ? ev.target.closest('.t-row') : null;
if (!row || row === dragRow) { clearRowDrop(); return; }
ev.preventDefault();
ev.dataTransfer.dropEffect = 'move';
clearRowDrop();
row.classList.add('drop-target');
});
rows.addEventListener('drop', function (ev) {
if (!dragRow) return;
ev.preventDefault();
var row = ev.target.closest ? ev.target.closest('.t-row') : null;
if (row && row !== dragRow) {
var rect = row.getBoundingClientRect();
var after = ev.clientY > rect.top + rect.height / 2;
rows.insertBefore(dragRow, after ? row.nextSibling : row);
}
});
rows.addEventListener('dragend', function () {
clearRowDrop();
dragRow = null;
});
document.getElementById('toolsAdd').addEventListener('click', function () {
rows.insertAdjacentHTML('beforeend', rowHTML());
});
// 保存:收集与前端校验
document.getElementById('toolsForm').addEventListener('submit', function (ev) {
var list = [], bad = false, badMsg = '';
Array.prototype.forEach.call(rows.querySelectorAll('.t-row'), function (r, i) {
if (bad) return;
var icon = r.querySelector('.t-icon').value.trim();
var name = r.querySelector('.t-name').value.trim();
var desc = r.querySelector('.t-desc').value.trim();
var url = r.querySelector('.t-url').value.trim();
var ext = r.querySelector('.t-ext').value;
var enabled = r.querySelector('.t-enabled').checked ? '1' : '0';
var cats = [];
var catInputs = r.querySelectorAll('.t-catc');
for (var cc = 0; cc < catInputs.length; cc++) {
if (catInputs[cc].checked) cats.push(catInputs[cc].getAttribute('data-k'));
}
if (!name && !url) return;
if (!name || !url) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:名称与地址均不能为空。'; return; }
if (ext === '1' && !/^https?:\/\//i.test(url)) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:外部链接需以 http(s):// 开头。'; return; }
if (ext === '0' && /^https?:\/\//i.test(url)) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:站内页面请填相对地址(如 codec.php如需外链请选“外部链接”。'; return; }
if (ext === '0' && (/^[a-z][a-z0-9+.-]*:/i.test(url) || /^\/\//.test(url))) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:站内页面仅支持相对地址,不允许填写协议地址。'; return; }
list.push({
id: parseInt(r.getAttribute('data-id'), 10) || 0,
icon: icon, name: name, description: desc, url: url,
ext: ext, enabled: enabled, cats: cats
});
});
if (bad) {
ev.preventDefault();
alert(badMsg);
return;
}
if (!list.length) {
ev.preventDefault();
alert('功能区至少保留一个工具。');
return;
}
document.getElementById('toolsData').value = JSON.stringify(list);
});
})();
</script>
</body>
</html>