662 lines
32 KiB
PHP
662 lines
32 KiB
PHP
<?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">
|
||
<h2 style="margin-bottom:6px">外部工具区管理</h2>
|
||
<p class="tip">
|
||
链接较多时可先筛选再编辑:按 分区/栏目、启停状态、展示范围、关键词 快速过滤(仅影响当前浏览,不影响保存)。
|
||
行首“分区 / 栏目”即前台分组标题(如“合集 / 导航”),<strong>留空 = 不分组平铺</strong>;
|
||
展示范围勾选库,<strong>全部不勾选 = 所有库通用</strong>。
|
||
</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">
|
||
<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='[ { "section": "合集" }, { "name": "工具A", "url": "https://example.com/a", "description": "一句话简介" }, { "name": "工具B", "url": "https://example.com/b" }, { "section": "导航" }, { "name": "站点C", "url": "https://example.com/c" } ]'></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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
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>
|