266 lines
13 KiB
PHP
266 lines
13 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; // 空行忽略
|
||
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,
|
||
];
|
||
}
|
||
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=?, sort=? WHERE id=?');
|
||
$stIns = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, 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'], $sort, $c['id']]);
|
||
} else {
|
||
$stIns->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $sort]);
|
||
}
|
||
}
|
||
$pdo->commit();
|
||
touch_last_updated();
|
||
header('Location: tools.php?ok=1');
|
||
exit;
|
||
} catch (Throwable $e) {
|
||
if ($pdo->inTransaction()) {
|
||
$pdo->rollBack();
|
||
}
|
||
$errorMsg = '保存失败:' . $e->getMessage();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- 展示现有工具 ----------
|
||
$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'] ?>">
|
||
<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>
|
||
<label class="t-on"><input type="checkbox" class="t-enabled"<?= (int)$t['enabled'] === 1 ? ' checked' : '' ?>> 启用</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">' +
|
||
'<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>' +
|
||
'<label class="t-on"><input type="checkbox" class="t-enabled" checked> 启用</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 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')) {
|
||
if (confirm('确认删除该工具入口?')) 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'));
|
||
}
|
||
});
|
||
rows.querySelectorAll('.t-ext').forEach(function (sel) {
|
||
placeholderHint(sel, sel.parentNode.querySelector('.t-url'));
|
||
});
|
||
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';
|
||
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
|
||
});
|
||
});
|
||
if (bad) {
|
||
ev.preventDefault();
|
||
alert(badMsg);
|
||
return;
|
||
}
|
||
if (!list.length) {
|
||
ev.preventDefault();
|
||
alert('功能区至少保留一个工具。');
|
||
return;
|
||
}
|
||
document.getElementById('toolsData').value = JSON.stringify(list);
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|