NewHome/admin/tools.php

266 lines
13 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; // 空行忽略
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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>