390 lines
20 KiB
PHP
390 lines
20 KiB
PHP
<?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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
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>
|