NewHome/admin/exttools.php
2026-09-14 22:06:21 +08:00

685 lines
36 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/exttools.php —— 外部工具区管理库首页“外部工具”Tab
* 功能:分区 / 名称 / 网址 / 简介 / 展示范围(科普、红队、蓝队库首页)增删改、排序、启停;
* 链接多时可按 分区/栏目 + 启停状态 + 展示范围 + 关键词 快速筛选定位,并按分区折叠分组编辑;
* 支持按 SecHub/assets/template.json 格式批量导入({section} 分区标记 + {name/url/description} 工具行)。
* 前端库首页按 ext_links.cat_keys 过滤展示:留空 = 所有库通用grp = 分区标题,留空不分组平铺。
*/
$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'] ?? ''));
$grp = trim(preg_replace('/\s+/', ' ', (string) ($r['grp'] ?? '')));
$desc = trim((string) ($r['description'] ?? ''));
$url = trim((string) ($r['url'] ?? ''));
$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;
}
}
}
if ($name === '' && $url === '')
continue; // 空行忽略
if ($name === '' || $url === '') {
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:名称与网址均不能为空。';
break;
}
if (!preg_match('#^https?://#i', $url)) {
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:外部工具需以 http(s):// 开头的完整网址。';
break;
}
$clean[] = [
'id' => (int) ($r['id'] ?? 0),
'name' => $name,
'grp' => $grp,
'description' => $desc,
'url' => $url,
'cats' => implode(',', $cats),
'enabled' => (((string) ($r['enabled'] ?? '1') === '1')) ? 1 : 0,
];
}
if ($errorMsg === '') {
try {
$pdo->beginTransaction();
$oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM ext_links')->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 ext_links WHERE id = ?')->execute([$did]);
}
$stUpd = $pdo->prepare('UPDATE ext_links SET name=?, url=?, description=?, grp=?, cat_keys=?, enabled=?, sort=? WHERE id=?');
$stIns = $pdo->prepare('INSERT INTO ext_links (name, url, description, grp, cat_keys, 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['url'], $c['description'], $c['grp'], $c['cats'], $c['enabled'], $sort, $c['id']]);
} else {
$stIns->execute([$c['name'], $c['url'], $c['description'], $c['grp'], $c['cats'], $c['enabled'], $sort]);
}
}
$pdo->commit();
touch_last_updated();
header('Location: exttools.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 ext_links 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;
}
// ---------- 展示(按 sort 排序) ----------
$tools = $pdo->query('SELECT * FROM ext_links ORDER BY sort ASC, id ASC')->fetchAll();
// 供“分区”筛选下拉使用(按出现顺序去重)
$grpNames = [];
foreach ($tools as $t) {
$g = trim((string) ($t['grp'] ?? ''));
if ($g !== '' && !in_array($g, $grpNames, true)) {
$grpNames[] = $g;
}
}
layout_head('外部工具区管理');
admin_topbar('exttools');
?>
<main class="page-main">
<div class="wrap page-body">
<div class="admin-head">
<div class="ah-main">
<h2 class="ah-title">外部工具区管理</h2>
<p class="ah-sub">
链接较多时可先筛选再编辑:按 分区/栏目、启停状态、展示范围、关键词 快速过滤(仅影响当前浏览,不影响保存)。
行首“分区 / 栏目”即前台分组标题(如“合集 / 导航”),<strong>留空 = 不分组平铺</strong>
展示范围勾选库,<strong>全部不勾选 = 所有库通用</strong>。
</p>
</div>
</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">
<div class="ext-filter">
<select id="fGrp" title="按分区/栏目筛选">
<option value="">分区:全部</option>
<option value="__none__">未分组</option>
<?php foreach ($grpNames as $gn): ?>
<option value="<?= he($gn) ?>"><?= he($gn) ?></option>
<?php endforeach; ?>
</select>
<select id="fState" title="按启停状态筛选">
<option value="">状态:全部</option>
<option value="1">启用</option>
<option value="0">停用</option>
</select>
<select id="fLib" title="按展示范围筛选(通用=所有库可见)">
<option value="">展示范围:不限</option>
<option value="__gen__">仅通用(所有库)</option>
<option value="popular">含科普库</option>
<option value="red">含红队库</option>
<option value="blue">含蓝队库</option>
</select>
<input type="text" id="fKw" placeholder="关键词:匹配名称 / 简介 / 网址 / 分区…">
<button type="button" class="btn btn-sm" id="fClear">清空筛选</button>
<button type="button" class="btn btn-sm" id="foldAll" title="折叠/展开所有分区">展开全部</button>
<span class="ext-stat" id="extStat"></span>
</div>
</div>
<!-- 批量导入(模板格式与 SecHub/assets/template.json 一致) -->
<div class="fieldset-card">
<div class="fs-title">批量导入</div>
<textarea id="extImportTa" rows="8" spellcheck="false"
style="font-family:Consolas,Menlo,monospace;font-size:13px"
placeholder='[&#10; { "section": "合集" },&#10; { "name": "工具A", "url": "https://example.com/a", "description": "一句话简介" },&#10; { "name": "工具B", "url": "https://example.com/b" },&#10; { "section": "导航" },&#10; { "name": "站点C", "url": "https://example.com/c" }&#10;]'></textarea>
<p class="tip" style="margin-top:6px">
数组内可穿插分区标记 <code>{"section":"栏目名"}</code>(其后的工具归入该分区,直至下一分区标记);
工具行为 <code>{"name","url","description"}</code>,名称与网址缺失的空行会自动忽略。
点击「解析并追加」后将<strong>追加到对应分区下方</strong>,核对后统一点「保存外部工具」生效。
</p>
<div style="margin-top:8px"><button type="button" class="btn btn-sm btn-primary"
id="extImportBtn">解析并追加到列表</button></div>
</div>
<!-- 分区折叠编辑列表 -->
<div class="fieldset-card">
<form method="post" id="extForm" autocomplete="off">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save">
<textarea name="data" id="extData" hidden></textarea>
<div id="extRows">
<?php
$extGroups = [];
foreach ($tools as $t) {
$g = trim((string) ($t['grp'] ?? ''));
$extGroups[$g === '' ? "\x00" : $g][] = $t;
}
foreach ($extGroups as $gk => $gItems):
$isNone = ($gk === "\x00");
$gLabel = $isNone ? '未分组' : $gk;
$gData = $isNone ? '__none__' : $gk;
$gN = count($gItems);
$gCnt = ['popular' => 0, 'red' => 0, 'blue' => 0];
foreach ($gItems as $gt) {
foreach (array_map('trim', explode(',', trim((string) $gt['cat_keys']))) as $ck) {
if (isset($gCnt[$ck])) {
$gCnt[$ck]++;
}
}
}
?>
<div class="xg is-fold" data-g="<?= he($gData) ?>">
<div class="xg-h" title="点击折叠 / 展开">
<button type="button" class="xg-arr" aria-hidden="true">▾</button>
<button type="button" class="drag-h" title="按住拖动,调整该分区先后顺序(点保存后生效)"
draggable="true">⠿</button>
<span class="xg-name"><?= he($gLabel) ?></span>
<span class="xg-cnt"><?= $gN ?> 条</span>
<?php if ($isNone): ?>
<span class="xg-hint tip">分区留空的工具在此平铺</span>
<?php endif; ?>
<span class="xg-cat" title="整组库选择:勾选 / 取消会应用到本分区全部行(部分勾选时显示半选)">
<span class="t-cats">
<span class="t-cats-t">整组库</span>
<label><input type="checkbox" class="g-catc" data-k="popular" <?= ($gN > 0 && $gCnt['popular'] === $gN) ? ' checked' : '' ?>>科普</label>
<label><input type="checkbox" class="g-catc" data-k="red" <?= ($gN > 0 && $gCnt['red'] === $gN) ? ' checked' : '' ?>>红队</label>
<label><input type="checkbox" class="g-catc" data-k="blue" <?= ($gN > 0 && $gCnt['blue'] === $gN) ? ' checked' : '' ?>>蓝队</label>
</span>
</span>
</div>
<div class="xg-b">
<?php foreach ($gItems as $t):
$cats = array_map('trim', explode(',', trim((string) $t['cat_keys']))); ?>
<div class="t-row" data-id="<?= (int) $t['id'] ?>">
<input type="text" class="t-grp" value="<?= he((string) ($t['grp'] ?? '')) ?>"
placeholder="分区 / 栏目(如:合集 / 导航)" title="前台按此分区加标题分组展示;留空则不分组平铺">
<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="完整网址,如 https://example.com">
<span class="t-cats" title="展示范围(不勾选=所有库通用)">
<span class="t-cats-t">展示于</span>
<label><input type="checkbox" class="t-catc" data-k="popular" <?= in_array('popular', $cats, true) ? ' checked' : '' ?>>科普</label>
<label><input type="checkbox" class="t-catc" data-k="red" <?= in_array('red', $cats, true) ? ' checked' : '' ?>>红队</label>
<label><input type="checkbox" class="t-catc" data-k="blue" <?= in_array('blue', $cats, 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>
<?php endforeach; ?>
</div>
<div class="field-row" style="margin:12px 0">
<button type="button" class="btn btn-sm" id="extAdd">+ 添加工具(未分组)</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('extRows');
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function rowHTML(grp, name, desc, url) {
grp = grp || ''; name = name || ''; desc = desc || ''; url = url || '';
return '<div class="t-row" data-id="0">' +
'<input type="text" class="t-grp" value="' + esc(grp) + '" placeholder="分区 / 栏目(如:合集 / 导航)" title="前台按此分区加标题分组展示;留空则不分组直接平铺">' +
'<input type="text" class="t-name" value="' + esc(name) + '" placeholder="工具名称(必填)">' +
'<input type="text" class="t-desc" value="' + esc(desc) + '" placeholder="一句话简介">' +
'<input type="text" class="t-url" value="' + esc(url) + '" placeholder="完整网址,如 https://example.com">' +
'<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 allGroups() {
var out = [];
Array.prototype.forEach.call(rows.children, function (c) {
if (c.classList.contains('xg')) out.push(c);
});
return out;
}
function findGroup(key) {
var found = null;
Array.prototype.forEach.call(rows.children, function (c) {
if (!found && c.classList.contains('xg') && c.getAttribute('data-g') === key) found = c;
});
return found;
}
function ensureGroup(key) {
var cur = findGroup(key);
if (cur) return cur;
var label = key === '__none__' ? '未分组' : key;
var xg = document.createElement('div');
xg.className = 'xg is-fold';
xg.setAttribute('data-g', key);
xg.innerHTML = '<div class="xg-h" title="点击折叠 / 展开">' +
'<button type="button" class="xg-arr" aria-hidden="true">▾</button>' +
'<button type="button" class="drag-h" title="按住拖动,调整该分区先后顺序(点保存后生效)" draggable="true">⠿</button>' +
'<span class="xg-name">' + esc(label) + '</span>' +
'<span class="xg-cnt"></span>' +
(key === '__none__' ? '<span class="xg-hint tip">分区留空的工具在此平铺</span>' : '') +
'<span class="xg-cat" title="整组库选择:勾选 / 取消会应用到本分区全部行">' +
'<span class="t-cats"><span class="t-cats-t">整组库</span>' +
'<label><input type="checkbox" class="g-catc" data-k="popular">科普</label>' +
'<label><input type="checkbox" class="g-catc" data-k="red">红队</label>' +
'<label><input type="checkbox" class="g-catc" data-k="blue">蓝队</label></span></span>' +
'</div><div class="xg-b"></div>';
rows.appendChild(xg);
return xg;
}
function appendRowTo(grpName, html) {
var key = grpName && grpName.trim() ? grpName.trim() : '__none__';
var xg = ensureGroup(key);
var tmp = document.createElement('div');
tmp.innerHTML = html;
xg.querySelector('.xg-b').appendChild(tmp.firstChild);
xg.classList.remove('is-fold'); // 展开目标组,便于查看新增行
}
function pruneEmptyGroups() {
var xgs = allGroups();
for (var i = xgs.length - 1; i >= 0; i--) {
if (!xgs[i].querySelector('.t-row')) xgs[i].remove();
}
}
function syncGroupCat(xg) {
if (!xg) return;
var ks = ['popular', 'red', 'blue'];
var rws = xg.querySelectorAll('.t-row');
for (var i = 0; i < ks.length; i++) {
var box = xg.querySelector('.g-catc[data-k="' + ks[i] + '"]');
if (!box) continue;
var on = 0;
for (var j = 0; j < rws.length; j++) {
var rc = rws[j].querySelector('.t-catc[data-k="' + ks[i] + '"]');
if (rc && rc.checked) on++;
}
box.checked = rws.length > 0 && on === rws.length;
box.indeterminate = on > 0 && on < rws.length;
}
}
function updateFoldLabel() {
var btn = document.getElementById('foldAll');
if (!btn) return;
var gs = allGroups();
var anyOpen = false;
for (var i = 0; i < gs.length; i++) {
if (!gs[i].classList.contains('is-fold')) { anyOpen = true; break; }
}
btn.textContent = anyOpen ? '收起全部' : '展开全部';
}
function refreshStats() {
var total = rows.querySelectorAll('.t-row').length;
Array.prototype.forEach.call(allGroups(), function (xg) {
var cnt = xg.querySelectorAll('.t-row').length;
var c = xg.querySelector('.xg-cnt');
if (c) c.textContent = cnt + ' 条';
syncGroupCat(xg);
});
updateFoldLabel();
applyFilter();
var stat = document.getElementById('extStat');
if (stat) stat.textContent = '共 ' + total + ' 条';
}
// ---------- 筛选 ----------
var fGrp = document.getElementById('fGrp');
var fState = document.getElementById('fState');
var fLib = document.getElementById('fLib');
var fKw = document.getElementById('fKw');
function rowVisible(r) {
var gv = (r.querySelector('.t-grp').value || '').trim();
if (fGrp.value) {
if (fGrp.value === '__none__') { if (gv !== '') return false; }
else if (gv !== fGrp.value) return false;
}
if (fState.value !== '') {
var on = r.querySelector('.t-enabled').checked ? 1 : 0;
if (String(on) !== fState.value) return false;
}
if (fLib.value) {
var any = false, hit = false;
var cs = r.querySelectorAll('.t-catc');
for (var i = 0; i < cs.length; i++) {
if (cs[i].checked) {
any = true;
if (cs[i].getAttribute('data-k') === fLib.value) hit = true;
}
}
if (fLib.value === '__gen__') { if (any) return false; }
else if (any && !hit) return false;
}
if (fKw.value) {
var hay = ((r.querySelector('.t-name').value || '') + ' ' +
(r.querySelector('.t-desc').value || '') + ' ' +
(r.querySelector('.t-url').value || '') + ' ' + gv).toLowerCase();
if (hay.indexOf(fKw.value.toLowerCase()) === -1) return false;
}
return true;
}
function applyFilter() {
var shown = 0, total = 0;
var all = rows.querySelectorAll('.t-row');
Array.prototype.forEach.call(all, function (r) {
var ok = rowVisible(r);
r.classList.toggle('is-hide', !ok);
if (ok) shown++;
total++;
});
Array.prototype.forEach.call(allGroups(), function (xg) {
var vis = false;
var rs = xg.querySelectorAll('.t-row');
for (var i = 0; i < rs.length; i++) {
if (!rs[i].classList.contains('is-hide')) { vis = true; break; }
}
xg.classList.toggle('is-hide', !vis);
});
var stat = document.getElementById('extStat');
if (stat) stat.textContent = total ? ('显示 ' + shown + ' / 共 ' + total + ' 条') : '共 0 条';
}
var filterEls = [fGrp, fState, fLib];
for (var fi = 0; fi < filterEls.length; fi++) {
if (filterEls[fi]) filterEls[fi].addEventListener('change', applyFilter);
}
if (fKw) fKw.addEventListener('input', applyFilter);
if (document.getElementById('fClear')) {
document.getElementById('fClear').addEventListener('click', function () {
fGrp.value = ''; fState.value = ''; fLib.value = ''; fKw.value = '';
applyFilter();
});
}
if (document.getElementById('foldAll')) {
document.getElementById('foldAll').addEventListener('click', function () {
var xgs = allGroups();
var anyOpen = false;
for (var i = 0; i < xgs.length; i++) {
if (!xgs[i].classList.contains('is-fold')) { anyOpen = true; break; }
}
for (var j = 0; j < xgs.length; j++) {
xgs[j].classList.toggle('is-fold', anyOpen);
}
updateFoldLabel();
});
}
// ---------- 组头折叠 / 行操作 ----------
rows.addEventListener('click', function (ev) {
var t = ev.target;
var xgh = t.closest ? t.closest('.xg-h') : null;
if (xgh && !t.closest('.xg-cat') && !t.closest('.drag-h')) {
xgh.parentNode.classList.toggle('is-fold');
updateFoldLabel();
return;
}
var row = t.closest('.t-row');
if (!row) return;
if (t.classList.contains('t-del')) {
(window.HomePage && HomePage.ask ? HomePage.ask('确认删除该外部工具?', { okText: '删除' }) : Promise.resolve(window.confirm('确认删除该外部工具?')))
.then(function (ok) {
if (!ok) return;
row.remove();
pruneEmptyGroups();
refreshStats();
});
} else if (t.classList.contains('t-up') || t.classList.contains('t-down')) {
var sib = t.classList.contains('t-up') ? row.previousElementSibling : row.nextElementSibling;
if (sib && sib.classList.contains('t-row')) {
if (t.classList.contains('t-up')) row.parentNode.insertBefore(row, sib);
else row.parentNode.insertBefore(sib, row);
}
}
});
// 分区/启停/展示范围 等变更
rows.addEventListener('change', function (ev) {
var t = ev.target;
if (t.classList.contains('g-catc')) {
// 组级库选择:应用到本组全部行的同库勾选
var k = t.getAttribute('data-k');
var xg = t.closest('.xg');
if (k && xg) {
var target = t.checked;
var rcs = xg.querySelectorAll('.t-catc[data-k="' + k + '"]');
for (var i = 0; i < rcs.length; i++) { rcs[i].checked = target; }
t.indeterminate = false;
syncGroupCat(xg);
applyFilter();
}
return;
}
if (t.classList.contains('t-enabled')) {
var input = t;
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('网络错误,启停未保存。');
});
return;
}
if (t.classList.contains('t-grp')) {
var row2 = t.closest('.t-row');
var key = t.value.trim() === '' ? '__none__' : t.value.trim();
var xg = ensureGroup(key);
xg.classList.remove('is-fold'); // 展开目标分区
xg.querySelector('.xg-b').appendChild(row2);
pruneEmptyGroups();
refreshStats();
return;
}
if (t.classList.contains('t-catc')) {
syncGroupCat(t.closest('.xg'));
applyFilter();
}
});
// ---------- 整组拖动排序(拖动结果随「保存外部工具」一并持久化) ----------
var dragXg = null;
function clearDrop() {
allGroups().forEach(function (g) { g.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 xg = h.closest('.xg');
if (!xg) return;
dragXg = xg;
ev.dataTransfer.effectAllowed = 'move';
try { ev.dataTransfer.setData('text/plain', xg.getAttribute('data-g') || ''); } catch (e) { }
setTimeout(function () { xg.classList.add('drag-src'); }, 0);
});
rows.addEventListener('dragover', function (ev) {
if (!dragXg) return;
var xg = ev.target.closest ? ev.target.closest('.xg') : null;
if (!xg || xg === dragXg) { clearDrop(); return; }
ev.preventDefault();
ev.dataTransfer.dropEffect = 'move';
clearDrop();
xg.classList.add('drop-target');
});
rows.addEventListener('drop', function (ev) {
if (!dragXg) return;
ev.preventDefault();
var xg = ev.target.closest ? ev.target.closest('.xg') : null;
if (xg && xg !== dragXg) {
var rect = xg.getBoundingClientRect();
var after = ev.clientY > rect.top + rect.height / 2;
rows.insertBefore(dragXg, after ? xg.nextSibling : xg);
}
});
rows.addEventListener('dragend', function () {
clearDrop();
dragXg = null;
});
// ---------- 新增 / 批量导入 ----------
document.getElementById('extAdd').addEventListener('click', function () {
appendRowTo('', rowHTML('', '', '', ''));
refreshStats();
});
var importBtn = document.getElementById('extImportBtn');
if (importBtn) {
importBtn.addEventListener('click', function () {
var ta = document.getElementById('extImportTa');
var txt = (ta ? ta.value : '').trim();
if (!txt) { alert('请先在上方粘贴 JSON 数据(可参考 SecHub/assets/template.json。'); return; }
var arr;
try { arr = JSON.parse(txt); } catch (e) { alert('JSON 解析失败:' + e.message); return; }
if (!Array.isArray(arr)) { alert('JSON 顶层需为数组(与 SecHub/assets/template.json 相同)。'); return; }
var cur = '', added = 0, bad = 0;
Array.prototype.forEach.call(arr, function (it) {
if (!it || typeof it !== 'object') return;
if (it.section !== undefined) { cur = String(it.section).trim(); return; }
var nm = String(it.name || '').trim();
var ur = String(it.url || '').trim();
var de = String(it.description || '').trim();
if (!nm && !ur) return;
if (!nm || !ur) { bad++; return; }
appendRowTo(cur, rowHTML(cur, nm, de, ur));
added++;
});
refreshStats();
var msg = '解析完成:新增 ' + added + ' 行,已归入对应分区。';
if (bad > 0) msg += '另有 ' + bad + ' 行因缺少名称或网址被跳过。';
msg += '\n请核对分区与内容后点击「保存外部工具」生效。';
alert(msg);
});
}
// ---------- 保存:收集与前端校验 ----------
document.getElementById('extForm').addEventListener('submit', function (ev) {
var list = [], bad = false, badMsg = '';
Array.prototype.forEach.call(rows.querySelectorAll('.t-row'), function (r, i) {
if (bad) return;
var grp = r.querySelector('.t-grp').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 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 (!/^https?:\/\//i.test(url)) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:需填写 http(s):// 开头的完整网址。'; return; }
list.push({ id: parseInt(r.getAttribute('data-id'), 10) || 0, grp: grp, name: name, description: desc, url: url, enabled: enabled, cats: cats });
});
if (bad) { ev.preventDefault(); alert(badMsg); return; }
document.getElementById('extData').value = JSON.stringify(list);
});
// 初始状态
refreshStats();
})();
</script>
</body>
</html>