初始化仓库,创建项目
This commit is contained in:
commit
90bd40ffde
34
admin/_guard.php
Normal file
34
admin/_guard.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/_guard.php —— 后台鉴权守卫 + 公共顶栏(所有后台页面在 $P='../' 定义后 require)
|
||||
*/
|
||||
$P = $P ?? '../';
|
||||
require_once dirname(__DIR__) . '/includes/auth.php';
|
||||
require_once dirname(__DIR__) . '/includes/layout.php';
|
||||
require_login('login.php');
|
||||
|
||||
/** 后台顶栏 */
|
||||
function admin_topbar(string $active = ''): void
|
||||
{
|
||||
global $P;
|
||||
$site = setting_get('site_name', '知识导航站');
|
||||
$admin = (string)($_SESSION['admin'] ?? '');
|
||||
echo '<div class="admin-nav"><div class="admin-nav-inner">';
|
||||
echo '<a class="brand brand-sm" href="../index.php" title="查看前台首页">';
|
||||
layout_logo_img('logo', $site);
|
||||
echo '<span class="site-name">' . he($site) . ' · 后台</span></a>';
|
||||
$links = [
|
||||
'nav' => ['nav.php', '导航内容管理'],
|
||||
'tools' => ['tools.php', '功能区管理'],
|
||||
'content' => ['content.php', '内容与顺序'],
|
||||
];
|
||||
foreach ($links as $k => $lk) {
|
||||
$cls = ($active === $k) ? 'active' : '';
|
||||
echo '<a href="' . he($lk[0]) . '"' . ($cls ? ' class="' . $cls . '"' : '') . '>' . he($lk[1]) . '</a>';
|
||||
}
|
||||
echo '<span class="sp">';
|
||||
echo '<span class="tip" style="color:rgba(255,255,255,.75)">管理员:' . he($admin) . '</span>';
|
||||
echo '<a href="logout.php">退出登录</a>';
|
||||
echo '</span>';
|
||||
echo '</div></div>' . "\n";
|
||||
}
|
||||
662
admin/content.php
Normal file
662
admin/content.php
Normal file
@ -0,0 +1,662 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/content.php —— 内容与顺序 / 站点设置
|
||||
* 含:站点信息、板块名称与说明、logo 上传(favicon + 内容 logo 同源)、
|
||||
* 名言库(quota.txt)、底部备案信息、分类顺序、修改密码。
|
||||
*/
|
||||
$P = '../';
|
||||
require_once __DIR__ . '/_guard.php';
|
||||
|
||||
$pdo = db();
|
||||
$catKeys = ['popular', 'red', 'blue', 'tool'];
|
||||
|
||||
function settings_set(string $key, string $value): void
|
||||
{
|
||||
$st = db()->prepare('INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value');
|
||||
$st->execute([$key, $value]);
|
||||
}
|
||||
|
||||
/** 删除当前已上传的自定义 logo 文件(仅限 data/logo 目录内) */
|
||||
function remove_old_logo(): void
|
||||
{
|
||||
$old = setting_get('site_logo', '');
|
||||
if ($old !== '' && strpos($old, 'data/logo/') === 0) {
|
||||
$file = DATA_DIR . '/logo/' . basename($old);
|
||||
if (is_file($file)) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function alert_html(string $kind, string $msg): void
|
||||
{
|
||||
echo '<div class="' . ($kind === 'ok' ? 'ok-msg' : 'err-msg') . '">' . he($msg) . '</div>';
|
||||
}
|
||||
|
||||
$msgKind = '';
|
||||
$msgText = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!csrf_verify()) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '安全校验失败,请刷新页面重试。';
|
||||
} else {
|
||||
$act = (string)($_POST['act'] ?? '');
|
||||
switch ($act) {
|
||||
case 'site':
|
||||
settings_set('site_name', trim((string)($_POST['site_name'] ?? '知识导航站')));
|
||||
settings_set('site_slogan', trim((string)($_POST['site_slogan'] ?? '')));
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '站点信息已保存。';
|
||||
break;
|
||||
case 'boards':
|
||||
$names = (array)($_POST['b_name'] ?? []);
|
||||
$descs = (array)($_POST['b_desc'] ?? []);
|
||||
$icons = (array)($_POST['b_icon'] ?? []);
|
||||
$sorts = (array)($_POST['b_sort'] ?? []);
|
||||
$stUpd = $pdo->prepare('UPDATE categories SET name = ?, description = ?, icon = ?, sort = ? WHERE id = ?');
|
||||
foreach (array_keys($names) as $i) {
|
||||
$name = trim((string)($names[$i] ?? ''));
|
||||
$desc = trim((string)($descs[$i] ?? ''));
|
||||
$icon = trim((string)($icons[$i] ?? ''));
|
||||
$sort = max(0, (int)($sorts[$i] ?? 0));
|
||||
$id = (int)$i;
|
||||
$key = $pdo->query('SELECT key FROM categories WHERE id = ' . $id)->fetchColumn();
|
||||
if ($name === '' || !$key || !in_array((string)$key, $catKeys, true)) continue;
|
||||
$stUpd->execute([$name, $desc, $icon, $sort, $id]);
|
||||
}
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '板块名称 / 说明 / 图标 / 顺序已保存。';
|
||||
break;
|
||||
case 'footer':
|
||||
settings_set('icp_no', trim((string)($_POST['icp_no'] ?? '')));
|
||||
settings_set('gongan_no', trim((string)($_POST['gongan_no'] ?? '')));
|
||||
settings_set('gongan_link', trim((string)($_POST['gongan_link'] ?? '')));
|
||||
settings_set('copyright', trim((string)($_POST['copyright'] ?? '')));
|
||||
settings_set('footer_text', trim((string)($_POST['footer_text'] ?? '')));
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '底部信息已保存。';
|
||||
break;
|
||||
case 'quotes':
|
||||
$content = (string)($_POST['quotes'] ?? '');
|
||||
$file = DATA_DIR . '/quota.txt';
|
||||
if (!is_dir(DATA_DIR)) @mkdir(DATA_DIR, 0777, true);
|
||||
$savedBytes = @file_put_contents($file, $content);
|
||||
if ($savedBytes === false) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '名言文件写入失败,请检查 data 目录权限。';
|
||||
} else {
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '名言库已保存。';
|
||||
}
|
||||
break;
|
||||
case 'logo':
|
||||
$up = $_FILES['logo_file'] ?? null;
|
||||
if (!$up || ($up['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '未收到有效图片,请重新选择。';
|
||||
break;
|
||||
}
|
||||
if (($up['size'] ?? 0) > 2 * 1024 * 1024) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '图片不能超过 2MB。';
|
||||
break;
|
||||
}
|
||||
$extMap = [
|
||||
'png' => 'png', 'jpg' => 'jpg', 'jpeg' => 'jpg',
|
||||
'gif' => 'gif', 'webp' => 'webp', 'svg' => 'svg', 'ico' => 'ico',
|
||||
];
|
||||
$ext = strtolower(pathinfo((string)$up['name'], PATHINFO_EXTENSION));
|
||||
if (!isset($extMap[$ext])) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '仅支持 png / jpg / gif / webp / svg / ico 图片。';
|
||||
break;
|
||||
}
|
||||
$logoDir = DATA_DIR . '/logo';
|
||||
if (!is_dir($logoDir)) {
|
||||
@mkdir($logoDir, 0777, true);
|
||||
}
|
||||
$filename = 'site_logo_' . date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . $extMap[$ext];
|
||||
if (!@move_uploaded_file($up['tmp_name'], $logoDir . '/' . $filename)) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '文件保存失败,请检查 data 目录写入权限。';
|
||||
break;
|
||||
}
|
||||
remove_old_logo();
|
||||
settings_set('site_logo', 'data/logo/' . $filename);
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = 'LOGO 已更新并应用到全站。';
|
||||
break;
|
||||
case 'logo_clear':
|
||||
remove_old_logo();
|
||||
settings_set('site_logo', '');
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '已恢复默认 LOGO。';
|
||||
break;
|
||||
case 'hero':
|
||||
settings_set('hero_bg', trim((string)($_POST['hero_bg'] ?? '')));
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '首页顶部标识栏背景已保存。';
|
||||
break;
|
||||
case 'cdn':
|
||||
settings_set('cdn_ranges', trim((string)($_POST['cdn_ranges'] ?? '')));
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = 'CDN IP 段库已保存。';
|
||||
break;
|
||||
case 'engines':
|
||||
$raw = (string)($_POST['data'] ?? '');
|
||||
$rows = json_decode($raw, true);
|
||||
if (!is_array($rows)) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '引擎数据格式错误。';
|
||||
break;
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $e) {
|
||||
if (!is_array($e)) continue;
|
||||
$name = trim((string)($e['name'] ?? ''));
|
||||
$url = trim((string)($e['url'] ?? ''));
|
||||
$icon = trim((string)($e['icon'] ?? ''));
|
||||
if ($name === '' || $url === '' || strpos($url, '{kw}') === false) continue;
|
||||
$list[] = [
|
||||
'key' => 'e' . (count($list) + 1),
|
||||
'name' => $name,
|
||||
'icon' => ($icon !== '' ? $icon : 'S'),
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
if (!$list) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '至少需要保留一个有效搜索引擎(名称与含 {kw} 的地址必填)。';
|
||||
break;
|
||||
}
|
||||
settings_set('search_engines', json_encode($list, JSON_UNESCAPED_UNICODE));
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '已保存 ' . count($list) . ' 个搜索引擎。';
|
||||
break;
|
||||
case 'engines_reset':
|
||||
settings_set('search_engines', '');
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '已恢复默认搜索引擎(Bing / 百度 / GitHub / Google)。';
|
||||
break;
|
||||
case 'quicklinks':
|
||||
$raw = (string)($_POST['data'] ?? '');
|
||||
$rows = json_decode($raw, true);
|
||||
if (!is_array($rows)) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '快捷站点数据格式错误。';
|
||||
break;
|
||||
}
|
||||
$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'] ?? ''));
|
||||
$note = trim((string)($r['note'] ?? ''));
|
||||
if ($name === '' && $url === '') continue;
|
||||
if ($name === '' || $url === '') {
|
||||
$msgKind = 'err';
|
||||
$msgText = '快捷站点第 ' . ((int)$i + 1) . ' 行:名称与地址均不能为空。';
|
||||
break;
|
||||
}
|
||||
if (!preg_match('#^https?://#i', $url)) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '快捷站点第 ' . ((int)$i + 1) . ' 行:地址需为完整 http(s) 链接。';
|
||||
break;
|
||||
}
|
||||
$clean[] = [
|
||||
'id' => (int)($r['id'] ?? 0),
|
||||
'icon' => ($icon !== '' ? $icon : '🔗'),
|
||||
'name' => $name,
|
||||
'url' => $url,
|
||||
'note' => $note,
|
||||
'enabled' => (((string)($r['enabled'] ?? '1') === '1')) ? 1 : 0,
|
||||
];
|
||||
}
|
||||
if ($msgKind === '' && !$clean) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '至少需要保留一个快捷站点。';
|
||||
}
|
||||
if ($msgKind === '') {
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM quick_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 quick_links WHERE id = ?')->execute([$did]);
|
||||
}
|
||||
$stUpd = $pdo->prepare('UPDATE quick_links SET name=?, url=?, icon=?, note=?, enabled=?, sort=? WHERE id=?');
|
||||
$stIns = $pdo->prepare('INSERT INTO quick_links (name, url, icon, note, 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['icon'], $c['note'], $c['enabled'], $sort, $c['id']]);
|
||||
} else {
|
||||
$stIns->execute([$c['name'], $c['url'], $c['icon'], $c['note'], $c['enabled'], $sort]);
|
||||
}
|
||||
}
|
||||
$pdo->commit();
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '快捷站点已保存。';
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
$msgKind = 'err';
|
||||
$msgText = '保存失败:' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'pwd':
|
||||
$np = (string)($_POST['new_pwd'] ?? '');
|
||||
$np2 = (string)($_POST['new_pwd2'] ?? '');
|
||||
if ($np !== $np2) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '两次输入的新密码不一致。';
|
||||
} else {
|
||||
[$ok, $text] = hp_change_password((string)($_POST['old_pwd'] ?? ''), $np);
|
||||
$msgKind = $ok ? 'ok' : 'err';
|
||||
$msgText = $text;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
$msgKind = 'err';
|
||||
$msgText = '未知操作。';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 板块数据(顺序展示,sort 同值按 id 兜底)
|
||||
$cats = $pdo->query('SELECT * FROM categories ORDER BY sort ASC, id ASC')->fetchAll();
|
||||
// 板块说明 seed(未编辑过时为空则给占位)
|
||||
$boardDefaults = [
|
||||
'popular' => '面向大众与初学者的计算机科学、网络安全与前沿技术科普。',
|
||||
'red' => '面向渗透测试与攻防演练的红队资源集。',
|
||||
'blue' => '面向防御侧:应急响应、威胁狩猎、取证溯源。',
|
||||
'tool' => '日常效率与安全实用小工具,直达功能区。',
|
||||
];
|
||||
|
||||
$logo = setting_get('site_logo', '');
|
||||
$quotesFile = DATA_DIR . '/quota.txt';
|
||||
$quotesContent = is_file($quotesFile) ? (string)@file_get_contents($quotesFile) : '';
|
||||
$all = [];
|
||||
foreach ($pdo->query('SELECT key, value FROM settings') as $r) {
|
||||
$all[$r['key']] = (string)$r['value'];
|
||||
}
|
||||
$site = $all['site_name'] ?? '知识导航站';
|
||||
|
||||
layout_head('内容与顺序');
|
||||
admin_topbar('content');
|
||||
?>
|
||||
<main class="page-main">
|
||||
<div class="wrap page-body">
|
||||
<h2 style="margin-bottom:6px">内容与顺序</h2>
|
||||
<p class="tip">对全站展示内容与 logo 进行维护,保存后自动更新首页"上次更新时间"。</p>
|
||||
|
||||
<?php if ($msgText !== ''): alert_html($msgKind, $msgText); endif; ?>
|
||||
|
||||
<div class="form-grid">
|
||||
<!-- 站点信息 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">站点信息</div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="site">
|
||||
<label class="fl">站点名称(浏览器标题 / 页面头部显示)</label>
|
||||
<input type="text" name="site_name" value="<?= he($site) ?>">
|
||||
<label class="fl">副标题 / Slogan</label>
|
||||
<input type="text" name="site_slogan" value="<?= he($all['site_slogan'] ?? '') ?>">
|
||||
<div style="margin-top:12px"><button type="submit" class="btn btn-primary">保存站点信息</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 站点 logo(favicon + 页面内容 logo) -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">站点 LOGO(浏览器标签页图标与页面内容同步生效)</div>
|
||||
<div class="logo-preview">
|
||||
<img src="<?= he($P . site_logo_rel()) ?>" alt="当前logo">
|
||||
<span class="tip">当前生效的 logo;更换后浏览器标签页图标(favicon)与页面头部 logo 都会更新。</span>
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="logo">
|
||||
<label class="fl">上传新 LOGO(png / jpg / gif / webp / svg / ico,≤ 2MB)</label>
|
||||
<input type="file" name="logo_file" accept=".png,.jpg,.jpeg,.gif,.webp,.svg,.ico,image/*" required>
|
||||
<div style="margin-top:12px" class="field-row">
|
||||
<button type="submit" class="btn btn-primary">上传并应用</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php if ($logo !== ''): ?>
|
||||
<form method="post" onsubmit="return confirm('确认恢复默认 LOGO?')">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="logo_clear">
|
||||
<div style="margin-top:8px"><button type="submit" class="btn btn-sm btn-danger">恢复默认 LOGO</button></div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- 首页顶部标识栏背景 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">首页顶部标识栏背景(Hero 区,支持纯色或 CSS 渐变)</div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="hero">
|
||||
<label class="fl">背景样式(纯色值或 CSS 渐变;留空恢复主题默认渐变)</label>
|
||||
<input type="text" name="hero_bg" id="heroBg" value="<?= he($all['hero_bg'] ?? '') ?>" placeholder="如 #2563eb 或 linear-gradient(135deg, #1f3a5f 0%, #2563eb 100%)">
|
||||
<div class="field-row" style="margin:10px 0;align-items:center">
|
||||
<input type="color" id="heroColor" value="#2563eb" title="拾取纯色并填入" style="width:40px;height:34px;padding:2px">
|
||||
<button type="button" class="btn btn-sm hero-pre" data-v="">默认渐变蓝</button>
|
||||
<button type="button" class="btn btn-sm hero-pre" data-v="#2563eb">纯色蓝</button>
|
||||
<button type="button" class="btn btn-sm hero-pre" data-v="linear-gradient(135deg,#1e3a8a 0%,#1d4ed8 55%,#7c3aed 100%)">深蓝紫渐变</button>
|
||||
<button type="button" class="btn btn-sm hero-pre" data-v="linear-gradient(135deg,#0f766e 0%,#06b6d4 100%)">青碧渐变</button>
|
||||
<button type="button" class="btn btn-sm hero-pre" data-v="linear-gradient(135deg,#7f1d1d 0%,#dc2626 100%)">绯红渐变</button>
|
||||
<button type="button" class="btn btn-sm hero-pre" data-v="linear-gradient(135deg,#111827 0%,#374151 100%)">石墨渐变</button>
|
||||
</div>
|
||||
<p class="tip" style="margin-top:2px">纯色直接填色值(如 #2563eb);渐变填完整 CSS,如 <code>linear-gradient(135deg, #1f3a5f 0%, #2563eb 100%)</code>。夜间模式沿用同一背景。</p>
|
||||
<div style="margin-top:10px"><button type="submit" class="btn btn-primary">保存顶部背景</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 四大板块:名称 / 说明 / 顺序 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">四大板块(名称 / 悬停说明文案 / 顺序)</div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="boards">
|
||||
<?php foreach ($cats as $c): $id = (int)$c['id']; ?>
|
||||
<div class="m-block" style="margin-bottom:10px">
|
||||
<div class="m-block-head">
|
||||
<strong><?= he((string)$c['key']) ?></strong>
|
||||
<span class="ops"><input type="number" name="b_sort[<?= $id ?>]" value="<?= (int)$c['sort'] ?>" min="1" max="99" style="width:70px" title="顺序数值,越小越靠前"></span>
|
||||
</div>
|
||||
<div class="m-items">
|
||||
<div class="icon-ctl" style="margin-top:8px">
|
||||
<span class="icon-preview"></span>
|
||||
<input type="text" class="icon-input" name="b_icon[<?= $id ?>]" value="<?= he((string)($c['icon'] ?? '')) ?>" placeholder="字符 / emoji,或粘贴/上传图片">
|
||||
<button type="button" class="btn btn-sm icon-upload" title="上传图片(≤200KB)">上传</button>
|
||||
<input type="file" class="icon-file" accept="image/*" hidden>
|
||||
</div>
|
||||
<input type="text" name="b_name[<?= $id ?>]" value="<?= he((string)$c['name']) ?>" placeholder="板块名称" style="margin-top:8px">
|
||||
<textarea name="b_desc[<?= $id ?>]" rows="2" placeholder="悬停说明文案"><?= he(trim((string)$c['description']) !== '' ? (string)$c['description'] : ($boardDefaults[(string)$c['key']] ?? '')) ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<button type="submit" class="btn btn-primary">保存板块设置</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 首页快捷站点(本域名其它网站) -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">首页快捷站点(四大板块上方的胶囊按钮,新标签页打开)</div>
|
||||
<form method="post" id="qlForm">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="quicklinks">
|
||||
<textarea name="data" id="qlData" hidden></textarea>
|
||||
<p class="tip">用于放置本域名下的其它网站入口;点击后新开标签页。地址需为完整 http(s) 链接。列表顺序即展示顺序。</p>
|
||||
<div id="qlRows">
|
||||
<?php $quickLinks = $pdo->query('SELECT * FROM quick_links ORDER BY sort ASC, id ASC')->fetchAll(); ?>
|
||||
<?php foreach ($quickLinks as $ql): ?>
|
||||
<div class="l-row" data-id="<?= (int)$ql['id'] ?>">
|
||||
<span class="icon-ctl">
|
||||
<span class="icon-preview"></span>
|
||||
<input type="text" class="icon-input l-icon" value="<?= he((string)$ql['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="l-name" value="<?= he((string)$ql['name']) ?>" placeholder="站点名称(必填)">
|
||||
<input type="text" class="l-url" value="<?= he((string)$ql['url']) ?>" placeholder="完整网址,如 https://子站.域名.com">
|
||||
<input type="text" class="l-note" value="<?= he((string)($ql['note'] ?? '')) ?>" placeholder="备注 / 网站介绍(悬停首页按钮显示)">
|
||||
<label class="t-on"><input type="checkbox" class="l-enabled"<?= (int)$ql['enabled'] === 1 ? ' checked' : '' ?>> 启用</label>
|
||||
<span class="ops">
|
||||
<button type="button" class="btn btn-sm l-up" title="上移">↑</button>
|
||||
<button type="button" class="btn btn-sm l-down" title="下移">↓</button>
|
||||
<button type="button" class="btn btn-sm btn-danger l-del" title="删除">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="field-row" style="margin:12px 0">
|
||||
<button type="button" class="btn btn-sm" id="qlAdd">+ 添加快捷站点</button>
|
||||
<button type="submit" class="btn btn-primary">保存快捷站点</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">底部信息</div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="footer">
|
||||
<label class="fl">ICP 备案号</label>
|
||||
<input type="text" name="icp_no" value="<?= he($all['icp_no'] ?? '') ?>" placeholder="如:京ICP备XXXXXXXX号-1">
|
||||
<label class="fl">公安备案号</label>
|
||||
<input type="text" name="gongan_no" value="<?= he($all['gongan_no'] ?? '') ?>">
|
||||
<label class="fl">公安备案号链接(可选,填写后备案号可点击跳转;建议使用官方查询页 https://beian.mps.gov.cn/)</label>
|
||||
<input type="url" name="gongan_link" value="<?= he($all['gongan_link'] ?? '') ?>" placeholder="https://beian.mps.gov.cn/…">
|
||||
<label class="fl">版权声明(可用 {site_name} 占位)</label>
|
||||
<input type="text" name="copyright" value="<?= he($all['copyright'] ?? '') ?>">
|
||||
<label class="fl">其他底部文本(可选)</label>
|
||||
<input type="text" name="footer_text" value="<?= he($all['footer_text'] ?? '') ?>">
|
||||
<div style="margin-top:12px"><button type="submit" class="btn btn-primary">保存底部信息</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 搜索引擎(导航区搜索栏) -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">搜索引擎(导航区搜索栏可切换的引擎)</div>
|
||||
<form method="post" id="engineForm">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="engines">
|
||||
<textarea name="data" id="engData" hidden></textarea>
|
||||
<p class="tip">图标用于搜索栏切换按钮常驻展示;搜索引擎全称在展开下拉列表时才显示。搜索地址需包含 {kw} 占位符,例如 https://www.bing.com/search?q={kw}。列表按从上到下顺序生效,可先删行再重新添加来调整顺序。</p>
|
||||
<div id="engineRows">
|
||||
<?php $curEngines = search_engines(); foreach ($curEngines as $e): ?>
|
||||
<div class="e-row">
|
||||
<input type="text" class="e-icon" value="<?= he((string)($e['icon'] ?? 'S')) ?>" maxlength="2" placeholder="图标" title="按钮图标(1 个字符或 emoji)">
|
||||
<input type="text" class="e-name" value="<?= he((string)($e['name'] ?? '')) ?>" placeholder="名称(如:百度)">
|
||||
<input type="text" class="e-url" value="<?= he((string)($e['url'] ?? '')) ?>" placeholder="搜索地址模板,必须包含 {kw}">
|
||||
<button type="button" class="btn btn-sm btn-danger e-del" title="移除该引擎">✕</button>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="field-row" style="margin:10px 0">
|
||||
<button type="button" class="btn btn-sm" id="engAdd">+ 添加引擎</button>
|
||||
<button type="submit" class="btn btn-primary">保存搜索引擎</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" onsubmit="return confirm('确认恢复为默认的 4 个搜索引擎?')">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="engines_reset">
|
||||
<button type="submit" class="btn btn-sm btn-danger">恢复默认引擎</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 名言库 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">名言库(data/quota.txt,首页随机展示)</div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="quotes">
|
||||
<label class="fl">逐行一条;以 # 开头或空行会被忽略(仅作注释与分隔)</label>
|
||||
<textarea name="quotes" rows="10" style="font-family:Consolas,Menlo,monospace;font-size:13px"><?= he($quotesContent) ?></textarea>
|
||||
<div style="margin-top:12px"><button type="submit" class="btn btn-primary">保存名言库</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- CDN IP 段库(IP 地址处理工具用) -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">CDN IP 段库(供“IP 地址处理”工具识别 CDN 来源)</div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="cdn">
|
||||
<label class="fl">每行一条:<code>CIDR 段</code> + 空格 + <code>厂商名</code>;以 # 开头的行为注释,不影响识别</label>
|
||||
<textarea name="cdn_ranges" rows="10" style="font-family:Consolas,Menlo,monospace;font-size:13px"><?= he(cdn_ranges_text()) ?></textarea>
|
||||
<div style="margin-top:12px"><button type="submit" class="btn btn-primary">保存 CDN 段库</button></div>
|
||||
<p class="tip" style="margin-top:6px">识别时会按“先命中即归为 CDN”处理;未命中且非内网的 IP 一律归为公网。默认内置 Cloudflare 官方 IPv4 段,可按需补充阿里云 / 腾讯云等厂商段。</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 修改密码 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">修改登录密码</div>
|
||||
<form method="post" id="pwdForm">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="pwd">
|
||||
<label class="fl">原密码</label>
|
||||
<input type="password" name="old_pwd" required autocomplete="off">
|
||||
<label class="fl">新密码(至少 6 位)</label>
|
||||
<input type="password" name="new_pwd" id="pwdNew" required minlength="6" autocomplete="off">
|
||||
<label class="fl">确认新密码</label>
|
||||
<input type="password" name="new_pwd2" id="pwdNew2" required minlength="6" autocomplete="off">
|
||||
<div style="margin-top:12px"><button type="submit" class="btn btn-primary">修改密码</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php layout_theme_fab(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var rows = document.getElementById('engineRows');
|
||||
function esc(s) { return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<'); }
|
||||
function rowHTML(icon, name, url) {
|
||||
return '<div class="e-row">' +
|
||||
'<input type="text" class="e-icon" value="' + esc(icon) + '" maxlength="2" placeholder="图标" title="按钮图标(1 个字符或 emoji)">' +
|
||||
'<input type="text" class="e-name" value="' + esc(name) + '" placeholder="名称(如:百度)">' +
|
||||
'<input type="text" class="e-url" value="' + esc(url) + '" placeholder="搜索地址模板,必须包含 {kw}">' +
|
||||
'<button type="button" class="btn btn-sm btn-danger e-del" title="移除该引擎">✕</button></div>';
|
||||
}
|
||||
|
||||
rows.addEventListener('click', function (ev) {
|
||||
if (ev.target.classList.contains('e-del')) {
|
||||
ev.target.closest('.e-row').remove();
|
||||
}
|
||||
});
|
||||
document.getElementById('engAdd').addEventListener('click', function () {
|
||||
rows.insertAdjacentHTML('beforeend', rowHTML('', '', ''));
|
||||
});
|
||||
|
||||
// 引擎保存:收集校验
|
||||
document.getElementById('engineForm').addEventListener('submit', function (ev) {
|
||||
var list = [], bad = false;
|
||||
Array.prototype.forEach.call(rows.querySelectorAll('.e-row'), function (r, i) {
|
||||
var name = r.querySelector('.e-name').value.trim();
|
||||
var url = r.querySelector('.e-url').value.trim();
|
||||
var icon = r.querySelector('.e-icon').value.trim();
|
||||
if (!name && !url) return; // 空行忽略
|
||||
if (!name || !url || url.indexOf('{kw}') === -1) { bad = true; return; }
|
||||
list.push({ key: 'e' + (i + 1), name: name, icon: (icon || 'S'), url: url });
|
||||
});
|
||||
if (bad) {
|
||||
ev.preventDefault();
|
||||
alert('存在未填写或搜索地址缺少 {kw} 的引擎行,请检查后重试。');
|
||||
return;
|
||||
}
|
||||
if (!list.length) {
|
||||
ev.preventDefault();
|
||||
alert('至少需要保留一个有效的搜索引擎。');
|
||||
return;
|
||||
}
|
||||
document.getElementById('engData').value = JSON.stringify(list);
|
||||
});
|
||||
|
||||
// 修改密码:两次新密码一致校验
|
||||
var pwdForm = document.getElementById('pwdForm');
|
||||
if (pwdForm) {
|
||||
pwdForm.addEventListener('submit', function (ev) {
|
||||
var a = document.getElementById('pwdNew').value;
|
||||
var b = document.getElementById('pwdNew2').value;
|
||||
if (a !== b) {
|
||||
ev.preventDefault();
|
||||
alert('两次输入的新密码不一致。');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var qlRows = document.getElementById('qlRows');
|
||||
if (!qlRows) return;
|
||||
function qEsc(s) { return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
function qRowHTML() {
|
||||
return '<div class="l-row" data-id="0">' +
|
||||
'<span class="icon-ctl">' +
|
||||
'<span class="icon-preview"></span>' +
|
||||
'<input type="text" class="icon-input l-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="l-name" value="" placeholder="站点名称(必填)">' +
|
||||
'<input type="text" class="l-url" value="" placeholder="完整网址,如 https://子站.域名.com">' +
|
||||
'<input type="text" class="l-note" value="" placeholder="备注 / 网站介绍(悬停首页按钮显示)">' +
|
||||
'<label class="t-on"><input type="checkbox" class="l-enabled" checked> 启用</label>' +
|
||||
'<span class="ops">' +
|
||||
'<button type="button" class="btn btn-sm l-up" title="上移">↑</button>' +
|
||||
'<button type="button" class="btn btn-sm l-down" title="下移">↓</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-danger l-del" title="删除">✕</button>' +
|
||||
'</span></div>';
|
||||
}
|
||||
qlRows.addEventListener('click', function (ev) {
|
||||
var row = ev.target.closest('.l-row');
|
||||
if (!row) return;
|
||||
if (ev.target.classList.contains('l-del')) {
|
||||
if (confirm('确认删除该快捷站点?')) row.remove();
|
||||
} else if (ev.target.classList.contains('l-up') || ev.target.classList.contains('l-down')) {
|
||||
var sib = ev.target.classList.contains('l-up') ? row.previousElementSibling : row.nextElementSibling;
|
||||
if (sib && sib.classList.contains('l-row')) {
|
||||
if (ev.target.classList.contains('l-up')) qlRows.insertBefore(row, sib);
|
||||
else qlRows.insertBefore(sib, row);
|
||||
}
|
||||
}
|
||||
});
|
||||
document.getElementById('qlAdd').addEventListener('click', function () {
|
||||
qlRows.insertAdjacentHTML('beforeend', qRowHTML());
|
||||
});
|
||||
document.getElementById('qlForm').addEventListener('submit', function (ev) {
|
||||
var list = [], bad = false, badMsg = '';
|
||||
Array.prototype.forEach.call(qlRows.querySelectorAll('.l-row'), function (r, i) {
|
||||
if (bad) return;
|
||||
var icon = r.querySelector('.l-icon').value.trim();
|
||||
var name = r.querySelector('.l-name').value.trim();
|
||||
var url = r.querySelector('.l-url').value.trim();
|
||||
var note = r.querySelector('.l-note').value.trim();
|
||||
var enabled = r.querySelector('.l-enabled').checked ? '1' : '0';
|
||||
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, icon: icon, name: name, url: url, note: note, enabled: enabled });
|
||||
});
|
||||
if (bad) { ev.preventDefault(); alert(badMsg); return; }
|
||||
if (!list.length) { ev.preventDefault(); alert('至少需要保留一个快捷站点。'); return; }
|
||||
document.getElementById('qlData').value = JSON.stringify(list);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var heroBg = document.getElementById('heroBg');
|
||||
if (!heroBg) return;
|
||||
var color = document.getElementById('heroColor');
|
||||
if (color) {
|
||||
color.addEventListener('input', function () { heroBg.value = this.value; });
|
||||
}
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.hero-pre'), function (b) {
|
||||
b.addEventListener('click', function () { heroBg.value = b.getAttribute('data-v') || ''; });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
56
admin/login.php
Normal file
56
admin/login.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/login.php —— 后台登录页
|
||||
*/
|
||||
$P = '../';
|
||||
require_once dirname(__DIR__) . '/includes/auth.php';
|
||||
require_once dirname(__DIR__) . '/includes/layout.php';
|
||||
|
||||
$err = '';
|
||||
$loggedOut = isset($_GET['out']);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$username = trim((string)($_POST['username'] ?? ''));
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
if ($username === '' || $password === '') {
|
||||
$err = '请输入用户名和密码。';
|
||||
} elseif (hp_login($username, $password)) {
|
||||
header('Location: nav.php');
|
||||
exit;
|
||||
} else {
|
||||
// 轻量防爆破延时
|
||||
usleep(500000);
|
||||
$err = '用户名或密码错误。';
|
||||
}
|
||||
}
|
||||
|
||||
$site = setting_get('site_name', '知识导航站');
|
||||
layout_head('后台登录');
|
||||
?>
|
||||
<main class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-logo"><?php layout_logo_img('', $site); ?></div>
|
||||
<h1>后台管理登录</h1>
|
||||
<p class="lc-sub"><?= he($site) ?></p>
|
||||
<?php if ($err !== ''): ?>
|
||||
<div class="err-msg"><?= he($err) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($loggedOut): ?>
|
||||
<div class="ok-msg">已安全退出登录。</div>
|
||||
<?php endif; ?>
|
||||
<form method="post" action="login.php" autocomplete="off">
|
||||
<label class="fl" for="username">用户名</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
<label class="fl" for="password">密码</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
<div style="margin-top:16px">
|
||||
<button type="submit" class="btn btn-primary" style="width:100%">登录</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="tip" style="margin-top:12px">初始账号:admin / admin123(首次登录后请在“内容与顺序”中修改)</p>
|
||||
</div>
|
||||
</main>
|
||||
<?php layout_theme_fab(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
9
admin/logout.php
Normal file
9
admin/logout.php
Normal file
@ -0,0 +1,9 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/logout.php —— 退出登录
|
||||
*/
|
||||
$P = '../';
|
||||
require_once dirname(__DIR__) . '/includes/auth.php';
|
||||
hp_logout();
|
||||
header('Location: login.php?out=1');
|
||||
exit;
|
||||
322
admin/nav.php
Normal file
322
admin/nav.php
Normal file
@ -0,0 +1,322 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/nav.php —— 导航内容管理(块/链接增删改查 + 顺序)
|
||||
* 完整重同步保存:提交的 JSON 中不存在的老块/老链接将被删除。
|
||||
*/
|
||||
$P = '../';
|
||||
require_once __DIR__ . '/_guard.php';
|
||||
|
||||
$pdo = db();
|
||||
$catKeys = ['popular', 'red', 'blue'];
|
||||
$cat = isset($_GET['c']) ? (string)$_GET['c'] : 'popular';
|
||||
if (!in_array($cat, $catKeys, true)) {
|
||||
$cat = 'popular';
|
||||
}
|
||||
$stCat = $pdo->prepare('SELECT * FROM categories WHERE key = ? LIMIT 1');
|
||||
$stCat->execute([$cat]);
|
||||
$catRow = $stCat->fetch();
|
||||
if (!$catRow) {
|
||||
http_response_code(404);
|
||||
die('分类不存在');
|
||||
}
|
||||
|
||||
$saved = isset($_GET['ok']);
|
||||
$errorMsg = '';
|
||||
|
||||
// ---------- 保存处理 ----------
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save') {
|
||||
if (!csrf_verify()) {
|
||||
$errorMsg = '安全校验失败,请刷新页面重试。';
|
||||
} else {
|
||||
$raw = (string)($_POST['data'] ?? '');
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
$errorMsg = '提交数据格式错误。';
|
||||
} else {
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
// 现有块
|
||||
$st = $pdo->prepare('SELECT id FROM nav_blocks WHERE cat_id = ?');
|
||||
$st->execute([(int)$catRow['id']]);
|
||||
$oldBlocks = array_map('intval', array_column($st->fetchAll(), 'id'));
|
||||
// 现有链接(按块分组)
|
||||
$oldItemsByBlock = [];
|
||||
foreach ($oldBlocks as $bid) {
|
||||
$st = $pdo->prepare('SELECT id FROM nav_items WHERE block_id = ?');
|
||||
$st->execute([$bid]);
|
||||
$oldItemsByBlock[$bid] = array_map('intval', array_column($st->fetchAll(), 'id'));
|
||||
}
|
||||
// 本轮保留的块 id
|
||||
$keepBlocks = [];
|
||||
$keepItems = [];
|
||||
foreach ($data as $b) {
|
||||
if (is_array($b) && isset($b['title'])) {
|
||||
$id = (int)($b['id'] ?? 0);
|
||||
if ($id > 0) $keepBlocks[] = $id;
|
||||
foreach (($b['items'] ?? []) as $it) {
|
||||
if (is_array($it) && (int)($it['id'] ?? 0) > 0) {
|
||||
$keepItems[] = (int)$it['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 删除被移除的块及其链接
|
||||
$delBlocks = array_values(array_diff($oldBlocks, $keepBlocks));
|
||||
foreach ($delBlocks as $dbid) {
|
||||
$pdo->prepare('DELETE FROM nav_items WHERE block_id = ?')->execute([$dbid]);
|
||||
$pdo->prepare('DELETE FROM nav_blocks WHERE id = ?')->execute([$dbid]);
|
||||
unset($oldItemsByBlock[$dbid]);
|
||||
}
|
||||
// 删除被移除的链接
|
||||
foreach ($oldItemsByBlock as $bid => $ids) {
|
||||
$del = array_values(array_diff($ids, $keepItems));
|
||||
foreach ($del as $did) {
|
||||
$pdo->prepare('DELETE FROM nav_items WHERE id = ?')->execute([$did]);
|
||||
}
|
||||
}
|
||||
// 写回块与链接
|
||||
$stInsBlock = $pdo->prepare('INSERT INTO nav_blocks (cat_id, title, sort) VALUES (?,?,?)');
|
||||
$stUpdBlock = $pdo->prepare('UPDATE nav_blocks SET title = ?, sort = ? WHERE id = ?');
|
||||
$stInsItem = $pdo->prepare('INSERT INTO nav_items (block_id, label, url, note, sort) VALUES (?,?,?,?,?)');
|
||||
$stUpdItem = $pdo->prepare('UPDATE nav_items SET label = ?, url = ?, note = ?, sort = ? WHERE id = ? AND block_id = ?');
|
||||
$blockSort = 0;
|
||||
foreach ($data as $b) {
|
||||
if (!is_array($b) || !isset($b['title'])) continue;
|
||||
$title = trim((string)$b['title']);
|
||||
if ($title === '') continue;
|
||||
$bid = (int)($b['id'] ?? 0);
|
||||
$blockBelongs = in_array($bid, $oldBlocks, true);
|
||||
if ($bid > 0 && $blockBelongs) {
|
||||
$stUpdBlock->execute([$title, $blockSort, $bid]);
|
||||
} else {
|
||||
$stInsBlock->execute([(int)$catRow['id'], $title, $blockSort]);
|
||||
$bid = (int)$pdo->lastInsertId();
|
||||
}
|
||||
$itemSort = 0;
|
||||
foreach (($b['items'] ?? []) as $it) {
|
||||
if (!is_array($it)) continue;
|
||||
$label = trim((string)($it['label'] ?? ''));
|
||||
$url = trim((string)($it['url'] ?? ''));
|
||||
$note = trim((string)($it['note'] ?? ''));
|
||||
if ($label === '' && $url === '') continue;
|
||||
$iid = (int)($it['id'] ?? 0);
|
||||
if ($iid > 0 && in_array($iid, $oldItemsByBlock[$bid] ?? [], true)) {
|
||||
$stUpdItem->execute([$label, $url, $note, $itemSort, $iid, $bid]);
|
||||
} else {
|
||||
$stInsItem->execute([$bid, $label, $url, $note, $itemSort]);
|
||||
}
|
||||
$itemSort++;
|
||||
}
|
||||
$blockSort++;
|
||||
}
|
||||
$pdo->commit();
|
||||
touch_last_updated();
|
||||
header('Location: nav.php?c=' . urlencode($cat) . '&ok=1');
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
$errorMsg = '保存失败:' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 读取现有数据用于展示 ----------
|
||||
$st = $pdo->prepare('SELECT * FROM nav_blocks WHERE cat_id = ? ORDER BY sort ASC, id ASC');
|
||||
$st->execute([(int)$catRow['id']]);
|
||||
$blocks = $st->fetchAll();
|
||||
$itemsMap = [];
|
||||
if ($blocks) {
|
||||
$ids = array_map('intval', array_column($blocks, 'id'));
|
||||
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||
$st = $pdo->prepare("SELECT * FROM nav_items WHERE block_id IN ($in) ORDER BY sort ASC, id ASC");
|
||||
$st->execute($ids);
|
||||
foreach ($st->fetchAll() as $it) {
|
||||
$itemsMap[(int)$it['block_id']][] = $it;
|
||||
}
|
||||
}
|
||||
|
||||
$catNames = [];
|
||||
foreach ($catKeys as $ck) {
|
||||
$row = $pdo->query("SELECT key, name FROM categories WHERE key = '" . $ck . "'")->fetch();
|
||||
$catNames[$ck] = $row['name'] ?? $ck;
|
||||
}
|
||||
|
||||
$csrfVal = csrf_token();
|
||||
|
||||
layout_head('导航内容管理');
|
||||
admin_topbar('nav');
|
||||
?>
|
||||
<main class="page-main">
|
||||
<div class="wrap page-body">
|
||||
<h2 style="margin-bottom:6px">导航内容管理</h2>
|
||||
<p class="tip">管理 <?= he($catNames[$cat]) ?> 的导航块与链接;块内最多 15 个链接(3 小列 × 5 行),超出部分请拆分到新块。</p>
|
||||
|
||||
<!-- 分类切换 -->
|
||||
<div class="field-row" style="margin:12px 0">
|
||||
<?php foreach ($catKeys as $ck): ?>
|
||||
<a class="btn <?= $cat === $ck ? 'btn-primary' : '' ?>" href="nav.php?c=<?= he($ck) ?>"><?= he($catNames[$ck]) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($saved): ?>
|
||||
<div class="ok-msg">保存成功,前台页面已同步更新。</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($errorMsg !== ''): ?>
|
||||
<div class="err-msg"><?= he($errorMsg) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" id="navForm" autocomplete="off">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save">
|
||||
<input type="hidden" name="cat" value="<?= he($cat) ?>">
|
||||
<textarea name="data" id="jsonData" hidden></textarea>
|
||||
|
||||
<div id="blocksWrap">
|
||||
<?php foreach ($blocks as $b):
|
||||
$items = $itemsMap[(int)$b['id']] ?? []; ?>
|
||||
<div class="m-block" data-id="<?= (int)$b['id'] ?>">
|
||||
<div class="m-block-head">
|
||||
<input type="text" class="b-title" value="<?= he((string)$b['title']) ?>" placeholder="导航块标题(必填)">
|
||||
<span class="ops">
|
||||
<button type="button" class="btn btn-sm b-up" title="上移">↑</button>
|
||||
<button type="button" class="btn btn-sm b-down" title="下移">↓</button>
|
||||
<button type="button" class="btn btn-sm btn-danger b-del" title="删除该块及其链接">删除块</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="m-items">
|
||||
<?php foreach (array_slice($items, 0, 15) as $it): ?>
|
||||
<div class="m-item">
|
||||
<input type="text" class="i-label" value="<?= he((string)$it['label']) ?>" placeholder="文字(必填)">
|
||||
<input type="text" class="i-url" value="<?= he((string)$it['url']) ?>" placeholder="链接,如 https://…(必填)">
|
||||
<input type="text" class="i-note" value="<?= he((string)$it['note']) ?>" placeholder="备注(可选,前台悬浮提示)">
|
||||
<button type="button" class="btn btn-sm btn-danger i-del" title="删除该链接">✕</button>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<button type="button" class="btn btn-sm b-add-item" style="margin-top:8px">+ 添加链接</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-sm add-btn" id="btnAddBlock">+ 新建导航块</button>
|
||||
<div>
|
||||
<button type="button" class="btn btn-primary" id="btnSave">保存全部修改</button>
|
||||
<span class="tip" id="saveTip"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php layout_theme_fab(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var wrap = document.getElementById('blocksWrap');
|
||||
var jsonData = document.getElementById('jsonData');
|
||||
|
||||
/* ---------- 模板 ---------- */
|
||||
function itemHTML(label, url, note) {
|
||||
return '<div class="m-item">' +
|
||||
'<input type="text" class="i-label" value="' + escAttr(label) + '" placeholder="文字(必填)">' +
|
||||
'<input type="text" class="i-url" value="' + escAttr(url) + '" placeholder="链接,如 https://…(必填)">' +
|
||||
'<input type="text" class="i-note" value="' + escAttr(note) + '" placeholder="备注(可选,前台悬浮提示)">' +
|
||||
'<button type="button" class="btn btn-sm btn-danger i-del" title="删除该链接">✕</button>' +
|
||||
'</div>';
|
||||
}
|
||||
function blockHTML(id, title, items) {
|
||||
var inner = items.map(function (it) {
|
||||
return itemHTML(it.label || '', it.url || '', it.note || '');
|
||||
}).join('');
|
||||
return '<div class="m-block" data-id="' + (id || 0) + '">' +
|
||||
'<div class="m-block-head">' +
|
||||
'<input type="text" class="b-title" value="' + escAttr(title) + '" placeholder="导航块标题(必填)">' +
|
||||
'<span class="ops">' +
|
||||
'<button type="button" class="btn btn-sm b-up" title="上移">↑</button>' +
|
||||
'<button type="button" class="btn btn-sm b-down" title="下移">↓</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-danger b-del" title="删除该块及其链接">删除块</button>' +
|
||||
'</span></div>' +
|
||||
'<div class="m-items">' + inner + '<button type="button" class="btn btn-sm b-add-item" style="margin-top:8px">+ 添加链接</button></div>' +
|
||||
'</div>';
|
||||
}
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/* ---------- 行内事件代理 ---------- */
|
||||
wrap.addEventListener('click', function (ev) {
|
||||
var target = ev.target;
|
||||
var block = target.closest('.m-block');
|
||||
if (!block) return;
|
||||
|
||||
if (target.classList.contains('b-del')) {
|
||||
if (confirm('确认删除该导航块及其全部链接?')) block.remove();
|
||||
return;
|
||||
}
|
||||
if (target.classList.contains('b-up') || target.classList.contains('b-down')) {
|
||||
var sib = target.classList.contains('b-up') ? block.previousElementSibling : block.nextElementSibling;
|
||||
if (sib && sib.classList.contains('m-block')) {
|
||||
if (target.classList.contains('b-up')) wrap.insertBefore(block, sib);
|
||||
else wrap.insertBefore(sib, block);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (target.classList.contains('b-add-item')) {
|
||||
var list = block.querySelector('.m-items');
|
||||
var count = list.querySelectorAll('.m-item').length;
|
||||
if (count >= 15) { alert('单个导航块最多 15 个链接,请新建导航块。'); return; }
|
||||
list.insertBefore(createElFromHTML(itemHTML('', '', '')), target);
|
||||
return;
|
||||
}
|
||||
if (target.classList.contains('i-del')) {
|
||||
target.closest('.m-item').remove();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btnAddBlock').addEventListener('click', function () {
|
||||
wrap.insertAdjacentHTML('beforeend', blockHTML(0, '', []));
|
||||
});
|
||||
|
||||
function createElFromHTML(html) {
|
||||
var t = document.createElement('template');
|
||||
t.innerHTML = html.trim();
|
||||
return t.content.firstChild;
|
||||
}
|
||||
|
||||
/* ---------- 收集并保存 ---------- */
|
||||
document.getElementById('btnSave').addEventListener('click', function () {
|
||||
var blocks = Array.prototype.slice.call(wrap.querySelectorAll('.m-block'));
|
||||
var data = [];
|
||||
var hasInvalid = false;
|
||||
|
||||
blocks.forEach(function (block) {
|
||||
if (hasInvalid) return;
|
||||
var title = block.querySelector('.b-title').value.trim();
|
||||
if (!title) { alert('存在未填写标题的导航块,请填写或删除。'); hasInvalid = true; return; }
|
||||
var items = [];
|
||||
Array.prototype.slice.call(block.querySelectorAll('.m-item')).forEach(function (row) {
|
||||
var label = row.querySelector('.i-label').value.trim();
|
||||
var url = row.querySelector('.i-url').value.trim();
|
||||
var note = row.querySelector('.i-note').value.trim();
|
||||
if (label === '' && url === '') return; // 空行忽略
|
||||
if (label === '' || url === '') {
|
||||
alert('链接的“文字”与“链接”不能为空。');
|
||||
hasInvalid = true;
|
||||
return;
|
||||
}
|
||||
items.push({ id: 0, label: label, url: url, note: note });
|
||||
});
|
||||
if (hasInvalid) return;
|
||||
data.push({ id: parseInt(block.getAttribute('data-id'), 10) || 0, title: title, items: items });
|
||||
});
|
||||
if (hasInvalid) return;
|
||||
jsonData.value = JSON.stringify(data);
|
||||
document.getElementById('navForm').submit();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
265
admin/tools.php
Normal file
265
admin/tools.php
Normal file
@ -0,0 +1,265 @@
|
||||
<?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>
|
||||
46
api/nav.php
Normal file
46
api/nav.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
/**
|
||||
* api/nav.php —— 导航数据 JSON 接口
|
||||
* 用法:api/nav.php?cat=popular | red | blue
|
||||
* 返回:[{ "title": "...", "items": [{"label":"...","url":"...","note":"..."}] }]
|
||||
*/
|
||||
require_once dirname(__DIR__) . '/includes/db.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$cat = (string)($_GET['cat'] ?? '');
|
||||
if (!in_array($cat, ['popular', 'red', 'blue'], true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'cat 参数须为 popular/red/blue'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
$st = $pdo->prepare('SELECT * FROM categories WHERE key = ? LIMIT 1');
|
||||
$st->execute([$cat]);
|
||||
$catRow = $st->fetch();
|
||||
if (!$catRow) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => '分类不存在'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
$st = $pdo->prepare('SELECT * FROM nav_blocks WHERE cat_id = ? ORDER BY sort ASC, id ASC');
|
||||
$st->execute([(int)$catRow['id']]);
|
||||
$blocks = $st->fetchAll();
|
||||
$itemSt = $pdo->prepare('SELECT * FROM nav_items WHERE block_id = ? ORDER BY sort ASC, id ASC LIMIT 15');
|
||||
foreach ($blocks as $b) {
|
||||
$itemSt->execute([(int)$b['id']]);
|
||||
$items = [];
|
||||
foreach ($itemSt->fetchAll() as $it) {
|
||||
$items[] = [
|
||||
'label' => (string)$it['label'],
|
||||
'url' => (string)$it['url'],
|
||||
'note' => (string)$it['note'],
|
||||
];
|
||||
}
|
||||
$out[] = ['title' => (string)$b['title'], 'items' => $items];
|
||||
}
|
||||
|
||||
echo json_encode($out, JSON_UNESCAPED_UNICODE);
|
||||
501
assets/css/common.css
Normal file
501
assets/css/common.css
Normal file
@ -0,0 +1,501 @@
|
||||
/* common.css —— 全站统一样式与组件(含昼/夜主题、响应式) */
|
||||
/* ============ 主题变量 ============ */
|
||||
:root, [data-theme="light"] {
|
||||
--bg: #f2f5fa;
|
||||
--bg-2: #e8edf5;
|
||||
--card: #ffffff;
|
||||
--line: #dde3ec;
|
||||
--line-2: #c9d2e0;
|
||||
--text: #1f2d3d;
|
||||
--text-2: #3c4b61;
|
||||
--text-3: #71809a;
|
||||
--brand: #2563eb;
|
||||
--brand-2: #1f3a5f;
|
||||
--brand-ink: #ffffff;
|
||||
--accent-soft: rgba(37, 99, 235, 0.10);
|
||||
--hover-bg: rgba(37, 99, 235, 0.08);
|
||||
--danger: #dc2626;
|
||||
--ok: #16a34a;
|
||||
--warn: #d97706;
|
||||
--shadow: 0 4px 16px rgba(15, 30, 55, 0.08);
|
||||
--shadow-sm: 0 1px 4px rgba(15, 30, 55, 0.06);
|
||||
--code-bg: #f4f6fa;
|
||||
--code-text: #1f2d3d;
|
||||
--tooltip-bg: #1f2d3d;
|
||||
--tooltip-text: #f2f5fa;
|
||||
--input-bg: #ffffff;
|
||||
--brand-grad: linear-gradient(135deg, #1f3a5f 0%, #2563eb 100%);
|
||||
--board-tint: linear-gradient(160deg, rgba(37, 99, 235, 0.10), rgba(31, 58, 95, 0.04));
|
||||
color-scheme: light;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--bg: #0f1520;
|
||||
--bg-2: #161e2c;
|
||||
--card: #1a2332;
|
||||
--line: #2a3648;
|
||||
--line-2: #3a4a60;
|
||||
--text: #e7edf5;
|
||||
--text-2: #c2ccda;
|
||||
--text-3: #8fa0b6;
|
||||
--brand: #60a5fa;
|
||||
--brand-2: #93c5fd;
|
||||
--brand-ink: #0b1220;
|
||||
--accent-soft: rgba(96, 165, 250, 0.14);
|
||||
--hover-bg: rgba(96, 165, 250, 0.12);
|
||||
--danger: #f87171;
|
||||
--ok: #4ade80;
|
||||
--warn: #fbbf24;
|
||||
--shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
--shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
--code-bg: #111927;
|
||||
--code-text: #dbe4f0;
|
||||
--tooltip-bg: #e7edf5;
|
||||
--tooltip-text: #1a2332;
|
||||
--input-bg: #131c2a;
|
||||
--brand-grad: linear-gradient(135deg, #12233d 0%, #1d4ed8 100%);
|
||||
--board-tint: linear-gradient(160deg, rgba(96, 165, 250, 0.13), rgba(18, 35, 61, 0.10));
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ============ 基础 ============ */
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background .25s ease, color .25s ease;
|
||||
}
|
||||
a { color: var(--brand); text-decoration: none; }
|
||||
a:hover { text-decoration: none; }
|
||||
img { max-width: 100%; }
|
||||
.wrap { width: 100%; max-width: 1200px; margin: 0 auto; padding: 0 20px; }
|
||||
main.page-main { flex: 1 0 auto; padding-bottom: 24px; }
|
||||
|
||||
h1, h2, h3, h4 { line-height: 1.3; margin: 0 0 .5em; }
|
||||
button { font-family: inherit; }
|
||||
|
||||
/* 提示小字 */
|
||||
.tip { font-size: 13px; color: var(--text-3); }
|
||||
.tip code { background: var(--code-bg); color: var(--code-text); border-radius: 4px; padding: 1px 6px; }
|
||||
.err-msg, .ok-msg { border-radius: 8px; padding: 10px 14px; font-size: 14px; margin: 12px 0; }
|
||||
.err-msg { background: rgba(220, 38, 38, .10); color: var(--danger); }
|
||||
.ok-msg { background: rgba(22, 163, 74, .12); color: var(--ok); }
|
||||
.seg-check { display: inline-flex; align-items: center; gap: 5px; font-size: 14px; color: var(--text-2); padding: 6px 10px; border: 1px solid var(--line-2); border-radius: 8px; background: var(--card); cursor: pointer; user-select: none; }
|
||||
.seg-check:hover { border-color: var(--brand); }
|
||||
.seg-check input { margin: 0; }
|
||||
|
||||
/* ============ 按钮 ============ */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
border: 1px solid var(--line-2); background: var(--card); color: var(--text);
|
||||
padding: 8px 16px; border-radius: 8px; font-size: 14px; cursor: pointer;
|
||||
transition: all .15s ease; line-height: 1.4; vertical-align: middle;
|
||||
}
|
||||
.btn:hover { border-color: var(--brand); color: var(--brand); }
|
||||
.btn-primary { background: var(--brand); border-color: var(--brand); color: var(--brand-ink); }
|
||||
.btn-primary:hover { filter: brightness(1.08); color: var(--brand-ink); }
|
||||
.btn-ghost { background: transparent; }
|
||||
.btn-danger { color: var(--danger); border-color: rgba(220, 38, 38, .4); }
|
||||
.btn-danger:hover { background: rgba(220, 38, 38, .1); border-color: var(--danger); color: var(--danger); }
|
||||
.btn-sm { padding: 4px 10px; font-size: 13px; border-radius: 6px; }
|
||||
.btn[disabled] { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
/* ============ 表单 ============ */
|
||||
label.fl { display: block; font-size: 13px; color: var(--text-3); margin: 10px 0 4px; }
|
||||
input[type=text], input[type=password], input[type=number], input[type=url], input[type=file],
|
||||
select, textarea {
|
||||
width: 100%; padding: 8px 12px; border: 1px solid var(--line-2); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text); font-size: 14px; font-family: inherit;
|
||||
transition: border .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
textarea { resize: vertical; min-height: 90px; line-height: 1.6; }
|
||||
input[type=checkbox] { accent-color: var(--brand); }
|
||||
select { width: auto; min-width: 150px; }
|
||||
.field-row { display: flex; gap: 10px; align-items: flex-start; flex-wrap: wrap; }
|
||||
|
||||
/* ============ 主题浮动按钮 ============ */
|
||||
.theme-fab {
|
||||
position: fixed; right: 18px; bottom: 22px; width: 46px; height: 46px; z-index: 1000;
|
||||
border-radius: 50%; border: 1px solid var(--line-2); background: var(--card);
|
||||
color: var(--text); font-size: 20px; cursor: pointer;
|
||||
box-shadow: var(--shadow); display: flex; align-items: center; justify-content: center;
|
||||
transition: transform .2s ease, background .2s ease;
|
||||
}
|
||||
.theme-fab:hover { transform: scale(1.08); }
|
||||
|
||||
/* ============ 通用头部(品牌条 / 顶部条) ============ */
|
||||
.brand { display: inline-flex; align-items: center; gap: 12px; }
|
||||
.brand .logo {
|
||||
width: 52px; height: 52px; border-radius: 12px; object-fit: contain;
|
||||
background: var(--card); padding: 4px; box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.brand .site-name { font-size: 21px; font-weight: 700; }
|
||||
.brand .site-slogan { font-size: 13px; color: var(--text-3); }
|
||||
.brand-sm .logo { width: 38px; height: 38px; border-radius: 9px; }
|
||||
.brand-sm .site-name { font-size: 17px; }
|
||||
|
||||
/* ---------- 首页 Hero 展示栏 ---------- */
|
||||
.hero {
|
||||
background: var(--brand-grad); color: #fff;
|
||||
padding: 26px 0 18px; min-height: 20vh; box-shadow: var(--shadow);
|
||||
}
|
||||
.hero .brand .site-name, .hero .brand .site-slogan { color: #fff; }
|
||||
.hero .brand .logo { background: rgba(255, 255, 255, .95); }
|
||||
.hero-inner { display: flex; flex-direction: column; gap: 14px; }
|
||||
.hero-top { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
.hero-meta { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: rgba(255, 255, 255, .9); text-align: right; }
|
||||
.hero-meta .val { font-variant-numeric: tabular-nums; font-weight: 600; color: #fff; }
|
||||
.weather-box { display: flex; justify-content: center; }
|
||||
.weather-box iframe { display: block; border-radius: 8px; background: transparent; max-width: 100%; }
|
||||
/* 第三方天气 iframe 内容为白底,无法跨域改色:深色主题下用反色滤镜融入当前背景 */
|
||||
[data-theme="dark"] .weather-box iframe { filter: invert(0.9) hue-rotate(180deg) contrast(0.92) saturate(0.9); }
|
||||
|
||||
/* ---------- 名言条 ---------- */
|
||||
.quote-bar { background: var(--card); border-bottom: 1px solid var(--line); }
|
||||
.quote-inner { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 12px 16px; text-align: center; }
|
||||
.quote-mark { font-size: 22px; color: var(--brand); }
|
||||
.quote-text {
|
||||
font-size: 17px; color: var(--text-2); cursor: pointer; user-select: none;
|
||||
padding: 2px 8px; border-radius: 8px;
|
||||
transition: color .15s ease, background .15s ease;
|
||||
}
|
||||
.quote-text:hover { color: var(--brand); background: var(--hover-bg); }
|
||||
|
||||
/* ============ 首页四大板块 ============ */
|
||||
/* 首页板块区:内容放得下时填满视口剩余高度(不出现竖向滚动条),内容超高时自然滚动 */
|
||||
.board-section { flex: 1 0 auto; display: flex; flex-direction: column; padding: 26px 0 24px; }
|
||||
.board-section > .wrap { flex: 1; display: flex; flex-direction: column; justify-content: center; }
|
||||
/* 首页快捷站点条(后台“内容与顺序”配置,点击新标签页打开) */
|
||||
.quick-strip { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-bottom: 24px; }
|
||||
.quick-chip {
|
||||
position: relative; display: inline-flex; align-items: center; gap: 6px; padding: 8px 18px; border-radius: 999px;
|
||||
background: var(--card); border: 1px solid var(--line-2); color: var(--text-2); font-size: 14px;
|
||||
box-shadow: var(--shadow-sm); text-decoration: none;
|
||||
transition: border-color .15s ease, color .15s ease, transform .12s ease, box-shadow .15s ease;
|
||||
}
|
||||
.quick-chip:hover { border-color: var(--brand); color: var(--brand); transform: translateY(-1px); box-shadow: var(--shadow); }
|
||||
.quick-chip .q-icon { font-size: 15px; line-height: 1; display: inline-flex; align-items: center; }
|
||||
.quick-chip .icon-img { width: 16px; height: 16px; object-fit: contain; }
|
||||
/* 快捷站点按钮 hover 备注(data-note) */
|
||||
a.quick-chip[data-note]::after {
|
||||
content: attr(data-note); position: absolute; z-index: 60; bottom: calc(100% + 8px); left: 50%;
|
||||
transform: translateX(-50%) translateY(4px); width: max-content; max-width: 320px;
|
||||
background: var(--tooltip-bg); color: var(--tooltip-text); font-size: 12px; line-height: 1.5;
|
||||
padding: 6px 10px; border-radius: 7px; white-space: normal; word-break: break-word;
|
||||
text-align: center; opacity: 0; pointer-events: none; box-shadow: var(--shadow);
|
||||
transition: opacity .14s ease, transform .14s ease;
|
||||
}
|
||||
a.quick-chip[data-note]:hover::after { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
/* 通用图标 <img>(site_icon 输出)在各容器中的尺寸 */
|
||||
.icon-img { object-fit: contain; vertical-align: middle; }
|
||||
.board-icon img.icon-img { width: 30px; height: 30px; }
|
||||
.board-section .section-title { text-align: center; font-size: 22px; margin-bottom: 6px; }
|
||||
.board-section .section-sub { text-align: center; color: var(--text-3); font-size: 14px; margin-bottom: 18px; }
|
||||
.boards { position: relative; display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; }
|
||||
.board-card {
|
||||
position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 12px; background-color: var(--card); background-image: var(--board-tint);
|
||||
border: 1px solid var(--line); border-radius: 16px; padding: 26px 14px; text-align: center;
|
||||
box-shadow: var(--shadow-sm); color: var(--text);
|
||||
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
|
||||
min-height: 230px;
|
||||
}
|
||||
.board-card:hover { transform: translateY(-4px); box-shadow: var(--shadow); border-color: var(--brand); }
|
||||
.board-icon {
|
||||
width: 64px; height: 64px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 30px; color: #fff; background: var(--brand-grad); box-shadow: var(--shadow);
|
||||
}
|
||||
.board-name { font-size: 19px; font-weight: 700; }
|
||||
.board-hint { font-size: 13px; color: var(--text-3); }
|
||||
.func-card .fc-icon img.icon-img { width: 24px; height: 24px; }
|
||||
|
||||
/* 板块展开说明浮层 */
|
||||
.board-panel {
|
||||
position: absolute; top: 6px; bottom: 6px; width: 52%; z-index: 5;
|
||||
background: var(--card); border: 1px solid var(--brand); border-radius: 16px;
|
||||
padding: 24px 26px; box-shadow: var(--shadow); pointer-events: none;
|
||||
opacity: 0; transform: translateY(8px) scale(.98); transition: opacity .18s ease, transform .18s ease;
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
}
|
||||
.board-panel.visible { opacity: 1; transform: none; }
|
||||
.board-panel.panel-left { left: 0; }
|
||||
.board-panel.panel-right { right: 0; }
|
||||
.board-panel-title { font-size: 20px; font-weight: 700; color: var(--brand); margin-bottom: 10px; }
|
||||
.board-panel-desc { font-size: 15px; color: var(--text-2); }
|
||||
|
||||
/* 名言隐藏池 */
|
||||
.quote-pool { display: none; }
|
||||
|
||||
/* ============ 页脚 ============ */
|
||||
.footer { background: var(--bg-2); border-top: 1px solid var(--line); color: var(--text-3); font-size: 13px; }
|
||||
.footer-inner { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 22px 16px; text-align: center; }
|
||||
.footer .admin-link { opacity: .65; }
|
||||
.footer .admin-link:hover { opacity: 1; }
|
||||
|
||||
/* ============ 顶部条(导航区/其他内页) ============ */
|
||||
.top-strip { background: var(--card); border-bottom: 1px solid var(--line); }
|
||||
.strip-inner { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 20px; }
|
||||
.nav-top { padding-top: 14px; }
|
||||
|
||||
/* ============ 搜索栏 ============ */
|
||||
.search-zone { padding: 10vh 0 0; }
|
||||
.search-zone .search-title { text-align: center; color: var(--text-3); font-size: 14px; margin: 8px 0 6px; }
|
||||
.search-box {
|
||||
max-width: 760px; margin: 0 auto; display: flex; align-items: center;
|
||||
border: 1px solid var(--line-2); border-radius: 26px; background: var(--card);
|
||||
box-shadow: var(--shadow-sm); overflow: visible; padding: 4px;
|
||||
transition: border-color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.search-box:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.engine-btn {
|
||||
position: relative; display: inline-flex; align-items: center; gap: 5px;
|
||||
border: none; background: transparent; color: var(--text-2); padding: 8px 14px 8px 16px;
|
||||
border-radius: 22px 0 0 22px; cursor: pointer; font-size: 14px; white-space: nowrap;
|
||||
}
|
||||
.engine-btn:hover { background: var(--hover-bg); }
|
||||
.engine-btn .arrow { font-size: 10px; opacity: .7; }
|
||||
.engine-menu {
|
||||
position: absolute; top: calc(100% + 6px); left: 0; margin: 0; padding: 6px; list-style: none;
|
||||
background: var(--card); border: 1px solid var(--line-2); border-radius: 10px; box-shadow: var(--shadow);
|
||||
min-width: 150px; display: none; z-index: 20;
|
||||
}
|
||||
.engine-menu.open { display: block; }
|
||||
.engine-menu li { padding: 7px 10px; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
.engine-menu li:hover { background: var(--hover-bg); }
|
||||
.engine-menu li.active { color: var(--brand); font-weight: 600; }
|
||||
.search-field { flex: 1; position: relative; display: flex; align-items: center; }
|
||||
.search-field input { border: none; box-shadow: none !important; background: transparent; width: 100%; padding: 9px 34px 9px 6px; font-size: 15px; }
|
||||
.search-field input:focus { border: none; }
|
||||
.search-clear {
|
||||
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
|
||||
width: 20px; height: 20px; border-radius: 50%; border: none; background: var(--bg-2);
|
||||
color: var(--text-3); font-size: 12px; line-height: 1; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; visibility: hidden;
|
||||
}
|
||||
.search-clear:hover { background: var(--line-2); color: var(--text); }
|
||||
.search-go {
|
||||
border: none; background: var(--brand); color: var(--brand-ink); border-radius: 20px;
|
||||
padding: 9px 22px; font-size: 15px; cursor: pointer; font-weight: 600;
|
||||
}
|
||||
.search-go:hover { filter: brightness(1.08); }
|
||||
|
||||
/* ============ 导航区 ============ */
|
||||
.nav-area { margin: 26px 15% 40px; }
|
||||
.nav-area-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 16px; gap: 10px; flex-wrap: wrap; }
|
||||
.nav-area-title { font-size: 20px; font-weight: 700; }
|
||||
.nav-area-count { color: var(--text-3); font-size: 13px; }
|
||||
.nav-cols { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; align-items: start; }
|
||||
.nav-col { display: flex; flex-direction: column; gap: 18px; }
|
||||
.nav-block {
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 14px;
|
||||
padding: 14px 14px 12px; box-shadow: var(--shadow-sm); break-inside: avoid;
|
||||
}
|
||||
.nav-block-title { font-weight: 700; font-size: 15px; margin: 0 0 10px; color: var(--brand-2); padding-left: 2px; }
|
||||
.nav-block-grid { display: grid; grid-template-columns: repeat(3, 1fr); grid-auto-rows: 36px; gap: 8px; }
|
||||
.nav-btn {
|
||||
position: relative; display: flex; align-items: center; justify-content: center; padding: 0 6px;
|
||||
border: 1px solid var(--line); border-radius: 8px; background: var(--bg);
|
||||
color: var(--text-2); font-size: 13px; text-align: center; overflow: visible;
|
||||
transition: background .15s ease, color .15s ease, border-color .15s ease, transform .12s ease;
|
||||
min-width: 0; white-space: nowrap;
|
||||
}
|
||||
a.nav-btn:hover { background: var(--brand); border-color: var(--brand); color: #fff; transform: translateY(-1px); }
|
||||
span.nav-btn.is-empty { visibility: hidden; }
|
||||
span.nav-btn.is-off { opacity: .45; cursor: not-allowed; }
|
||||
/* tooltip:有备注按钮 hover 浮出气泡 */
|
||||
a.nav-btn[data-note]::after {
|
||||
content: attr(data-note); position: absolute; z-index: 60; bottom: calc(100% + 7px); left: 50%;
|
||||
transform: translateX(-50%) translateY(4px); background: var(--tooltip-bg); color: var(--tooltip-text);
|
||||
font-size: 12px; line-height: 1.5; padding: 5px 10px; border-radius: 7px; white-space: nowrap;
|
||||
max-width: 280px; overflow: hidden; text-overflow: ellipsis;
|
||||
opacity: 0; pointer-events: none; transition: opacity .14s ease, transform .14s ease;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
a.nav-btn[data-note]:hover::after { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
|
||||
/* ============ 功能区 ============ */
|
||||
.func-bar { background: var(--card); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 50; }
|
||||
.func-bar-inner { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; }
|
||||
.func-bar-title { font-size: 16px; font-weight: 700; text-align: center; flex: 0 1 auto; }
|
||||
.func-bar .spacer { width: 120px; }
|
||||
.sub-hero { position: relative; background: var(--brand-grad); color: #fff; text-align: center; padding: 46px 20px 40px; }
|
||||
.sub-hero h1 { font-size: 26px; margin: 0 0 8px; }
|
||||
.sub-hero p { color: rgba(255, 255, 255, .88); margin: 0; font-size: 14px; }
|
||||
.sub-hero .back-home { position: absolute; top: 14px; left: 18px; color: rgba(255, 255, 255, .92); font-size: 14px; }
|
||||
.sub-hero .back-home:hover { color: #fff; text-decoration: underline; }
|
||||
.page-body { padding: 26px 0 10px; }
|
||||
|
||||
/* 工具入口卡片 */
|
||||
.func-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 18px; margin: 6px 0 20px; }
|
||||
.func-card {
|
||||
display: flex; align-items: center; gap: 14px; background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 14px; padding: 18px; box-shadow: var(--shadow-sm); color: var(--text);
|
||||
transition: transform .16s ease, box-shadow .16s ease, border-color .16s ease;
|
||||
}
|
||||
.func-card:hover { transform: translateY(-3px); box-shadow: var(--shadow); border-color: var(--brand); }
|
||||
.func-card .fc-icon {
|
||||
width: 50px; height: 50px; flex: 0 0 50px; border-radius: 12px; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 24px; color: var(--brand); background: var(--accent-soft);
|
||||
}
|
||||
.func-card .fc-name { font-weight: 700; font-size: 15px; }
|
||||
.func-card .fc-desc { font-size: 13px; color: var(--text-3); margin-top: 2px; }
|
||||
|
||||
/* 工具卡片容器 */
|
||||
.panel-card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 20px; margin-bottom: 20px; box-shadow: var(--shadow-sm); }
|
||||
.panel-card > h2 { font-size: 17px; margin-top: 0; }
|
||||
|
||||
/* IO 双文本区布局 */
|
||||
.io-area { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
|
||||
.io-box { display: flex; flex-direction: column; }
|
||||
.io-box .io-label { font-size: 13px; color: var(--text-3); margin-bottom: 6px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.io-box textarea { min-height: 230px; font-family: Consolas, Menlo, monospace; font-size: 13px; }
|
||||
.copy-btn .btn { display: inline-flex; }
|
||||
|
||||
/* 工具操作行 */
|
||||
.tool-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 10px 0; }
|
||||
.seg { display: inline-flex; border: 1px solid var(--line-2); border-radius: 8px; overflow: hidden; }
|
||||
.seg button { border: none; background: var(--card); color: var(--text-2); padding: 7px 16px; cursor: pointer; font-size: 14px; }
|
||||
.seg button.active { background: var(--brand); color: #fff; }
|
||||
|
||||
/* 密码结果 */
|
||||
.pwd-results { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
|
||||
.pwd-line { display: flex; align-items: center; gap: 10px; }
|
||||
.pwd-line code { flex: 1; background: var(--code-bg); color: var(--code-text); padding: 8px 12px; border-radius: 8px; font-size: 14px; overflow-x: auto; white-space: pre; word-break: break-all; }
|
||||
|
||||
/* 二维码 */
|
||||
.qr-stage { display: flex; flex-direction: column; align-items: center; gap: 14px; margin-top: 10px; }
|
||||
.qr-stage canvas, .qr-stage img { border-radius: 10px; background: #fff; padding: 8px; box-shadow: var(--shadow-sm); }
|
||||
|
||||
/* 数据表格 */
|
||||
.tbl { width: 100%; border-collapse: collapse; font-size: 14px; margin: 10px 0; }
|
||||
.tbl th, .tbl td { border: 1px solid var(--line); padding: 8px 12px; text-align: left; }
|
||||
.tbl th { background: var(--bg-2); font-weight: 600; }
|
||||
.tbl tr:hover td { background: var(--hover-bg); }
|
||||
.badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; }
|
||||
.badge-ok { background: rgba(22, 163, 74, .14); color: var(--ok); }
|
||||
.badge-no { background: rgba(148, 163, 184, .16); color: var(--text-3); }
|
||||
/* IP 地址处理:来源分类徽章 */
|
||||
.badge-cdn { background: rgba(37, 99, 235, .14); color: var(--brand); }
|
||||
.badge-priv { background: rgba(217, 119, 6, .15); color: var(--warn); }
|
||||
.badge-pub { background: rgba(148, 163, 184, .16); color: var(--text-3); }
|
||||
/* IP 处理结果预览(红色标记重点 IP) */
|
||||
.ip-pre {
|
||||
font-family: Consolas, Menlo, monospace; font-size: 13px; line-height: 1.8;
|
||||
background: var(--code-bg); color: var(--code-text);
|
||||
border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px;
|
||||
white-space: pre-wrap; word-break: break-all; overflow: auto; max-height: 440px;
|
||||
}
|
||||
.ip-pre .ip-hit, .ip-pre .ip-seg-hit { color: var(--danger); font-weight: 700; }
|
||||
.ip-pre .ip-seg-hit { background: rgba(220, 38, 38, .10); border-radius: 4px; }
|
||||
|
||||
/* ============ 后台管理 ============ */
|
||||
.admin-nav { background: var(--brand-2); color: #fff; }
|
||||
.admin-nav a { color: rgba(255, 255, 255, .9); }
|
||||
.admin-nav-inner { display: flex; align-items: center; gap: 18px; padding: 10px 20px; flex-wrap: wrap; }
|
||||
.admin-nav-inner .sp { margin-left: auto; display: flex; gap: 14px; align-items: center; }
|
||||
.admin-nav-inner .logo { height: 30px; width: 30px; border-radius: 7px; object-fit: contain; background: #fff; padding: 2px; }
|
||||
.admin-nav .brand .site-name { color: #fff; }
|
||||
.admin-nav a.active { text-decoration: underline; text-underline-offset: 4px; font-weight: 600; }
|
||||
|
||||
.fieldset-card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 18px; margin-bottom: 20px; box-shadow: var(--shadow-sm); }
|
||||
.fieldset-card > .fs-title { font-weight: 700; font-size: 16px; margin: 0 0 12px; padding-bottom: 8px; border-bottom: 1px solid var(--line); }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0 18px; }
|
||||
|
||||
/* 后台导航内容管理 */
|
||||
.m-block { border: 1px solid var(--line); border-radius: 12px; margin-bottom: 16px; overflow: hidden; }
|
||||
.m-block-head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--bg-2); flex-wrap: wrap; }
|
||||
.m-block-head input[type=text] { max-width: 320px; }
|
||||
.m-block-head input.b-title { flex: 1 1 240px; width: auto; max-width: 520px; }
|
||||
.m-block-head .ops { margin-left: auto; display: flex; gap: 6px; align-items: center; }
|
||||
.m-items { padding: 4px 12px 12px; }
|
||||
.m-item { display: grid; grid-template-columns: 1fr 1.4fr 1.2fr 36px; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
.m-item .m-del { text-align: center; }
|
||||
/* 后台搜索引擎管理行 */
|
||||
.e-row { display: grid; grid-template-columns: 70px 1.1fr 1.8fr 40px; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
/* 后台图标控件:预览框 + 文本输入 + 上传按钮 + 隐藏文件选择 */
|
||||
.icon-ctl { display: flex; align-items: center; gap: 6px; flex: 0 1 250px; min-width: 170px; }
|
||||
.icon-ctl .icon-preview {
|
||||
width: 30px; height: 30px; flex: 0 0 30px; border: 1px solid var(--line-2); border-radius: 8px;
|
||||
background: var(--bg); color: var(--text-2); overflow: hidden;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 13px; line-height: 1; white-space: nowrap; text-overflow: ellipsis;
|
||||
}
|
||||
.icon-ctl .icon-preview img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.icon-ctl .icon-input { flex: 1 1 90px; min-width: 70px; padding: 7px 9px; font-size: 13px; }
|
||||
.icon-ctl .icon-upload { flex: 0 0 auto; padding: 7px 12px; font-size: 13px; }
|
||||
/* 后台功能区工具管理行 / 首页快捷站点管理行:flex 自适应,窄屏自动换行,避免重叠 */
|
||||
.t-row, .l-row {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
|
||||
padding: 12px 0; margin-top: 12px; border-top: 1px dashed var(--line);
|
||||
}
|
||||
.t-row:first-child, .l-row:first-child { margin-top: 0; padding-top: 0; border-top: none; }
|
||||
.t-row .icon-ctl, .l-row .icon-ctl { flex: 0 1 240px; }
|
||||
.t-row .t-name, .l-row .l-name { flex: 1 1 120px; min-width: 90px; }
|
||||
.t-row .t-desc { flex: 1 1 170px; min-width: 110px; }
|
||||
.t-row .t-url, .l-row .l-url { flex: 2 1 230px; min-width: 150px; }
|
||||
.t-row .t-ext { flex: 0 1 132px; min-width: 0; width: 100%; }
|
||||
.l-row .l-note { flex: 1 1 170px; min-width: 120px; }
|
||||
.t-row .ops, .l-row .ops { display: flex; gap: 6px; flex: 0 0 auto; margin-left: auto; }
|
||||
.t-on { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: var(--text-2); white-space: nowrap; flex: 0 0 auto; }
|
||||
.add-btn { margin: 6px 0 18px; }
|
||||
|
||||
/* 登录页 */
|
||||
.login-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg-2); padding: 20px; }
|
||||
.login-card { width: 100%; max-width: 380px; background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 30px; box-shadow: var(--shadow); }
|
||||
.login-card h1 { text-align: center; font-size: 20px; margin-bottom: 4px; }
|
||||
.login-card .lc-sub { text-align: center; color: var(--text-3); font-size: 13px; margin-bottom: 16px; }
|
||||
.login-logo { text-align: center; margin-bottom: 10px; }
|
||||
.login-logo img { width: 64px; height: 64px; border-radius: 14px; }
|
||||
|
||||
/* 顶部 logo 占位展示 */
|
||||
.logo-preview { display: flex; align-items: center; gap: 14px; margin: 8px 0; }
|
||||
.logo-preview img { width: 64px; height: 64px; border-radius: 12px; border: 1px solid var(--line); object-fit: contain; background: #fff; }
|
||||
|
||||
/* ============ 响应式 ============ */
|
||||
@media (max-width: 1199px) {
|
||||
.nav-area { margin: 24px 5% 36px; }
|
||||
}
|
||||
@media (max-width: 991px) {
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.hero { min-height: 0; }
|
||||
.hero-top { flex-direction: column; text-align: center; }
|
||||
.hero-meta { text-align: center; }
|
||||
.weather-box iframe { width: 100%; }
|
||||
.quote-inner { flex-wrap: wrap; }
|
||||
.boards { grid-template-columns: repeat(2, 1fr); gap: 12px; min-height: 0; }
|
||||
.board-panel { width: 100%; }
|
||||
.nav-cols { grid-template-columns: 1fr; gap: 14px; }
|
||||
.nav-area { margin: 18px 12px 30px; }
|
||||
.search-zone { padding-top: 6vh; }
|
||||
.search-box { flex-wrap: wrap; border-radius: 16px; padding: 6px; }
|
||||
.engine-btn { border-radius: 12px; }
|
||||
.search-go { border-radius: 12px; }
|
||||
.search-field { order: 3; flex-basis: 100%; }
|
||||
.io-area { grid-template-columns: 1fr; }
|
||||
.io-box textarea { min-height: 160px; }
|
||||
.m-item { grid-template-columns: 1fr; gap: 4px; }
|
||||
.e-row { grid-template-columns: 1fr; gap: 4px; }
|
||||
.t-row, .l-row { gap: 6px; padding: 10px 0; }
|
||||
.t-row > *, .l-row > * { flex: 1 1 100%; min-width: 0; }
|
||||
.icon-ctl { min-width: 0; }
|
||||
.t-row .t-on, .l-row .t-on { flex: 0 0 auto; }
|
||||
.t-row .ops, .l-row .ops { flex: 0 0 auto; margin-left: auto; justify-content: flex-end; }
|
||||
.m-block-head input[type=text] { max-width: 100%; }
|
||||
.func-bar-title { font-size: 14px; }
|
||||
.func-bar .spacer { width: 60px; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.nav-block-grid { grid-template-columns: 1fr; grid-auto-rows: auto; }
|
||||
.nav-btn { justify-content: flex-start; padding: 8px 12px; }
|
||||
}
|
||||
10
assets/img/logo.svg
Normal file
10
assets/img/logo.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#2563eb"/>
|
||||
<stop offset="1" stop-color="#1f3a5f"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#g)"/>
|
||||
<text x="32" y="42" font-family="Arial, 'Microsoft YaHei', sans-serif" font-size="30" font-weight="bold" text-anchor="middle" fill="#ffffff">知</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 502 B |
240
assets/js/codec.js
Normal file
240
assets/js/codec.js
Normal file
@ -0,0 +1,240 @@
|
||||
/* codec.js —— 编码/加解密纯前端算法库
|
||||
* 提供:UTF-8、Base64、Base32、URL、Unicode、SHA 系列(Web Crypto 原生)
|
||||
* MD5 / SHA-224 依赖 CryptoJS(由页面懒加载,双 CDN fallback)
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
/* ---------- UTF-8 工具 ---------- */
|
||||
function utf8Encode(str) {
|
||||
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(str);
|
||||
// 降级实现
|
||||
var out = [], ch, i, code;
|
||||
for (i = 0; i < str.length; i++) {
|
||||
code = str.charCodeAt(i);
|
||||
if (code < 0x80) out.push(code);
|
||||
else if (code < 0x800) {
|
||||
out.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
|
||||
} else if (code < 0xd800 || code >= 0xe000) {
|
||||
out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
||||
} else {
|
||||
// surrogate pair
|
||||
ch = (code - 0xd800) * 0x400 + (str.charCodeAt(++i) - 0xdc00) + 0x10000;
|
||||
out.push(0xf0 | (ch >> 18), 0x80 | ((ch >> 12) & 0x3f), 0x80 | ((ch >> 6) & 0x3f), 0x80 | (ch & 0x3f));
|
||||
}
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
function utf8Decode(bytes) {
|
||||
if (typeof TextDecoder !== 'undefined') return new TextDecoder('utf-8').decode(bytes);
|
||||
var out = '', i = 0, b1, b2, b3, b4, code;
|
||||
while (i < bytes.length) {
|
||||
b1 = bytes[i++];
|
||||
if (b1 < 0x80) { out += String.fromCharCode(b1); }
|
||||
else if (b1 < 0xe0) { b2 = bytes[i++]; out += String.fromCharCode(((b1 & 0x1f) << 6) | (b2 & 0x3f)); }
|
||||
else if (b1 < 0xf0) {
|
||||
b2 = bytes[i++]; b3 = bytes[i++];
|
||||
out += String.fromCharCode(((b1 & 0x0f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f));
|
||||
} else {
|
||||
b2 = bytes[i++]; b3 = bytes[i++]; b4 = bytes[i++];
|
||||
code = ((b1 & 0x07) << 18) | ((b2 & 0x3f) << 12) | ((b3 & 0x3f) << 6) | (b4 & 0x3f);
|
||||
code -= 0x10000;
|
||||
out += String.fromCharCode(0xd800 + (code >> 10), 0xdc00 + (code & 0x3ff));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToBin(bytes) {
|
||||
var bin = '', i;
|
||||
for (i = 0; i < bytes.length; i++) {
|
||||
bin += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return bin;
|
||||
}
|
||||
|
||||
function binToBytes(bin) {
|
||||
var out = new Uint8Array(bin.length), i;
|
||||
for (i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- Base64(UTF-8 安全) ---------- */
|
||||
function b64Encode(str) {
|
||||
var bytes = utf8Encode(str);
|
||||
// 浏览器环境用 btoa
|
||||
if (typeof btoa !== 'undefined') {
|
||||
var bin = '';
|
||||
for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
return global.Base64 && global.Base64.encode(bytesToBin(bytes)) ? global.Base64.encode(bytesToBin(bytes)) : '';
|
||||
}
|
||||
|
||||
function b64Decode(b64) {
|
||||
b64 = String(b64 || '').replace(/\s+/g, '');
|
||||
var bin;
|
||||
if (typeof atob !== 'undefined') {
|
||||
bin = atob(b64);
|
||||
} else if (global.Base64) {
|
||||
bin = global.Base64.decode(b64);
|
||||
} else {
|
||||
throw new Error('当前环境不支持 Base64 解码');
|
||||
}
|
||||
return utf8Decode(binToBytes(bin));
|
||||
}
|
||||
|
||||
/* ---------- Base32(RFC 4648,UTF-8 安全) ---------- */
|
||||
var B32_ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
function base32Encode(str) {
|
||||
var bytes = utf8Encode(str), out = '', bits = 0, value = 0, i;
|
||||
for (i = 0; i < bytes.length; i++) {
|
||||
value = (value << 8) | bytes[i];
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += B32_ALPHA[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += B32_ALPHA[(value << (5 - bits)) & 31];
|
||||
while (out.length % 8 !== 0) out += '=';
|
||||
return out;
|
||||
}
|
||||
|
||||
function base32Decode(b32) {
|
||||
b32 = String(b32 || '').toUpperCase().replace(/[=\s]/g, '');
|
||||
if (!b32) return '';
|
||||
var bits = 0, value = 0, out = [], i, idx, b;
|
||||
for (i = 0; i < b32.length; i++) {
|
||||
idx = B32_ALPHA.indexOf(b32[i]);
|
||||
if (idx === -1) throw new Error('Base32 字符串包含非法字符: ' + b32[i]);
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
b = (value >>> (bits - 8)) & 0xff;
|
||||
out.push(b);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return utf8Decode(new Uint8Array(out));
|
||||
}
|
||||
|
||||
/* ---------- URL 编解码 ---------- */
|
||||
function urlEncode(str) { return encodeURIComponent(String(str)); }
|
||||
function urlDecode(str) { return decodeURIComponent(String(str).replace(/\+/g, ' ')); }
|
||||
|
||||
/* ---------- Unicode 编解码(支持中文、emoji) ---------- */
|
||||
function unicodeEncode(str) {
|
||||
str = String(str);
|
||||
var out = [], i, code;
|
||||
for (i = 0; i < str.length; i++) {
|
||||
code = str.charCodeAt(i);
|
||||
if (code >= 0xd800 && code <= 0xdbff && i + 1 < str.length) {
|
||||
// 代理对:按两个 \uXXXX 输出,JS 自动组合
|
||||
out.push('\\u' + code.toString(16).toUpperCase().padStart(4, '0'));
|
||||
out.push('\\u' + str.charCodeAt(++i).toString(16).toUpperCase().padStart(4, '0'));
|
||||
} else {
|
||||
out.push('\\u' + code.toString(16).toUpperCase().padStart(4, '0'));
|
||||
}
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function unicodeDecode(str) {
|
||||
str = String(str);
|
||||
// 兼容 \u{1F600} 与 \uXXXX 两种写法
|
||||
str = str.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, function (m, h) {
|
||||
var cp = parseInt(h, 16);
|
||||
if (cp > 0xffff) {
|
||||
cp -= 0x10000;
|
||||
return String.fromCharCode(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff));
|
||||
}
|
||||
return String.fromCharCode(cp);
|
||||
});
|
||||
str = str.replace(/\\u([0-9a-fA-F]{4})/g, function (m, h) {
|
||||
return String.fromCharCode(parseInt(h, 16));
|
||||
});
|
||||
return str;
|
||||
}
|
||||
|
||||
/* ---------- SHA 系列(Web Crypto 原生) ---------- */
|
||||
var HASHES = { 'SHA-1': 'SHA-1', 'SHA-256': 'SHA-256', 'SHA-384': 'SHA-384', 'SHA-512': 'SHA-512' };
|
||||
|
||||
function hexFromBuffer(buf) {
|
||||
var bytes = new Uint8Array(buf), out = '', i;
|
||||
for (i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, '0');
|
||||
return out;
|
||||
}
|
||||
|
||||
function shaDigest(algorithm, text) {
|
||||
var bytes = utf8Encode(text);
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
return crypto.subtle.digest(algorithm, bytes).then(hexFromBuffer);
|
||||
}
|
||||
// 非安全上下文(如普通 http)无 Web Crypto:回退 crypto-js
|
||||
return cryptoJsDigest(algorithm, text);
|
||||
}
|
||||
|
||||
/* ---------- CryptoJS 懒加载(MD5 / SHA-224 等需要) ---------- */
|
||||
var CDN_CRYPTO_JS = [
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js',
|
||||
'https://cdn.jsdelivr.net/npm/crypto-js@4.2.0/crypto-js.min.js'
|
||||
];
|
||||
|
||||
var cryptoJSPromise = null;
|
||||
function loadCryptoJS() {
|
||||
if (global.CryptoJS) return Promise.resolve(global.CryptoJS);
|
||||
if (cryptoJSPromise) return cryptoJSPromise;
|
||||
cryptoJSPromise = new Promise(function (resolve) {
|
||||
var load = function (idx) {
|
||||
if (global.CryptoJS) { resolve(global.CryptoJS); return; }
|
||||
if (idx >= CDN_CRYPTO_JS.length) { resolve(null); return; }
|
||||
var s = document.createElement('script');
|
||||
s.src = CDN_CRYPTO_JS[idx];
|
||||
s.onload = function () { resolve(global.CryptoJS || null); };
|
||||
s.onerror = function () { load(idx + 1); };
|
||||
document.head.appendChild(s);
|
||||
};
|
||||
load(0);
|
||||
});
|
||||
return cryptoJSPromise;
|
||||
}
|
||||
|
||||
var CRYPTO_JS_METHODS = {
|
||||
'MD5': 'MD5', 'SHA-1': 'SHA1', 'SHA-224': 'SHA224',
|
||||
'SHA-256': 'SHA256', 'SHA-384': 'SHA384', 'SHA-512': 'SHA512'
|
||||
};
|
||||
|
||||
function cryptoJsDigest(algo, text) {
|
||||
return loadCryptoJS().then(function (C) {
|
||||
if (!C) throw new Error('计算 ' + algo + ' 需要联网加载加密组件(crypto-js)失败,请检查网络');
|
||||
var method = CRYPTO_JS_METHODS[algo];
|
||||
if (!method || typeof C[method] !== 'function') throw new Error('不支持的算法: ' + algo);
|
||||
return C[method](text).toString(C.enc.Hex);
|
||||
});
|
||||
}
|
||||
|
||||
function hashText(algo, text) {
|
||||
if (HASHES[algo]) return shaDigest(HASHES[algo], text);
|
||||
if (algo === 'MD5' || algo === 'SHA-224') return cryptoJsDigest(algo, text);
|
||||
return Promise.reject(new Error('未知算法: ' + algo));
|
||||
}
|
||||
|
||||
/* ---------- 导出 ---------- */
|
||||
global.CodecLib = {
|
||||
utf8Encode: utf8Encode,
|
||||
utf8Decode: utf8Decode,
|
||||
b64Encode: b64Encode,
|
||||
b64Decode: b64Decode,
|
||||
base32Encode: base32Encode,
|
||||
base32Decode: base32Decode,
|
||||
urlEncode: urlEncode,
|
||||
urlDecode: urlDecode,
|
||||
unicodeEncode: unicodeEncode,
|
||||
unicodeDecode: unicodeDecode,
|
||||
hashText: hashText,
|
||||
hashes: ['MD5', 'SHA-1', 'SHA-224', 'SHA-256', 'SHA-384', 'SHA-512']
|
||||
};
|
||||
})(window);
|
||||
308
assets/js/common.js
Normal file
308
assets/js/common.js
Normal file
@ -0,0 +1,308 @@
|
||||
/* common.js —— 全站通用交互
|
||||
* 功能:昼/夜主题切换、实时时钟、随机名言刷新、四大板块 hover 浮层、
|
||||
* 搜索栏(引擎下拉/清除/回车搜索)、复制到剪贴板、DOM 工具函数
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
/* ---------- 工具 ---------- */
|
||||
function $(sel, root) { return (root || document).querySelector(sel); }
|
||||
function $$(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }
|
||||
|
||||
/* ---------- 主题(昼/夜) ---------- */
|
||||
var THEME_KEY = 'hp-theme';
|
||||
|
||||
function currentTheme() {
|
||||
var saved = null;
|
||||
try { saved = localStorage.getItem(THEME_KEY); } catch (e) { /* ignore */ }
|
||||
if (saved === 'light' || saved === 'dark') return saved;
|
||||
// 未设置:跟随系统
|
||||
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function applyTheme(t, persist) {
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
if (persist) {
|
||||
try { localStorage.setItem(THEME_KEY, t); } catch (e) { /* ignore */ }
|
||||
}
|
||||
var icons = $$('.js-theme-icon');
|
||||
icons.forEach(function (el) { el.textContent = (t === 'dark') ? '☀' : '☾'; });
|
||||
var btn = $('.js-theme-btn');
|
||||
if (btn) btn.title = (t === 'dark') ? '切换到白天模式' : '切换到黑夜模式';
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
applyTheme(currentTheme(), false);
|
||||
$$('.js-theme-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var next = (document.documentElement.getAttribute('data-theme') === 'dark') ? 'light' : 'dark';
|
||||
applyTheme(next, true);
|
||||
});
|
||||
});
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
|
||||
// 用户手动选过则不跟随系统变化
|
||||
try {
|
||||
if (!localStorage.getItem(THEME_KEY)) applyTheme(currentTheme(), false);
|
||||
} catch (e) { applyTheme(currentTheme(), false); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 实时时钟 ---------- */
|
||||
function pad(n) { return (n < 10 ? '0' : '') + n; }
|
||||
function formatTime(d) {
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' +
|
||||
pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||||
}
|
||||
function initClocks() {
|
||||
function tick() { $$('.js-clock').forEach(function (el) { el.textContent = formatTime(new Date()); }); }
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
/* ---------- 名言条:点击格言本身切换 ---------- */
|
||||
function initQuoteRefresh() {
|
||||
$$('.js-quote').forEach(function (quote) {
|
||||
quote.addEventListener('click', function () {
|
||||
var list = $$('.js-quote-pool span', document); // 服务端把所有名言放入隐藏池
|
||||
if (!list.length) return;
|
||||
// 定位当前展示文本在池中的位置,避免换到下一条仍是同一条
|
||||
var currentText = quote.textContent;
|
||||
var cur = -1;
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (list[i].textContent === currentText) { cur = i; break; }
|
||||
}
|
||||
var n = list.length;
|
||||
var idx = Math.floor(Math.random() * n);
|
||||
if (n > 1 && cur !== -1) {
|
||||
while (idx === cur) idx = (idx + 1) % n;
|
||||
}
|
||||
quote.textContent = list[idx].textContent;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 首页四大板块 hover 展开说明 ---------- */
|
||||
function initBoardHover() {
|
||||
var section = $('.js-boards');
|
||||
if (!section) return;
|
||||
var cards = $$('.js-board-card', section);
|
||||
var overlay = $('.js-board-panel', section);
|
||||
if (!cards.length || !overlay) return;
|
||||
var titleEl = $('.js-board-panel-title', overlay);
|
||||
var descEl = $('.js-board-panel-desc', overlay);
|
||||
var bodyEl = section;
|
||||
|
||||
function showPanel(card) {
|
||||
var name = card.getAttribute('data-name') || '';
|
||||
var desc = card.getAttribute('data-desc') || '';
|
||||
var idx = cards.indexOf(card);
|
||||
if (titleEl) titleEl.textContent = name;
|
||||
if (descEl) descEl.textContent = desc;
|
||||
overlay.classList.remove('panel-left', 'panel-right');
|
||||
// 第 0 / 1 块吸附左侧边缘,第 2 / 3 块吸附右侧边缘(实际板块位置不变)
|
||||
if (idx === 0 || idx === 1) overlay.classList.add('panel-left');
|
||||
else overlay.classList.add('panel-right');
|
||||
overlay.classList.add('visible');
|
||||
}
|
||||
|
||||
cards.forEach(function (card) {
|
||||
card.addEventListener('mouseenter', function () { showPanel(card); });
|
||||
card.addEventListener('click', function () { /* 点击由 <a> 包裹,这里无额外处理 */ });
|
||||
});
|
||||
section.addEventListener('mouseleave', function () { overlay.classList.remove('visible'); });
|
||||
}
|
||||
|
||||
/* ---------- 搜索栏 ---------- */
|
||||
var DEFAULT_ENGINES = [
|
||||
{ key: 'bing', name: 'Bing', icon: 'B', url: 'https://www.bing.com/search?q={kw}' },
|
||||
{ key: 'baidu', name: '百度', icon: '度', url: 'https://www.baidu.com/s?wd={kw}' },
|
||||
{ key: 'github', name: 'GitHub', icon: 'G', url: 'https://github.com/search?q={kw}' },
|
||||
{ key: 'google', name: 'Google', icon: 'G', url: 'https://www.google.com/search?q={kw}' }
|
||||
];
|
||||
|
||||
function initSearch() {
|
||||
var box = $('.js-search');
|
||||
if (!box) return;
|
||||
var cfg = null;
|
||||
try { cfg = JSON.parse(box.getAttribute('data-engines') || 'null'); } catch (e) { cfg = null; }
|
||||
var engines = (cfg && cfg.length) ? cfg : DEFAULT_ENGINES;
|
||||
var curKey = box.getAttribute('data-engine') || engines[0].key;
|
||||
var cur = engines.filter(function (e) { return e.key === curKey; })[0] || engines[0];
|
||||
|
||||
var input = $('.js-search-input', box);
|
||||
var clearBtn = $('.js-search-clear', box);
|
||||
var engineBtn = $('.js-engine-btn', box);
|
||||
var engineName = $('.js-engine-name', box);
|
||||
var menu = $('.js-engine-menu', box);
|
||||
|
||||
function setEngine(e) {
|
||||
cur = e;
|
||||
// 按钮区域只显示图标(全称在展开下拉列表时展示)
|
||||
if (engineName) engineName.textContent = e.icon || '';
|
||||
if (engineBtn) engineBtn.title = '搜索引擎:' + e.name;
|
||||
box.setAttribute('data-engine', e.key);
|
||||
hideMenu();
|
||||
}
|
||||
function showMenu() {
|
||||
if (!menu) return;
|
||||
if (!menu.children.length) {
|
||||
engines.forEach(function (e) {
|
||||
var li = document.createElement('li');
|
||||
li.textContent = e.icon + ' ' + e.name;
|
||||
li.className = (e.key === cur.key) ? 'active' : '';
|
||||
li.addEventListener('click', function () { setEngine(e); });
|
||||
menu.appendChild(li);
|
||||
});
|
||||
}
|
||||
menu.classList.add('open');
|
||||
}
|
||||
function hideMenu() { if (menu) menu.classList.remove('open'); }
|
||||
|
||||
function updateClear() {
|
||||
if (clearBtn) clearBtn.style.visibility = (input && input.value) ? 'visible' : 'hidden';
|
||||
}
|
||||
function doSearch() {
|
||||
var kw = (input ? input.value : '').trim();
|
||||
if (!kw) { if (input) input.focus(); return; }
|
||||
window.open(cur.url.replace('{kw}', encodeURIComponent(kw)), '_blank', 'noopener');
|
||||
}
|
||||
|
||||
if (engineBtn) engineBtn.addEventListener('click', function (ev) {
|
||||
ev.stopPropagation();
|
||||
if (menu && menu.classList.contains('open')) hideMenu(); else showMenu();
|
||||
});
|
||||
if (clearBtn) clearBtn.addEventListener('click', function () {
|
||||
if (input) { input.value = ''; input.focus(); }
|
||||
updateClear();
|
||||
});
|
||||
if (input) {
|
||||
input.addEventListener('input', updateClear);
|
||||
input.addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doSearch(); });
|
||||
}
|
||||
document.addEventListener('click', function () { hideMenu(); });
|
||||
var goBtn = $('.js-search-go', box);
|
||||
if (goBtn) goBtn.addEventListener('click', doSearch);
|
||||
setEngine(cur);
|
||||
updateClear();
|
||||
}
|
||||
|
||||
/* ---------- 后台图标控件:文本 / 图片上传(≤200KB → dataURL) ---------- */
|
||||
function isIconImage(v) {
|
||||
v = String(v || '').trim();
|
||||
if (!v) return false;
|
||||
if (/^data:image\//i.test(v) || /^https?:\/\//i.test(v)) return true;
|
||||
if (/\.(png|jpe?g|gif|svg|webp)(\?|#|$)/i.test(v)) return true;
|
||||
return v.charAt(0) === '/' || v.indexOf('./') === 0 || v.indexOf('../') === 0;
|
||||
}
|
||||
function updateIconPreview(ctl) {
|
||||
var input = ctl.querySelector('.icon-input');
|
||||
var box = ctl.querySelector('.icon-preview');
|
||||
if (!input || !box) return;
|
||||
var v = input.value.trim();
|
||||
box.innerHTML = '';
|
||||
if (!v) return; // 空:显示空心占位框
|
||||
if (isIconImage(v)) {
|
||||
var img = document.createElement('img');
|
||||
img.src = v;
|
||||
img.alt = '';
|
||||
img.loading = 'lazy';
|
||||
box.appendChild(img);
|
||||
} else {
|
||||
box.textContent = v;
|
||||
}
|
||||
}
|
||||
function initIconControls() {
|
||||
// 点击“上传”→ 触发隐藏 file
|
||||
document.addEventListener('click', function (ev) {
|
||||
var btn = ev.target && ev.target.closest ? ev.target.closest('.icon-upload') : null;
|
||||
if (!btn) return;
|
||||
var ctl = btn.closest('.icon-ctl');
|
||||
var file = ctl ? ctl.querySelector('.icon-file') : null;
|
||||
if (file) file.click();
|
||||
});
|
||||
// 选择图片 → 校验并转 dataURL 写入同控件文本框
|
||||
document.addEventListener('change', function (ev) {
|
||||
var file = ev.target;
|
||||
if (!file || !file.classList || !file.classList.contains('icon-file')) return;
|
||||
var ctl = file.closest('.icon-ctl');
|
||||
var input = ctl ? ctl.querySelector('.icon-input') : null;
|
||||
var f = file.files && file.files[0];
|
||||
function reset() { file.value = ''; }
|
||||
if (!f) return;
|
||||
if (f.type.indexOf('image/') !== 0) { alert('请选择图片文件。'); reset(); return; }
|
||||
if (f.size > 200 * 1024) { alert('图片不能超过 200KB。'); reset(); return; }
|
||||
var rd = new FileReader();
|
||||
rd.onload = function () {
|
||||
if (input) {
|
||||
input.value = rd.result;
|
||||
updateIconPreview(ctl);
|
||||
}
|
||||
reset();
|
||||
};
|
||||
rd.onerror = function () { alert('图片读取失败,请重试。'); reset(); };
|
||||
rd.readAsDataURL(f);
|
||||
});
|
||||
// 手动输入文本 → 实时预览
|
||||
document.addEventListener('input', function (ev) {
|
||||
var t = ev.target;
|
||||
if (t && t.classList && t.classList.contains('icon-input')) {
|
||||
updateIconPreview(t.closest('.icon-ctl'));
|
||||
}
|
||||
});
|
||||
// 初始化页面既有控件预览
|
||||
$$('.icon-ctl').forEach(updateIconPreview);
|
||||
}
|
||||
|
||||
/* ---------- 复制 ---------- */
|
||||
function copyText(text, btnEl) {
|
||||
function done(ok) {
|
||||
if (!btnEl) return;
|
||||
var old = btnEl.textContent;
|
||||
btnEl.textContent = ok ? '✓ 已复制' : '复制失败';
|
||||
setTimeout(function () { btnEl.textContent = old; }, 1500);
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(function () { done(true); }, function () { fallbackCopy(text, done); });
|
||||
} else {
|
||||
fallbackCopy(text, done);
|
||||
}
|
||||
}
|
||||
function fallbackCopy(text, done) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;opacity:0;';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { done(document.execCommand('copy')); } catch (e) { done(false); }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
function initCopyButtons() {
|
||||
document.addEventListener('click', function (ev) {
|
||||
var btn = ev.target.closest ? ev.target.closest('.js-copy') : null;
|
||||
if (!btn) return;
|
||||
var text = btn.getAttribute('data-copy');
|
||||
if (text === null || text === undefined) {
|
||||
var target = $(btn.getAttribute('data-target') || '');
|
||||
text = target ? target.value : '';
|
||||
}
|
||||
copyText(text, btn);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 自动初始化 ---------- */
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initTheme();
|
||||
initClocks();
|
||||
initQuoteRefresh();
|
||||
initBoardHover();
|
||||
initSearch();
|
||||
initIconControls();
|
||||
initCopyButtons();
|
||||
});
|
||||
|
||||
global.HomePage = {
|
||||
$: $, $$: $$, copyText: copyText, applyTheme: applyTheme, currentTheme: currentTheme
|
||||
};
|
||||
})(window);
|
||||
206
assets/js/gmcrypto.js
Normal file
206
assets/js/gmcrypto.js
Normal file
@ -0,0 +1,206 @@
|
||||
/* gmcrypto.js —— 国密 SM2 / SM3 / SM4 前端封装
|
||||
* SM1 为国密硬件算法(不公开、依赖专用加密芯片),无法在浏览器纯软件实现,
|
||||
* 故本页提供可在网页中直接使用的 SM2(非对称)/ SM3(摘要)/ SM4(对称分组)。
|
||||
* 底层使用 sm-crypto@0.3.13,运行时按需双 CDN 懒加载(jsdelivr → unpkg)。
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var VERSION = '0.3.13';
|
||||
var LIBS = {
|
||||
sm2: {
|
||||
file: 'sm2.js', key: 'sm2',
|
||||
urls: [
|
||||
'https://cdn.jsdelivr.net/npm/sm-crypto@' + VERSION + '/dist/sm2.js',
|
||||
'https://unpkg.com/sm-crypto@' + VERSION + '/dist/sm2.js'
|
||||
]
|
||||
},
|
||||
sm3: {
|
||||
file: 'sm3.js', key: 'sm3',
|
||||
urls: [
|
||||
'https://cdn.jsdelivr.net/npm/sm-crypto@' + VERSION + '/dist/sm3.js',
|
||||
'https://unpkg.com/sm-crypto@' + VERSION + '/dist/sm3.js'
|
||||
]
|
||||
},
|
||||
sm4: {
|
||||
file: 'sm4.js', key: 'sm4',
|
||||
urls: [
|
||||
'https://cdn.jsdelivr.net/npm/sm-crypto@' + VERSION + '/dist/sm4.js',
|
||||
'https://unpkg.com/sm-crypto@' + VERSION + '/dist/sm4.js'
|
||||
]
|
||||
}
|
||||
};
|
||||
var loadCache = {};
|
||||
|
||||
/** 加载指定算法库(返回 Promise<库对象|null>) */
|
||||
function loadLib(name) {
|
||||
var lib = LIBS[name];
|
||||
if (!lib) return Promise.resolve(null);
|
||||
if (global[lib.key]) return Promise.resolve(global[lib.key]);
|
||||
if (loadCache[name]) return loadCache[name];
|
||||
loadCache[name] = new Promise(function (resolve) {
|
||||
var load = function (idx) {
|
||||
if (global[lib.key]) { resolve(global[lib.key]); return; }
|
||||
if (idx >= lib.urls.length) { resolve(null); return; }
|
||||
var s = document.createElement('script');
|
||||
s.src = lib.urls[idx];
|
||||
s.onload = function () { resolve(global[lib.key] || null); };
|
||||
s.onerror = function () { load(idx + 1); };
|
||||
document.head.appendChild(s);
|
||||
};
|
||||
load(0);
|
||||
});
|
||||
return loadCache[name];
|
||||
}
|
||||
|
||||
/* ---------- 基础编码工具 ---------- */
|
||||
function utf8Bytes(str) {
|
||||
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(String(str));
|
||||
var out = [], code, i;
|
||||
for (i = 0; i < str.length; i++) {
|
||||
code = str.charCodeAt(i);
|
||||
if (code < 0x80) out.push(code);
|
||||
else if (code < 0x800) out.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
|
||||
else if (code < 0xd800 || code >= 0xe000) {
|
||||
out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
||||
} else {
|
||||
var ch = (code - 0xd800) * 0x400 + (str.charCodeAt(++i) - 0xdc00) + 0x10000;
|
||||
out.push(0xf0 | (ch >> 18), 0x80 | ((ch >> 12) & 0x3f), 0x80 | ((ch >> 6) & 0x3f), 0x80 | (ch & 0x3f));
|
||||
}
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
function hexToBytes(hexStr) {
|
||||
var h = String(hexStr).replace(/[^0-9a-fA-F]/g, '');
|
||||
if (h === '' || h.length % 2 !== 0) throw new Error('十六进制串长度需为偶数');
|
||||
var out = new Uint8Array(h.length / 2), i;
|
||||
for (i = 0; i < out.length; i++) {
|
||||
out[i] = parseInt(h.substr(i * 2, 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToHex(bytes) {
|
||||
var s = '', i, t;
|
||||
for (i = 0; i < bytes.length; i++) {
|
||||
t = bytes[i].toString(16);
|
||||
s += t.length < 2 ? '0' + t : t;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function bytesToB64(bytes) {
|
||||
var s = '', i;
|
||||
for (i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||
return global.btoa(s);
|
||||
}
|
||||
|
||||
function b64ToBytes(b64) {
|
||||
var s = global.atob(String(b64).trim());
|
||||
var out = new Uint8Array(s.length), i;
|
||||
for (i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function randomHex(bytesLen) {
|
||||
var c = global.crypto || global.msCrypto;
|
||||
var out = new Uint8Array(bytesLen), i;
|
||||
if (c && c.getRandomValues) {
|
||||
c.getRandomValues(out);
|
||||
} else {
|
||||
for (i = 0; i < bytesLen; i++) out[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return bytesToHex(out);
|
||||
}
|
||||
|
||||
/* ---------- SM4 密钥 / IV 规范化 ---------- */
|
||||
function sm4KeyBytes(keyStr) {
|
||||
var s = String(keyStr || '').trim();
|
||||
if (!s) throw new Error('请输入 SM4 密钥');
|
||||
if (/^[0-9a-fA-F]{32}$/.test(s)) return hexToBytes(s);
|
||||
var b = utf8Bytes(s);
|
||||
if (b.length === 16) return b;
|
||||
throw new Error('SM4 密钥需为 128 位(16 字节):可输入 32 位十六进制串,或恰好 16 个 ASCII 字符');
|
||||
}
|
||||
|
||||
function sm4IvHex(ivStr) {
|
||||
var s = String(ivStr || '').trim();
|
||||
if (/^[0-9a-fA-F]{32}$/.test(s)) return s;
|
||||
var b = utf8Bytes(s);
|
||||
if (b.length === 16) return bytesToHex(b);
|
||||
throw new Error('SM4 CBC 模式需要 16 字节 IV(32 位十六进制或恰好 16 个字符)');
|
||||
}
|
||||
|
||||
/* ---------- 业务 Promise ---------- */
|
||||
function asArray(bytes) { return Array.prototype.slice.call(bytes); }
|
||||
|
||||
function need(name) {
|
||||
return loadLib(name).then(function (lib) {
|
||||
if (!lib) throw new Error('国密 ' + name.toUpperCase() + ' 组件加载失败,请检查网络后重试');
|
||||
return lib;
|
||||
});
|
||||
}
|
||||
|
||||
function sm3Digest(text, hmacKeyHex) {
|
||||
return need('sm3').then(function (sm3) {
|
||||
if (hmacKeyHex) return sm3(String(text), { key: hmacKeyHex });
|
||||
return sm3(String(text));
|
||||
});
|
||||
}
|
||||
|
||||
function sm4Run(text, keyStr, mode, ivStr, isEncrypt) {
|
||||
return need('sm4').then(function (sm4) {
|
||||
var kb = asArray(sm4KeyBytes(keyStr));
|
||||
var opt = {};
|
||||
if (mode === 'cbc') opt = { mode: 'cbc', iv: sm4IvHex(ivStr) };
|
||||
if (isEncrypt) {
|
||||
var hex = sm4.encrypt(String(text), kb, opt);
|
||||
return { ok: true, hex: hex };
|
||||
}
|
||||
var raw = String(text).trim().replace(/\s+/g, '');
|
||||
var inArr;
|
||||
if (/^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0) inArr = asArray(hexToBytes(raw));
|
||||
else inArr = asArray(b64ToBytes(raw));
|
||||
var out = sm4.decrypt(inArr, kb, opt);
|
||||
return { ok: true, hex: '', text: out };
|
||||
});
|
||||
}
|
||||
|
||||
function sm2GenKey() {
|
||||
return need('sm2').then(function (sm2) {
|
||||
var kp = sm2.generateKeyPairHex();
|
||||
return { publicKey: kp.publicKey, privateKey: kp.privateKey };
|
||||
});
|
||||
}
|
||||
|
||||
function sm2Encrypt(text, publicKey) {
|
||||
return need('sm2').then(function (sm2) {
|
||||
if (!String(publicKey || '').trim()) throw new Error('SM2 加密请填写对方公钥');
|
||||
return sm2.doEncrypt(String(text), String(publicKey).trim(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
function sm2Decrypt(cipherHex, privateKey) {
|
||||
return need('sm2').then(function (sm2) {
|
||||
if (!String(privateKey || '').trim()) throw new Error('SM2 解密请填写自己的私钥');
|
||||
return sm2.doDecrypt(String(cipherHex).trim().replace(/\s+/g, ''), String(privateKey).trim(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 导出 ---------- */
|
||||
global.GmCrypto = {
|
||||
load: loadLib,
|
||||
utf8Bytes: utf8Bytes,
|
||||
hexToBytes: hexToBytes,
|
||||
bytesToHex: bytesToHex,
|
||||
bytesToB64: bytesToB64,
|
||||
b64ToBytes: b64ToBytes,
|
||||
randomHex: randomHex,
|
||||
sm3: sm3Digest,
|
||||
sm4: sm4Run,
|
||||
sm2Key: sm2GenKey,
|
||||
sm2Encrypt: sm2Encrypt,
|
||||
sm2Decrypt: sm2Decrypt
|
||||
};
|
||||
})(window);
|
||||
289
assets/js/ip.js
Normal file
289
assets/js/ip.js
Normal file
@ -0,0 +1,289 @@
|
||||
/* ip.js —— IP 地址处理
|
||||
* 提取输入文本中全部 IPv4 → 按 /24 段聚合输出(文本 / 高亮预览):
|
||||
* IP段 发现数量(段内去重IP数) IP段性质
|
||||
* 1.2.3.0/24 8 公网
|
||||
* 1.2.3.4
|
||||
*
|
||||
* 「重点匹配 IP」:填写的 IP 命中后,其所在 /24 段优先置顶、对应 IP 红色标出。
|
||||
* CDN 段库由服务端通过 #ipCdnRaw 隐藏文本框注入,可在后台维护。
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
// 内网 / 保留地址段(IPv4)
|
||||
var PRIV_RANGES = [
|
||||
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
||||
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.168.0.0', 16],
|
||||
['192.0.2.0', 24], ['198.51.100.0', 24], ['203.0.113.0', 24],
|
||||
['224.0.0.0', 4], ['240.0.0.0', 4]
|
||||
].map(function (r) {
|
||||
return { net: ipToInt(r[0]), prefix: r[1] };
|
||||
});
|
||||
|
||||
function ipToInt(ip) {
|
||||
var p = String(ip).split('.');
|
||||
return (((+p[0]) << 24) + ((+p[1]) << 16) + ((+p[2]) << 8) + (+p[3])) >>> 0;
|
||||
}
|
||||
function inRange(ipInt, net, prefix) {
|
||||
var mask = (prefix === 0) ? 0 : ((0xFFFFFFFF << (32 - prefix)) >>> 0);
|
||||
return ((ipInt & mask) >>> 0) === ((net & mask) >>> 0);
|
||||
}
|
||||
function isPrivate(ipInt) {
|
||||
for (var i = 0; i < PRIV_RANGES.length; i++) {
|
||||
if (inRange(ipInt, PRIV_RANGES[i].net, PRIV_RANGES[i].prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ---------- CDN 段库解析 ---------- */
|
||||
function parseCdnText(text) {
|
||||
var out = [], lines = String(text || '').split(/\r?\n/), i, m, p;
|
||||
for (i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].trim();
|
||||
if (!line || line.charAt(0) === '#') continue;
|
||||
p = line.split(/\s+/);
|
||||
m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/.exec(p[0]);
|
||||
if (!m) continue;
|
||||
var a = +m[1], b = +m[2], c = +m[3], d = +m[4], pre = +m[5];
|
||||
if (a > 255 || b > 255 || c > 255 || d > 255 || pre > 32) continue;
|
||||
out.push({
|
||||
net: (((a << 24) + (b << 16) + (c << 8) + d) >>> 0),
|
||||
prefix: pre,
|
||||
label: p.slice(1).join(' ') || 'CDN'
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function cdnOf(ipInt, ranges) {
|
||||
for (var i = 0; i < ranges.length; i++) {
|
||||
if (inRange(ipInt, ranges[i].net, ranges[i].prefix)) return ranges[i].label;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- IPv4 提取 ---------- */
|
||||
function extractIps(text) {
|
||||
var re = /(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})/g, m, found = [];
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
var a = +m[1], b = +m[2], c = +m[3], d = +m[4];
|
||||
if (a > 255 || b > 255 || c > 255 || d > 255) continue;
|
||||
found.push(a + '.' + b + '.' + c + '.' + d);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/* ---------- 重点 IP 解析 ---------- */
|
||||
function parseFocus(text) {
|
||||
var set = {};
|
||||
var parts = String(text || '').split(/[\s,,;;]+/);
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var p = parts[i].trim();
|
||||
if (!p) continue;
|
||||
var m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(p);
|
||||
if (!m) continue;
|
||||
if (+m[1] > 255 || +m[2] > 255 || +m[3] > 255 || +m[4] > 255) continue;
|
||||
set[m[1] + '.' + m[2] + '.' + m[3] + '.' + m[4]] = true;
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
function ipLess(a, b) {
|
||||
var aa = a.split('.').map(Number), bb = b.split('.').map(Number), k;
|
||||
for (k = 0; k < 4; k++) {
|
||||
if (aa[k] !== bb[k]) return aa[k] - bb[k];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function keyLess(a, b) {
|
||||
var aa = a.split('.').map(Number), bb = b.split('.').map(Number), k;
|
||||
for (k = 0; k < 3; k++) {
|
||||
if (aa[k] !== bb[k]) return aa[k] - bb[k];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
var TYPE_ZH = { priv: '内网', cdn: 'CDN', pub: '公网' };
|
||||
|
||||
/* 返回 { segs:[{line, hit, ips:[{ip,hit}]}], segTotals, totalIps, totalSeg, hitSeg, hitIps, rangeCount } */
|
||||
function analyze(text, cdnRaw, focusSet) {
|
||||
var all = extractIps(text);
|
||||
if (!all.length) return null;
|
||||
var ranges = parseCdnText(cdnRaw);
|
||||
|
||||
var segMap = {}, segOrder = [];
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
var ip = all[i];
|
||||
var key = ip.substring(0, ip.lastIndexOf('.'));
|
||||
if (!segMap[key]) {
|
||||
segMap[key] = { key: key, ips: [], classes: {} };
|
||||
segOrder.push(key);
|
||||
}
|
||||
var seg = segMap[key];
|
||||
if (seg.ips.indexOf(ip) === -1) {
|
||||
seg.ips.push(ip);
|
||||
var int = ipToInt(ip);
|
||||
var vendor = cdnOf(int, ranges);
|
||||
var cls = vendor ? 'cdn' : (isPrivate(int) ? 'priv' : 'pub');
|
||||
seg.classes[cls] = (seg.classes[cls] || 0) + 1;
|
||||
}
|
||||
}
|
||||
segOrder.sort(keyLess);
|
||||
|
||||
function segLabel(s) {
|
||||
var keys = Object.keys(s.classes);
|
||||
if (keys.length === 1) return TYPE_ZH[keys[0]];
|
||||
var best = keys.slice().sort(function (x, y) {
|
||||
return (s.classes[y] - s.classes[x]) || (TYPE_ZH[x] < TYPE_ZH[y] ? -1 : 1);
|
||||
})[0];
|
||||
return TYPE_ZH[best] + '为主(混合)';
|
||||
}
|
||||
|
||||
var segs = segOrder.map(function (k) { return segMap[k]; });
|
||||
segs.forEach(function (s) { s.label = segLabel(s); });
|
||||
// 命中重点 IP 的段排前,组内段号升序
|
||||
var focusSegs = [], otherSegs = [];
|
||||
segs.forEach(function (s) {
|
||||
s.hit = s.ips.some(function (ip) { return focusSet[ip]; });
|
||||
(s.hit ? focusSegs : otherSegs).push(s);
|
||||
});
|
||||
segs = focusSegs.concat(otherSegs);
|
||||
|
||||
var totalIps = 0, hitIps = 0, hitSeg = 0;
|
||||
segs.forEach(function (s) {
|
||||
totalIps += s.ips.length;
|
||||
if (s.hit) hitSeg++;
|
||||
s.ips.sort(ipLess);
|
||||
var h = [], o = [];
|
||||
s.ips.forEach(function (ip) {
|
||||
if (focusSet[ip]) { h.push(ip); hitIps++; }
|
||||
else o.push(ip);
|
||||
});
|
||||
s.details = h.concat(o);
|
||||
s.line = s.key + '.0/24\t' + s.ips.length + '\t' + s.label;
|
||||
});
|
||||
|
||||
var cntSeg = { priv: 0, cdn: 0, pub: 0 };
|
||||
segs.forEach(function (s) {
|
||||
var lab = s.label;
|
||||
if (lab.indexOf('内网') === 0) cntSeg.priv++;
|
||||
else if (lab.indexOf('CDN') === 0) cntSeg.cdn++;
|
||||
else cntSeg.pub++;
|
||||
});
|
||||
|
||||
return {
|
||||
segs: segs,
|
||||
totalIps: totalIps,
|
||||
totalSeg: segs.length,
|
||||
hitSeg: hitSeg,
|
||||
hitIps: hitIps,
|
||||
cntSeg: cntSeg,
|
||||
rangeCount: ranges.length
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- 文本 / 高亮输出 ---------- */
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
function plainText(data) {
|
||||
var lines = ['IP段\t发现数量\tIP段性质'];
|
||||
data.segs.forEach(function (s) {
|
||||
lines.push(s.line);
|
||||
s.details.forEach(function (ip) { lines.push('\t' + ip); });
|
||||
lines.push('');
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
function previewHtml(data) {
|
||||
var parts = [];
|
||||
parts.push(esc('IP段\t发现数量\tIP段性质').replace(/\t/g, ' '));
|
||||
data.segs.forEach(function (s) {
|
||||
var lineTxt = s.line.replace(/\t/g, ' ');
|
||||
parts.push(s.hit ? '<span class="ip-seg-hit">★ ' + esc(lineTxt) + '</span>' : esc(lineTxt));
|
||||
s.details.forEach(function (ip) {
|
||||
parts.push(focusCur[ip] ? '<span class="ip-hit">' + esc(ip) + '</span>' : esc(ip));
|
||||
});
|
||||
parts.push('');
|
||||
});
|
||||
return parts.join('\n');
|
||||
}
|
||||
var focusCur = {};
|
||||
|
||||
/* ---------- 事件 ---------- */
|
||||
var lastData = null;
|
||||
function run() {
|
||||
var input = $('ipInput');
|
||||
var msg = $('ipMsg');
|
||||
msg.innerHTML = '';
|
||||
var text = input.value;
|
||||
if (!text.trim()) {
|
||||
msg.innerHTML = '<div class="err-msg">请先粘贴要分析的日志 / 文本内容。</div>';
|
||||
return;
|
||||
}
|
||||
var cdnRaw = ($('ipCdnRaw') && $('ipCdnRaw').value) || '';
|
||||
var fs = parseFocus($('ipFocus').value);
|
||||
focusCur = fs;
|
||||
lastData = analyze(text, cdnRaw, fs);
|
||||
if (!lastData) {
|
||||
$('ipResult').hidden = true;
|
||||
msg.innerHTML = '<div class="err-msg">未在输入内容中解析到有效的 IPv4 地址。</div>';
|
||||
return;
|
||||
}
|
||||
var d = lastData;
|
||||
$('ipPreview').innerHTML = previewHtml(d);
|
||||
var st = d.cntSeg;
|
||||
var note = '共提取去重 IP ' + d.totalIps + ' 个,涉及 ' + d.totalSeg + ' 个 /24 段(内网 ' +
|
||||
st.priv + ' 段 / CDN ' + st.cdn + ' 段 / 公网 ' + st.pub + ' 段)。';
|
||||
if (Object.keys(parseFocus($('ipFocus').value)).length === 0) {
|
||||
note += ' 未填写重点 IP,按原顺序输出。';
|
||||
} else if (d.hitIps === 0) {
|
||||
note += ' 填写的重点 IP 均未在输入内容中命中。';
|
||||
} else {
|
||||
note += ' 命中重点 IP ' + d.hitIps + ' 个,' + d.hitSeg + ' 个 /24 段已优先置顶。';
|
||||
}
|
||||
if (d.rangeCount === 0) note += ' CDN 段库为空,可在后台补充。';
|
||||
$('ipStat').textContent = note;
|
||||
$('ipResult').hidden = false;
|
||||
msg.innerHTML = '';
|
||||
}
|
||||
function copyText(text) {
|
||||
function done(ok) {
|
||||
var btn = $('ipCopy');
|
||||
var old = btn.textContent;
|
||||
btn.textContent = ok ? '✓ 已复制' : '复制失败';
|
||||
setTimeout(function () { btn.textContent = old; }, 1200);
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(function () { done(true); }, function () { fallback(text, done); });
|
||||
} else {
|
||||
fallback(text, done);
|
||||
}
|
||||
}
|
||||
function fallback(text, done) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;opacity:0;';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { done(document.execCommand('copy')); } catch (e) { done(false); }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
function clearAll() {
|
||||
$('ipInput').value = '';
|
||||
$('ipFocus').value = '';
|
||||
$('ipMsg').innerHTML = '';
|
||||
$('ipResult').hidden = true;
|
||||
$('ipInput').focus();
|
||||
}
|
||||
|
||||
$('ipBtn').addEventListener('click', run);
|
||||
$('ipCopy').addEventListener('click', function () {
|
||||
if (lastData) copyText(plainText(lastData));
|
||||
});
|
||||
$('ipClear').addEventListener('click', clearAll);
|
||||
$('ipInput').addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) run();
|
||||
});
|
||||
})();
|
||||
55
data/avlist.js
Normal file
55
data/avlist.js
Normal file
@ -0,0 +1,55 @@
|
||||
// avlist.js —— 杀软/安全软件进程识别映射表
|
||||
// 格式:进程名 -> "产品 - 组件说明"。匹配时忽略大小写。
|
||||
// 可在此文件追加条目;如需通过后台管理,可在"内容设计"中扩展。
|
||||
var avList = {
|
||||
"360tray.exe": "360安全卫士 - 实时保护",
|
||||
"360safe.exe": "360安全卫士 - 主程序",
|
||||
"360rp.exe": "360杀毒 - 实时监控",
|
||||
"360sd.exe": "360杀毒 - 主程序",
|
||||
"QQPCRTP.exe": "QQ电脑管家 - 实时防护",
|
||||
"QQPCMgr.exe": "QQ电脑管家 - 主程序",
|
||||
"kxescore.exe": "金山毒霸 - 核心防护",
|
||||
"kwsprotect64.exe": "金山毒霸 - 服务进程",
|
||||
"kavsvc.exe": "金山毒霸 - 杀毒引擎",
|
||||
"risingcore.exe": "瑞星 - 云安全引擎",
|
||||
"ravmond.exe": "瑞星杀毒软件 - 监控进程",
|
||||
"rsmain.exe": "瑞星杀毒软件 - 主程序",
|
||||
"ekrn.exe": "ESET NOD32 - 核心服务",
|
||||
"egui.exe": "ESET NOD32 - 图形界面",
|
||||
"MsMpEng.exe": "Windows Defender - 恶意软件防护",
|
||||
"NisSrv.exe": "Windows Defender - 网络检查服务",
|
||||
"Sense.exe": "Windows Defender ATP - 终端感知",
|
||||
"avp.exe": "卡巴斯基 - 核心进程",
|
||||
"avpui.exe": "卡巴斯基 - 用户界面",
|
||||
"avgnt.exe": "Avira/小红伞 - 实时防护",
|
||||
"avguard.exe": "Avira/小红伞 - 扫描守护",
|
||||
"avcenter.exe": "Avast - 主控制台",
|
||||
"ashWebSv.exe": "Avast - 网页防护",
|
||||
"ccSvcHst.exe": "赛门铁克/诺顿 - 核心服务",
|
||||
"rtvscan.exe": "诺顿杀毒 - 扫描进程",
|
||||
"McAfeeSecurity.exe": "McAfee - 安全中心",
|
||||
"UdaterUI.exe": "McAfee - 更新程序",
|
||||
"mfemms.exe": "McAfee - 管理服务",
|
||||
"hipsvc.exe": "火绒安全 - 主服务",
|
||||
"HipsTray.exe": "火绒安全 - 托盘程序",
|
||||
"usysdiag.exe": "火绒安全 - 诊断工具",
|
||||
"wsctrl.exe": "火绒安全 - 网络控制",
|
||||
"TMBMSRV.exe": "趋势科技 - 管理服务",
|
||||
"PccNTMon.exe": "趋势科技 - 监控",
|
||||
"WRSA.exe": "Webroot SecureAnywhere",
|
||||
"dwengine.exe": "Deep Instinct - 引擎",
|
||||
"CylanceSvc.exe": "Cylance - 服务",
|
||||
"MBAMService.exe": "Malwarebytes - 服务",
|
||||
"mbam.exe": "Malwarebytes - 主程序",
|
||||
"SophosUI.exe": "Sophos - 界面",
|
||||
"SEDService.exe": "Sophos - 终端检测服务",
|
||||
"vsserv.exe": "比特梵德 - 服务进程",
|
||||
"bdagent.exe": "比特梵德 - 代理进程",
|
||||
"cis.exe": "Comodo Internet Security",
|
||||
"cavsvc.exe": "Comodo - 反病毒服务",
|
||||
"LavasoftTcpService.exe": "Ad-Aware - 服务",
|
||||
"sp_svc.exe": "超级巡警",
|
||||
"HipsDaemon.exe": "火绒/其他HIPS - 守护进程",
|
||||
"360Tray.exe": "360安全卫士 - 托盘(同360tray)",
|
||||
"avgui.exe": "Avast - 图形界面"
|
||||
};
|
||||
BIN
data/homepage.db
Normal file
BIN
data/homepage.db
Normal file
Binary file not shown.
13
data/quota.txt
Normal file
13
data/quota.txt
Normal file
@ -0,0 +1,13 @@
|
||||
# 名人名言库(quota.txt)
|
||||
# 每行一条,空行或 # 开头的行会被忽略;后台"内容设计"页可在线维护本文件。
|
||||
|
||||
道阻且长,行则将至。
|
||||
知识就是力量。
|
||||
知彼知己,百战不殆。
|
||||
学而不思则罔,思而不学则殆。
|
||||
合抱之木,生于毫末;九层之台,起于累土。
|
||||
工欲善其事,必先利其器。
|
||||
人类的一切智慧,都包含在"等待"和"希望"这四个字里面。
|
||||
所有的胜利,首先是思想的胜利。
|
||||
保持热爱,奔赴山海。
|
||||
真正的自由,来源于自律与清醒。
|
||||
15
func/_bar.php
Normal file
15
func/_bar.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* func/_bar.php —— 功能区子页公共头部条(需先引入 includes/layout.php)
|
||||
* 仅包含函数,不直接输出。
|
||||
*/
|
||||
|
||||
function func_bar(string $title): void
|
||||
{
|
||||
echo '<div class="func-bar">';
|
||||
echo '<div class="wrap func-bar-inner">';
|
||||
echo '<a class="btn btn-sm" href="index.php">← 返回功能区首页</a>';
|
||||
echo '<span class="func-bar-title">' . he($title) . '</span>';
|
||||
echo '<a class="btn btn-sm" href="../index.php">首页</a>';
|
||||
echo '</div></div>' . "\n";
|
||||
}
|
||||
142
func/av.php
Normal file
142
func/av.php
Normal file
@ -0,0 +1,142 @@
|
||||
<?php
|
||||
/**
|
||||
* func/av.php —— 杀软识别
|
||||
* 粘贴 tasklist 输出 → 匹配 data/avlist.js 进程表 → 表格输出 + 统计
|
||||
*/
|
||||
$P = '../';
|
||||
require_once dirname(__DIR__) . '/includes/layout.php';
|
||||
require_once __DIR__ . '/_bar.php';
|
||||
|
||||
layout_head('杀软识别');
|
||||
func_bar('杀软识别');
|
||||
?>
|
||||
<main class="page-main">
|
||||
<div class="wrap page-body">
|
||||
<div class="panel-card">
|
||||
<h2>使用说明</h2>
|
||||
<p class="tip">在目标机器执行 <code>tasklist</code> 复制全部输出,粘贴到下方输入框后点击“识别”。
|
||||
系统会逐行解析进程名并与预置的安全软件进程表(data/avlist.js)进行大小写不敏感匹配,<strong>表格仅展示识别出的安全软件进程</strong>,常规进程不展示。</p>
|
||||
<label class="fl" for="taskInput">tasklist 输出内容</label>
|
||||
<textarea id="taskInput" rows="12" placeholder="在此粘贴 tasklist 命令输出…"></textarea>
|
||||
<div class="field-row" style="margin-top:10px">
|
||||
<button type="button" class="btn btn-primary" id="btnAv">识别</button>
|
||||
<button type="button" class="btn" id="btnAvClear">清空</button>
|
||||
</div>
|
||||
<div id="avMsg"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card" id="avResult" hidden>
|
||||
<h2>识别结果 <span id="avStat" class="tip"></span></h2>
|
||||
<div style="overflow-x:auto">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>#</th><th>进程名</th><th>识别结果</th><th>产品 / 说明</th></tr></thead>
|
||||
<tbody id="avBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php layout_theme_fab(); ?>
|
||||
<?php layout_footer(); ?>
|
||||
<script src="../data/avlist.js"></script>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function parseLines(text) {
|
||||
var lines = text.split(/\r?\n/);
|
||||
var out = [];
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
// 跳过表头与分隔行
|
||||
if (/映像名称|Image Name|进程名|PID|===/i.test(line) && !/\d$/.test(line.trim())) continue;
|
||||
var name = line.split(/[\s,]+/)[0];
|
||||
if (!name) continue;
|
||||
out.push({ name: name, raw: line });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeName(n) {
|
||||
return n.toLowerCase().replace(/\.exe$/i, '');
|
||||
}
|
||||
|
||||
function lookup(name) {
|
||||
var lower = name.toLowerCase();
|
||||
var map = window.avList || {};
|
||||
if (Object.prototype.hasOwnProperty.call(map, lower)) return map[lower];
|
||||
var base = normalizeName(name);
|
||||
for (var key in map) {
|
||||
if (Object.prototype.hasOwnProperty.call(map, key) && normalizeName(key) === base) {
|
||||
return map[key];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function run() {
|
||||
var text = $('taskInput').value;
|
||||
var msg = $('avMsg');
|
||||
msg.innerHTML = '';
|
||||
if (!text.trim()) {
|
||||
msg.innerHTML = '<div class="err-msg">请先粘贴 tasklist 输出内容。</div>';
|
||||
return;
|
||||
}
|
||||
var rows = parseLines(text);
|
||||
if (!rows.length) {
|
||||
msg.innerHTML = '<div class="err-msg">未能解析到有效的进程行,请确认粘贴的是 tasklist 输出。</div>';
|
||||
return;
|
||||
}
|
||||
var body = $('avBody');
|
||||
body.innerHTML = '';
|
||||
// 仅保留识别(命中安全软件进程表)的行
|
||||
var matched = [];
|
||||
rows.forEach(function (r) {
|
||||
var info = lookup(r.name);
|
||||
if (info) matched.push({ name: r.name, info: info });
|
||||
});
|
||||
if (!matched.length) {
|
||||
$('avResult').hidden = true;
|
||||
msg.innerHTML = '<div class="ok-msg">共解析 ' + rows.length + ' 行进程,未识别到已知的安全软件进程。</div>';
|
||||
return;
|
||||
}
|
||||
matched.forEach(function (m, i) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td>' + (i + 1) + '</td>' +
|
||||
'<td>' + esc(m.name) + '</td>' +
|
||||
'<td><span class="badge badge-ok">已匹配</span></td>' +
|
||||
'<td>' + esc(m.info) + '</td>';
|
||||
body.appendChild(tr);
|
||||
});
|
||||
$('avStat').textContent = '共解析 ' + rows.length + ' 行进程,识别到 ' + matched.length + ' 个安全软件进程';
|
||||
$('avResult').hidden = false;
|
||||
msg.innerHTML = '';
|
||||
}
|
||||
|
||||
$('btnAv').addEventListener('click', run);
|
||||
$('btnAvClear').addEventListener('click', function () {
|
||||
$('taskInput').value = '';
|
||||
$('avResult').hidden = true;
|
||||
$('avBody').innerHTML = '';
|
||||
$('avMsg').innerHTML = '';
|
||||
$('taskInput').focus();
|
||||
});
|
||||
// 输入内容后可用 Ctrl/Cmd + Enter 快速识别
|
||||
$('taskInput').addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) run();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
148
func/codec.php
Normal file
148
func/codec.php
Normal file
@ -0,0 +1,148 @@
|
||||
<?php
|
||||
/**
|
||||
* func/codec.php —— 编码 / 加解密工具
|
||||
* Base64 / Base32 / URL / Unicode(支持中文与 emoji)双向;MD5 / SHA-1/224/256/384/512 哈希。
|
||||
* 输入输出文本区均可自由编辑、可竖向拉伸。
|
||||
*/
|
||||
$P = '../';
|
||||
require_once dirname(__DIR__) . '/includes/layout.php';
|
||||
require_once __DIR__ . '/_bar.php';
|
||||
|
||||
layout_head('编码 / 加解密');
|
||||
func_bar('编码 / 加解密');
|
||||
?>
|
||||
<main class="page-main">
|
||||
<div class="wrap page-body">
|
||||
<div class="panel-card">
|
||||
<div class="field-row" style="align-items:center">
|
||||
<label class="fl" style="margin:0" for="selAlgo">算法</label>
|
||||
<select id="selAlgo">
|
||||
<option value="b64">Base64</option>
|
||||
<option value="b32">Base32</option>
|
||||
<option value="url">URL 编码</option>
|
||||
<option value="unicode">Unicode(\uXXXX)</option>
|
||||
<option value="md5">MD5(不可逆)</option>
|
||||
<option value="sha1">SHA-1(不可逆)</option>
|
||||
<option value="sha224">SHA-224(不可逆)</option>
|
||||
<option value="sha256">SHA-256(不可逆)</option>
|
||||
<option value="sha384">SHA-384(不可逆)</option>
|
||||
<option value="sha512">SHA-512(不可逆)</option>
|
||||
</select>
|
||||
<div class="seg" id="dirSeg">
|
||||
<button type="button" data-dir="enc" class="active">编码</button>
|
||||
<button type="button" data-dir="dec">解码</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="btnRun">执行</button>
|
||||
<button type="button" class="btn" id="btnSwap" title="把输出内容放入输入区">输出 → 输入</button>
|
||||
<button type="button" class="btn" id="btnClear">清空</button>
|
||||
</div>
|
||||
<div id="msgBox"></div>
|
||||
</div>
|
||||
|
||||
<div class="io-area">
|
||||
<div class="io-box">
|
||||
<div class="io-label">
|
||||
<span>输入区(可编辑)</span>
|
||||
<button type="button" class="btn btn-sm js-copy" data-target="#inText">复制</button>
|
||||
</div>
|
||||
<textarea id="inText" placeholder="在此粘贴或输入要处理的内容…(支持中文 / emoji)"></textarea>
|
||||
</div>
|
||||
<div class="io-box">
|
||||
<div class="io-label">
|
||||
<span>输出区(可编辑)</span>
|
||||
<button type="button" class="btn btn-sm js-copy" data-target="#outText">复制</button>
|
||||
</div>
|
||||
<textarea id="outText" placeholder="结果输出到这里,可手动修改后再次使用"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php layout_theme_fab(); ?>
|
||||
<?php layout_footer(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script src="../assets/js/codec.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var HASH_ALGOS = { md5: 'MD5', sha1: 'SHA-1', sha224: 'SHA-224', sha256: 'SHA-256', sha384: 'SHA-384', sha512: 'SHA-512' };
|
||||
|
||||
var algo = document.getElementById('selAlgo');
|
||||
var dirButtons = Array.prototype.slice.call(document.querySelectorAll('#dirSeg button'));
|
||||
var inText = document.getElementById('inText');
|
||||
var outText = document.getElementById('outText');
|
||||
var msgBox = document.getElementById('msgBox');
|
||||
|
||||
function activeDir() {
|
||||
var b = dirButtons.filter(function (x) { return x.classList.contains('active'); })[0];
|
||||
return b ? b.getAttribute('data-dir') : 'enc';
|
||||
}
|
||||
function setMsg(text, isErr) {
|
||||
msgBox.innerHTML = text
|
||||
? '<div class="' + (isErr ? 'err-msg' : 'ok-msg') + '">' + text + '</div>'
|
||||
: '';
|
||||
}
|
||||
function syncDirectionUI() {
|
||||
var isHash = HASH_ALGOS[algo.value] !== undefined;
|
||||
dirButtons.forEach(function (b) { b.disabled = isHash; });
|
||||
if (isHash) dirButtons.forEach(function (b) { b.classList.remove('active'); });
|
||||
else if (!dirButtons.some(function (x) { return x.classList.contains('active'); })) {
|
||||
dirButtons[0].classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function run() {
|
||||
var text = inText.value;
|
||||
if (!text) { setMsg('请输入内容', true); inText.focus(); return; }
|
||||
var key = algo.value;
|
||||
var dir = activeDir();
|
||||
var fn = null;
|
||||
|
||||
if (key === 'b64') fn = dir === 'enc' ? function (t) { return CodecLib.b64Encode(t); } : function (t) { return CodecLib.b64Decode(t); };
|
||||
else if (key === 'b32') fn = dir === 'enc' ? function (t) { return CodecLib.base32Encode(t); } : function (t) { return CodecLib.base32Decode(t); };
|
||||
else if (key === 'url') fn = dir === 'enc' ? function (t) { return CodecLib.urlEncode(t); } : function (t) { return CodecLib.urlDecode(t); };
|
||||
else if (key === 'unicode') fn = dir === 'enc' ? function (t) { return CodecLib.unicodeEncode(t); } : function (t) { return CodecLib.unicodeDecode(t); };
|
||||
|
||||
if (fn) {
|
||||
try {
|
||||
outText.value = fn(text);
|
||||
setMsg('执行完成', false);
|
||||
} catch (e) {
|
||||
setMsg('执行出错:' + (e && e.message ? e.message : e), true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var algoName = HASH_ALGOS[key];
|
||||
if (algoName) {
|
||||
setMsg('正在计算 ' + algoName + ' …', false);
|
||||
CodecLib.hashText(algoName, text).then(function (hex) {
|
||||
outText.value = hex;
|
||||
setMsg('计算完成(' + algoName + ')', false);
|
||||
}).catch(function (e) {
|
||||
setMsg('计算失败:' + (e && e.message ? e.message : e), true);
|
||||
});
|
||||
} else {
|
||||
setMsg('未知算法', true);
|
||||
}
|
||||
}
|
||||
|
||||
algo.addEventListener('change', syncDirectionUI);
|
||||
dirButtons.forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
if (b.disabled) return;
|
||||
dirButtons.forEach(function (x) { x.classList.remove('active'); });
|
||||
b.classList.add('active');
|
||||
});
|
||||
});
|
||||
document.getElementById('btnRun').addEventListener('click', run);
|
||||
document.getElementById('btnSwap').addEventListener('click', function () {
|
||||
if (outText.value) { inText.value = outText.value; setMsg('已将输出内容回填到输入区', false); }
|
||||
});
|
||||
document.getElementById('btnClear').addEventListener('click', function () {
|
||||
inText.value = ''; outText.value = ''; setMsg('', false); inText.focus();
|
||||
});
|
||||
syncDirectionUI();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
237
func/gmcodec.php
Normal file
237
func/gmcodec.php
Normal file
@ -0,0 +1,237 @@
|
||||
<?php
|
||||
/**
|
||||
* func/gmcodec.php —— 国密加解密
|
||||
* SM2 非对称加解密 / SM3 摘要 / SM4 对称加解密。
|
||||
* 说明:SM1 为国密硬件算法(不公开、依赖专用芯片),无法在浏览器纯软件实现,故未提供。
|
||||
*/
|
||||
$P = '../';
|
||||
require_once dirname(__DIR__) . '/includes/layout.php';
|
||||
require_once __DIR__ . '/_bar.php';
|
||||
|
||||
layout_head('国密加解密');
|
||||
func_bar('国密加解密');
|
||||
?>
|
||||
<main class="page-main">
|
||||
<div class="wrap page-body">
|
||||
<div class="panel-card">
|
||||
<p class="tip" style="margin-top:0">支持 SM2 / SM3 / SM4 三种国密算法;SM1 为不公开的硬件算法(需国密芯片),无法纯软件实现。
|
||||
SM2/SM4 运算依赖在线加载 <code>sm-crypto</code> 组件(双 CDN),加载失败会明确提示。</p>
|
||||
|
||||
<div class="field-row" style="align-items:center;margin-top:12px">
|
||||
<label class="fl" style="margin:0" for="gmAlgo">算法</label>
|
||||
<select id="gmAlgo">
|
||||
<option value="sm4">SM4(对称加解密)</option>
|
||||
<option value="sm2">SM2(非对称加解密)</option>
|
||||
<option value="sm3">SM3(摘要,不可逆)</option>
|
||||
</select>
|
||||
<div class="seg" id="gmDir">
|
||||
<button type="button" data-dir="enc" class="active">加密</button>
|
||||
<button type="button" data-dir="dec">解密</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="gmRun">执行</button>
|
||||
<button type="button" class="btn" id="gmSwap" title="把输出内容放入输入区">输出 → 输入</button>
|
||||
<button type="button" class="btn" id="gmClear">清空</button>
|
||||
</div>
|
||||
|
||||
<!-- SM4 参数区 -->
|
||||
<div id="gmSm4">
|
||||
<div class="field-row" style="margin-top:10px;align-items:center">
|
||||
<label class="fl" style="margin:0">密钥(16 字节)</label>
|
||||
<input type="text" id="sm4Key" style="flex:1 1 240px;min-width:160px" placeholder="32 位十六进制,或恰好 16 个字符">
|
||||
<button type="button" class="btn btn-sm" id="sm4KeyRand">随机密钥</button>
|
||||
</div>
|
||||
<div class="field-row" style="margin-top:8px;align-items:center">
|
||||
<label class="fl" style="margin:0">模式</label>
|
||||
<select id="sm4Mode">
|
||||
<option value="ecb">ECB</option>
|
||||
<option value="cbc">CBC</option>
|
||||
</select>
|
||||
<input type="text" id="sm4Iv" style="flex:1 1 240px;min-width:160px" placeholder="CBC 模式需 32 位十六进制 IV">
|
||||
<button type="button" class="btn btn-sm" id="sm4IvRand">随机 IV</button>
|
||||
<label class="fl" style="margin:0">密文格式</label>
|
||||
<select id="sm4Out">
|
||||
<option value="hex">十六进制</option>
|
||||
<option value="b64">Base64</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SM2 参数区 -->
|
||||
<div id="gmSm2" hidden>
|
||||
<div class="field-row" style="margin-top:10px;align-items:center">
|
||||
<label class="fl" style="margin:0">公钥</label>
|
||||
<input type="text" id="sm2Pub" style="flex:1 1 320px;min-width:180px" placeholder="加密时填对方公钥(130 位 hex)">
|
||||
<label class="fl" style="margin:0">私钥</label>
|
||||
<input type="text" id="sm2Priv" style="flex:1 1 320px;min-width:180px" placeholder="解密时填自己的私钥(130 位 hex)">
|
||||
<button type="button" class="btn btn-sm" id="sm2Gen">生成密钥对</button>
|
||||
</div>
|
||||
<p class="tip" style="margin:8px 0 0">SM2 为非对称算法:<strong>加密用“对方公钥”,解密用“自己私钥”</strong>;同一把私钥导出的公钥可直接用于加密。</p>
|
||||
</div>
|
||||
|
||||
<!-- SM3 提示区 -->
|
||||
<div id="gmSm3Tip" class="tip" hidden style="margin-top:8px">SM3 输出固定 64 位十六进制摘要(HMAC 场景暂不提供,如需请使用后台维护的其它方案)。</div>
|
||||
|
||||
<div id="gmMsg"></div>
|
||||
</div>
|
||||
|
||||
<div class="io-area">
|
||||
<div class="io-box">
|
||||
<div class="io-label">
|
||||
<span>输入区(明文 / 待解密内容,可编辑)</span>
|
||||
<button type="button" class="btn btn-sm js-copy" data-target="#gmIn">复制</button>
|
||||
</div>
|
||||
<textarea id="gmIn" placeholder="SM3:任意文本;SM4/SM2 加密:UTF-8 明文;解密:粘贴密文(十六进制,SM4 亦支持 Base64)"></textarea>
|
||||
</div>
|
||||
<div class="io-box">
|
||||
<div class="io-label">
|
||||
<span>输出区(可编辑)</span>
|
||||
<button type="button" class="btn btn-sm js-copy" data-target="#gmOut">复制</button>
|
||||
</div>
|
||||
<textarea id="gmOut" placeholder="结果输出到这里,可手动修改后再次使用"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php layout_theme_fab(); ?>
|
||||
<?php layout_footer(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script src="../assets/js/gmcrypto.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
function $(id) { return document.getElementById(id); }
|
||||
var algo = $('gmAlgo');
|
||||
var dirButtons = Array.prototype.slice.call(document.querySelectorAll('#gmDir button'));
|
||||
var gmIn = $('gmIn');
|
||||
var gmOut = $('gmOut');
|
||||
var gmMsg = $('gmMsg');
|
||||
var panSm4 = $('gmSm4');
|
||||
var panSm2 = $('gmSm2');
|
||||
var panSm3Tip = $('gmSm3Tip');
|
||||
var sm4IvRow = $('sm4Iv');
|
||||
var ivRandBtn = $('sm4IvRand');
|
||||
|
||||
function activeDir() {
|
||||
var b = dirButtons.filter(function (x) { return x.classList.contains('active'); })[0];
|
||||
return b ? b.getAttribute('data-dir') : 'enc';
|
||||
}
|
||||
function setMsg(text, isErr) {
|
||||
gmMsg.innerHTML = '';
|
||||
if (!text) return;
|
||||
var d = document.createElement('div');
|
||||
d.className = isErr ? 'err-msg' : 'ok-msg';
|
||||
d.textContent = text;
|
||||
gmMsg.appendChild(d);
|
||||
}
|
||||
function syncUI() {
|
||||
var v = algo.value;
|
||||
panSm4.hidden = v !== 'sm4';
|
||||
panSm2.hidden = v !== 'sm2';
|
||||
panSm3Tip.hidden = v !== 'sm3';
|
||||
var isHash = v === 'sm3';
|
||||
dirButtons.forEach(function (b) {
|
||||
b.disabled = isHash;
|
||||
if (isHash) b.classList.remove('active');
|
||||
});
|
||||
if (!isHash && !dirButtons.some(function (x) { return x.classList.contains('active'); })) {
|
||||
dirButtons[0].classList.add('active');
|
||||
}
|
||||
syncIv();
|
||||
}
|
||||
function syncIv() {
|
||||
var cbc = $('sm4Mode').value === 'cbc';
|
||||
sm4IvRow.disabled = !cbc;
|
||||
ivRandBtn.disabled = !cbc;
|
||||
}
|
||||
|
||||
function busy(on) {
|
||||
$('gmRun').disabled = on;
|
||||
$('gmRun').textContent = on ? '处理中…' : '执行';
|
||||
}
|
||||
|
||||
function finish(okText) {
|
||||
busy(false);
|
||||
setMsg(okText, false);
|
||||
}
|
||||
|
||||
function run() {
|
||||
var text = gmIn.value;
|
||||
if (!text.trim()) { setMsg('请先输入内容', true); gmIn.focus(); return; }
|
||||
var v = algo.value;
|
||||
var dir = activeDir();
|
||||
var G = window.GmCrypto;
|
||||
|
||||
if (v === 'sm3') {
|
||||
busy(true);
|
||||
setMsg('正在计算 SM3 摘要…', false);
|
||||
G.sm3(text).then(function (hex) {
|
||||
gmOut.value = hex;
|
||||
finish('SM3 摘要计算完成(64 位十六进制)');
|
||||
}).catch(function (e) { busy(false); setMsg('SM3 计算失败:' + (e && e.message ? e.message : e), true); });
|
||||
return;
|
||||
}
|
||||
|
||||
if (v === 'sm4') {
|
||||
busy(true);
|
||||
setMsg('正在执行 SM4 ' + (dir === 'enc' ? '加密' : '解密') + '…', false);
|
||||
G.sm4(text, $('sm4Key').value, $('sm4Mode').value, $('sm4Iv').value, dir === 'enc')
|
||||
.then(function (r) {
|
||||
if (dir === 'enc') {
|
||||
gmOut.value = ($('sm4Out').value === 'b64')
|
||||
? G.bytesToB64(G.hexToBytes(r.hex))
|
||||
: r.hex;
|
||||
finish('SM4 加密完成');
|
||||
} else {
|
||||
gmOut.value = r.text || '';
|
||||
finish('SM4 解密完成');
|
||||
}
|
||||
})
|
||||
.catch(function (e) { busy(false); setMsg('SM4 ' + (dir === 'enc' ? '加密' : '解密') + '失败:' + (e && e.message ? e.message : e), true); });
|
||||
return;
|
||||
}
|
||||
|
||||
// SM2
|
||||
busy(true);
|
||||
setMsg('正在执行 SM2 ' + (dir === 'enc' ? '加密' : '解密') + '…', false);
|
||||
var p = (dir === 'enc')
|
||||
? G.sm2Encrypt(text, $('sm2Pub').value)
|
||||
: G.sm2Decrypt(text, $('sm2Priv').value);
|
||||
p.then(function (out) {
|
||||
gmOut.value = out || '';
|
||||
finish('SM2 ' + (dir === 'enc' ? '加密' : '解密') + '完成');
|
||||
}).catch(function (e) { busy(false); setMsg('SM2 ' + (dir === 'enc' ? '加密' : '解密') + '失败:' + (e && e.message ? e.message : e), true); });
|
||||
}
|
||||
|
||||
// 事件绑定
|
||||
algo.addEventListener('change', syncUI);
|
||||
dirButtons.forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
if (b.disabled) return;
|
||||
dirButtons.forEach(function (x) { x.classList.remove('active'); });
|
||||
b.classList.add('active');
|
||||
});
|
||||
});
|
||||
$('sm4Mode').addEventListener('change', syncIv);
|
||||
$('sm4KeyRand').addEventListener('click', function () { $('sm4Key').value = window.GmCrypto.randomHex(16); });
|
||||
ivRandBtn.addEventListener('click', function () { $('sm4Iv').value = window.GmCrypto.randomHex(16); });
|
||||
$('sm2Gen').addEventListener('click', function () {
|
||||
setMsg('正在生成 SM2 密钥对…', false);
|
||||
window.GmCrypto.sm2Key().then(function (kp) {
|
||||
$('sm2Pub').value = kp.publicKey;
|
||||
$('sm2Priv').value = kp.privateKey;
|
||||
setMsg('密钥对已生成,请妥善保存私钥后使用', false);
|
||||
}).catch(function (e) { setMsg('密钥对生成失败:' + (e && e.message ? e.message : e), true); });
|
||||
});
|
||||
$('gmRun').addEventListener('click', run);
|
||||
$('gmSwap').addEventListener('click', function () {
|
||||
if (gmOut.value) { gmIn.value = gmOut.value; setMsg('已将输出内容回填到输入区', false); }
|
||||
});
|
||||
$('gmClear').addEventListener('click', function () {
|
||||
gmIn.value = ''; gmOut.value = ''; setMsg('', false); gmIn.focus();
|
||||
});
|
||||
syncUI();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
49
func/index.php
Normal file
49
func/index.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
/**
|
||||
* func/index.php —— 功能区首页(工具入口,由后台“功能区管理”动态驱动)
|
||||
*/
|
||||
$P = '../';
|
||||
require_once dirname(__DIR__) . '/includes/layout.php';
|
||||
|
||||
$tools = db()->query('SELECT * FROM func_tools WHERE enabled = 1 ORDER BY sort ASC, id ASC')->fetchAll();
|
||||
|
||||
layout_head('功能区');
|
||||
?>
|
||||
<main class="page-main">
|
||||
<!-- 功能区展示横幅 -->
|
||||
<section class="sub-hero">
|
||||
<a class="back-home" href="../index.php">← 返回首页</a>
|
||||
<h1>功能区</h1>
|
||||
<p>工具统一收纳于此;新工具的入口可在后台“功能区管理”中登记</p>
|
||||
</section>
|
||||
|
||||
<div class="wrap page-body">
|
||||
<?php if ($tools): ?>
|
||||
<div class="func-cards">
|
||||
<?php foreach ($tools as $t):
|
||||
$rawIcon = trim((string)($t['icon'] ?? ''));
|
||||
$rawIcon = $rawIcon !== '' ? $rawIcon : '🧩';
|
||||
$isExt = (int)$t['is_external'] === 1;
|
||||
?>
|
||||
<a class="func-card" href="<?= he((string)$t['url']) ?>"<?php if ($isExt): ?> target="_blank" rel="noopener noreferrer"<?php endif; ?>>
|
||||
<span class="fc-icon"><?= site_icon($rawIcon, '') ?></span>
|
||||
<span>
|
||||
<span class="fc-name"><?= he((string)$t['name']) ?></span>
|
||||
<span class="fc-desc"><?= he((string)$t['description']) ?></span>
|
||||
</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="panel-card">
|
||||
<div class="tip">功能区还没有可用的工具,请到后台“功能区管理”中添加。</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php layout_theme_fab(); ?>
|
||||
<?php layout_footer(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
func/ip.php
Normal file
59
func/ip.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
/**
|
||||
* func/ip.php —— IP 地址处理
|
||||
* 提取输入文本中的全部 IPv4,按 /24 段聚合输出纯文本结果:
|
||||
* IP段 发现数量(段内去重IP数) IP段性质
|
||||
* 1.2.3.0/24 8 公网
|
||||
* 1.2.3.4
|
||||
*
|
||||
* 段性质区分:内网(含保留)/ CDN / 公网;IPv6 暂不参与。
|
||||
*/
|
||||
$P = '../';
|
||||
require_once dirname(__DIR__) . '/includes/layout.php';
|
||||
require_once __DIR__ . '/_bar.php';
|
||||
|
||||
layout_head('IP 地址处理');
|
||||
func_bar('IP 地址处理');
|
||||
?>
|
||||
<main class="page-main">
|
||||
<div class="wrap page-body">
|
||||
<div class="panel-card">
|
||||
<h2>使用说明</h2>
|
||||
<p class="tip">粘贴任意日志 / 文本,系统提取其中全部 <strong>IPv4</strong>(IPv6 暂不参与),
|
||||
按 <strong>/24 段</strong>聚合为文本结果:每段一行 <code>IP段 / 发现数量 / IP段性质</code>,
|
||||
其中「发现数量」为该段内<strong>去重后的 IP 个数</strong>;段行下方缩进列出该段具体 IP,段与段之间空一行分隔。
|
||||
段性质按 <strong>内网(含保留)/ CDN / 公网</strong> 区分。
|
||||
若在下方「重点匹配 IP」中填写了需要关注的 IP,命中其所在的 /24 段会<strong>优先置顶</strong>,并在预览结果中用<strong>红色</strong>标出该 IP(复制得到的纯文本不含颜色)。</p>
|
||||
<label class="fl" for="ipInput">待分析文本</label>
|
||||
<textarea id="ipInput" rows="12" placeholder="粘贴防火墙 / nginx / 安全设备日志等包含 IP 的内容…"></textarea>
|
||||
<label class="fl" for="ipFocus" style="margin-top:12px">重点匹配 IP(可选,多个用空格 / 逗号 / 换行分隔)</label>
|
||||
<input type="text" id="ipFocus" placeholder="如 8.8.8.8 104.16.1.18;命中该 IP 的 /24 段将优先置顶展示,并将对应 IP 标红">
|
||||
<div class="field-row" style="margin-top:10px">
|
||||
<button type="button" class="btn btn-primary" id="ipBtn">分析</button>
|
||||
<button type="button" class="btn" id="ipClear">清空</button>
|
||||
<span class="tip" style="align-self:center">支持 Ctrl / Cmd + Enter 快速分析</span>
|
||||
</div>
|
||||
<div id="ipMsg"></div>
|
||||
</div>
|
||||
|
||||
<div id="ipResult" hidden>
|
||||
<div class="panel-card">
|
||||
<div class="field-row" style="align-items:center;justify-content:space-between">
|
||||
<h2 style="margin:0">识别结果</h2>
|
||||
<button type="button" class="btn btn-sm" id="ipCopy">复制结果</button>
|
||||
</div>
|
||||
<div id="ipPreview" class="ip-pre"></div>
|
||||
<p class="tip" id="ipStat" style="margin:8px 0"></p>
|
||||
<p class="tip">复制结果为纯文本(不含红色标记);含重点 IP 的段已优先置顶展示。若 /24 段内混有不同性质,按数量多者标注“xx为主(混合)”。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php layout_theme_fab(); ?>
|
||||
<?php layout_footer(); ?>
|
||||
<textarea id="ipCdnRaw" hidden><?= he(cdn_ranges_text()) ?></textarea>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script src="../assets/js/ip.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
143
func/password.php
Normal file
143
func/password.php
Normal file
@ -0,0 +1,143 @@
|
||||
<?php
|
||||
/**
|
||||
* func/password.php —— 随机密码生成
|
||||
* 字符集多选 + 自定义字符集 + 长度 + 一次生成多组 + 复制
|
||||
*/
|
||||
$P = '../';
|
||||
require_once dirname(__DIR__) . '/includes/layout.php';
|
||||
require_once __DIR__ . '/_bar.php';
|
||||
|
||||
layout_head('随机密码生成');
|
||||
func_bar('随机密码生成');
|
||||
?>
|
||||
<main class="page-main">
|
||||
<div class="wrap page-body">
|
||||
<div class="panel-card">
|
||||
<div class="field-row">
|
||||
<label class="seg-check"><input type="checkbox" id="ckUpper" checked> 大写 A-Z</label>
|
||||
<label class="seg-check"><input type="checkbox" id="ckLower" checked> 小写 a-z</label>
|
||||
<label class="seg-check"><input type="checkbox" id="ckDigit" checked> 数字 0-9</label>
|
||||
<label class="seg-check"><input type="checkbox" id="ckSym"> 特殊符号</label>
|
||||
</div>
|
||||
<div class="field-row" style="margin-top:6px">
|
||||
<label class="seg-check"><input type="checkbox" id="ckCustom"> 使用自定义字符集</label>
|
||||
<input type="text" id="inCustom" placeholder="自定义字符集(如:ABCabc!@#),勾选后启用" style="flex:1;min-width:260px">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="fl" style="margin:0">密码长度:<b id="lenVal">16</b></label>
|
||||
<input type="range" id="lenRange" min="4" max="64" value="16" style="max-width:280px">
|
||||
<label class="fl" style="margin:0">生成条数:<b id="cntVal">5</b></label>
|
||||
<input type="range" id="cntRange" min="1" max="10" value="5" style="max-width:280px">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<button type="button" class="btn btn-primary" id="btnGen">生成密码</button>
|
||||
<button type="button" class="btn" id="btnCopyAll">复制全部</button>
|
||||
</div>
|
||||
<div id="pwdMsg"></div>
|
||||
<div class="pwd-results" id="pwdList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php layout_theme_fab(); ?>
|
||||
<?php layout_footer(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var SETS = {
|
||||
upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
lower: 'abcdefghijklmnopqrstuvwxyz',
|
||||
digit: '0123456789',
|
||||
sym: '!@#$%^&*()-_=+[]{};:,.<>?'
|
||||
};
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function uniq(str) {
|
||||
var seen = {}, out = '';
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
var ch = str.charAt(i);
|
||||
if (!seen[ch]) { seen[ch] = 1; out += ch; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function randInt(max) { return Math.floor(Math.random() * max); }
|
||||
|
||||
function charset() {
|
||||
if ($('ckCustom').checked) {
|
||||
var c = uniq($('inCustom').value);
|
||||
return c;
|
||||
}
|
||||
var s = '';
|
||||
if ($('ckUpper').checked) s += SETS.upper;
|
||||
if ($('ckLower').checked) s += SETS.lower;
|
||||
if ($('ckDigit').checked) s += SETS.digit;
|
||||
if ($('ckSym').checked) s += SETS.sym;
|
||||
return uniq(s);
|
||||
}
|
||||
|
||||
function genOne(chars, len) {
|
||||
var out = '';
|
||||
// 确保自定义字符集大小时每个字符都可能出现
|
||||
for (var i = 0; i < len; i++) {
|
||||
out += chars.charAt(randInt(chars.length));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function generate() {
|
||||
var chars = charset();
|
||||
var len = parseInt($('lenRange').value, 10);
|
||||
var cnt = parseInt($('cntRange').value, 10);
|
||||
var msg = $('pwdMsg');
|
||||
var list = $('pwdList');
|
||||
if (!chars) {
|
||||
msg.innerHTML = '<div class="err-msg">请至少选择一种字符集,或填写自定义字符集。</div>';
|
||||
list.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
if ($('ckCustom').checked && chars.length < 2) {
|
||||
msg.innerHTML = '<div class="err-msg">自定义字符集至少包含 2 个不同字符。</div>';
|
||||
list.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
msg.innerHTML = '';
|
||||
list.innerHTML = '';
|
||||
for (var i = 0; i < cnt; i++) {
|
||||
var pwd = genOne(chars, len);
|
||||
var line = document.createElement('div');
|
||||
line.className = 'pwd-line';
|
||||
var code = document.createElement('code');
|
||||
code.textContent = pwd; // 用 textContent 写入,避免 HTML 注入
|
||||
var copyBtn = document.createElement('button');
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.className = 'btn btn-sm js-copy';
|
||||
copyBtn.setAttribute('data-copy', pwd);
|
||||
copyBtn.textContent = '复制';
|
||||
line.appendChild(code);
|
||||
line.appendChild(copyBtn);
|
||||
list.appendChild(line);
|
||||
}
|
||||
}
|
||||
|
||||
function copyAll() {
|
||||
var codes = Array.prototype.slice.call($('pwdList').querySelectorAll('code'));
|
||||
var text = codes.map(function (c) { return c.textContent; }).join('\n');
|
||||
if (!text) return;
|
||||
HomePage.copyText(text, $('btnCopyAll'));
|
||||
}
|
||||
|
||||
$('ckCustom').addEventListener('change', function () {
|
||||
var on = $('ckCustom').checked;
|
||||
['ckUpper', 'ckLower', 'ckDigit', 'ckSym'].forEach(function (id) {
|
||||
$(id).disabled = on;
|
||||
});
|
||||
});
|
||||
$('lenRange').addEventListener('input', function () { $('lenVal').textContent = this.value; });
|
||||
$('cntRange').addEventListener('input', function () { $('cntVal').textContent = this.value; });
|
||||
$('btnGen').addEventListener('click', generate);
|
||||
$('btnCopyAll').addEventListener('click', copyAll);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
148
func/qrcode.php
Normal file
148
func/qrcode.php
Normal file
@ -0,0 +1,148 @@
|
||||
<?php
|
||||
/**
|
||||
* func/qrcode.php —— 二维码生成器
|
||||
* 使用 qrcodejs(CDN 双源加载);支持尺寸调节、放大预览与 PNG 下载。
|
||||
*/
|
||||
$P = '../';
|
||||
require_once dirname(__DIR__) . '/includes/layout.php';
|
||||
require_once __DIR__ . '/_bar.php';
|
||||
|
||||
layout_head('二维码生成器');
|
||||
func_bar('二维码生成器');
|
||||
?>
|
||||
<main class="page-main">
|
||||
<div class="wrap page-body">
|
||||
<div class="panel-card">
|
||||
<div class="field-row">
|
||||
<input type="text" id="qrText" placeholder="输入需要生成二维码的内容,如网址 / 文本…" style="flex:1;min-width:260px">
|
||||
<button type="button" class="btn btn-primary" id="qrGen">生成二维码</button>
|
||||
<button type="button" class="btn" id="qrClear">清空</button>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="fl" style="margin:0">尺寸:<b id="qrSizeVal">300</b> px</label>
|
||||
<input type="range" id="qrSize" min="180" max="520" step="10" value="300" style="max-width:300px">
|
||||
</div>
|
||||
<div id="qrMsg"></div>
|
||||
|
||||
<div class="qr-stage" id="qrStage" hidden>
|
||||
<div id="qrBox"></div>
|
||||
<div class="field-row" style="justify-content:center">
|
||||
<button type="button" class="btn" id="qrZoom">点击放大预览</button>
|
||||
<button type="button" class="btn btn-primary" id="qrDl">下载 PNG</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php layout_theme_fab(); ?>
|
||||
<?php layout_footer(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var CDNS = [
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js',
|
||||
'https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js'
|
||||
];
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function loadQR(cb) {
|
||||
if (window.QRCode) { cb(null); return; }
|
||||
var idx = 0;
|
||||
(function next() {
|
||||
if (window.QRCode) { cb(null); return; }
|
||||
if (idx >= CDNS.length) { cb(new Error('二维码组件加载失败,请检查网络后重试')); return; }
|
||||
var s = document.createElement('script');
|
||||
s.src = CDNS[idx++];
|
||||
s.onload = function () { cb(window.QRCode ? null : new Error('二维码组件加载失败')); };
|
||||
s.onerror = function () { next(); };
|
||||
document.head.appendChild(s);
|
||||
})();
|
||||
}
|
||||
|
||||
var lastValue = '';
|
||||
function generate() {
|
||||
var value = $('qrText').value.trim();
|
||||
var msg = $('qrMsg');
|
||||
msg.innerHTML = '';
|
||||
if (!value) { msg.innerHTML = '<div class="err-msg">请输入内容。</div>'; $('qrText').focus(); return; }
|
||||
loadQR(function (err) {
|
||||
if (err) { msg.innerHTML = '<div class="err-msg">' + err.message + '</div>'; return; }
|
||||
if ($('qrText').value.trim() !== value) return; // 期间内容已变
|
||||
var size = parseInt($('qrSize').value, 10);
|
||||
var box = $('qrBox');
|
||||
box.innerHTML = '';
|
||||
try {
|
||||
new QRCode(box, {
|
||||
text: value,
|
||||
width: size,
|
||||
height: size,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
lastValue = value;
|
||||
$('qrStage').hidden = false;
|
||||
msg.innerHTML = '<div class="ok-msg">二维码已生成。</div>';
|
||||
} catch (e) {
|
||||
box.innerHTML = '';
|
||||
msg.innerHTML = '<div class="err-msg">内容过长或生成失败,请缩短后重试。</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getDataURL() {
|
||||
var box = $('qrBox');
|
||||
var canvas = box.querySelector('canvas');
|
||||
if (canvas && canvas.toDataURL) return canvas.toDataURL('image/png');
|
||||
var img = box.querySelector('img');
|
||||
if (img && /^data:/i.test(img.src)) return img.src;
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearQR() {
|
||||
$('qrText').value = '';
|
||||
$('qrBox').innerHTML = '';
|
||||
$('qrStage').hidden = true;
|
||||
$('qrMsg').innerHTML = '';
|
||||
lastValue = '';
|
||||
}
|
||||
|
||||
$('qrGen').addEventListener('click', generate);
|
||||
$('qrText').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') generate(); });
|
||||
$('qrSize').addEventListener('input', function () {
|
||||
$('qrSizeVal').textContent = this.value;
|
||||
if (lastValue) generate();
|
||||
});
|
||||
$('qrClear').addEventListener('click', clearQR);
|
||||
|
||||
$('qrDl').addEventListener('click', function () {
|
||||
if (!lastValue) return;
|
||||
var url = getDataURL();
|
||||
if (!url) { $('qrMsg').innerHTML = '<div class="err-msg">暂无法导出图片。</div>'; return; }
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'qrcode_' + Date.now() + '.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
|
||||
$('qrZoom').addEventListener('click', function () {
|
||||
if (!lastValue) return;
|
||||
var url = getDataURL();
|
||||
var w = window.open('', '_blank');
|
||||
if (!w) { $('qrMsg').innerHTML = '<div class="err-msg">请允许浏览器弹窗以预览大图。</div>'; return; }
|
||||
w.document.write('<title>二维码预览</title><body style="margin:0;background:#fff;display:flex;align-items:center;justify-content:center;min-height:100vh">');
|
||||
if (url) {
|
||||
w.document.write('<img src="' + url + '" style="max-width:95vw;max-height:95vh;image-rendering:pixelated">');
|
||||
} else {
|
||||
w.document.write('<p>无法放大预览</p>');
|
||||
}
|
||||
w.document.write('</body>');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
101
includes/auth.php
Normal file
101
includes/auth.php
Normal file
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* auth.php —— 后台登录会话工具
|
||||
* 密码存储算法(按需求):md5(base64(明文))
|
||||
*/
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
/** 是否已登录 */
|
||||
function is_logged_in(): bool
|
||||
{
|
||||
return !empty($_SESSION['admin']);
|
||||
}
|
||||
|
||||
/** 未登录则跳转登录页(后台各页顶部调用) */
|
||||
function require_login(string $loginPage = 'login.php'): void
|
||||
{
|
||||
if (!is_logged_in()) {
|
||||
header('Location: ' . $loginPage);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/** 计算需求规定的密码散列:先 base64 再 md5 */
|
||||
function hp_password_hash(string $plain): string
|
||||
{
|
||||
return md5(base64_encode($plain));
|
||||
}
|
||||
|
||||
/** 验证用户 */
|
||||
function hp_verify_user(string $username, string $plain): bool
|
||||
{
|
||||
$st = db()->prepare('SELECT password_hash FROM users WHERE username = ? LIMIT 1');
|
||||
$st->execute([$username]);
|
||||
$row = $st->fetch();
|
||||
if (!$row) {
|
||||
return false;
|
||||
}
|
||||
return hash_equals($row['password_hash'], hp_password_hash($plain));
|
||||
}
|
||||
|
||||
/** 执行登录(成功返回 true) */
|
||||
function hp_login(string $username, string $plain): bool
|
||||
{
|
||||
if (hp_verify_user($username, $plain)) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['admin'] = $username;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 修改当前用户密码 */
|
||||
function hp_change_password(string $oldPlain, string $newPlain): array
|
||||
{
|
||||
if (!is_logged_in()) {
|
||||
return [false, '未登录'];
|
||||
}
|
||||
if (!hp_verify_user((string)$_SESSION['admin'], $oldPlain)) {
|
||||
return [false, '原密码不正确'];
|
||||
}
|
||||
if (strlen($newPlain) < 6) {
|
||||
return [false, '新密码至少 6 位'];
|
||||
}
|
||||
$st = db()->prepare('UPDATE users SET password_hash = ? WHERE username = ?');
|
||||
$st->execute([hp_password_hash($newPlain), (string)$_SESSION['admin']]);
|
||||
return [true, '密码修改成功'];
|
||||
}
|
||||
|
||||
/** 退出登录 */
|
||||
function hp_logout(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$p = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
|
||||
}
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
/** CSRF token */
|
||||
function csrf_token(): string
|
||||
{
|
||||
if (empty($_SESSION['csrf'])) {
|
||||
$_SESSION['csrf'] = bin2hex(random_bytes(16));
|
||||
}
|
||||
return (string)$_SESSION['csrf'];
|
||||
}
|
||||
|
||||
function csrf_field(): string
|
||||
{
|
||||
return '<input type="hidden" name="csrf" value="' . htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8') . '">';
|
||||
}
|
||||
|
||||
function csrf_verify(): bool
|
||||
{
|
||||
return isset($_POST['csrf']) && is_string($_POST['csrf']) && hash_equals(csrf_token(), $_POST['csrf']);
|
||||
}
|
||||
206
includes/db.php
Normal file
206
includes/db.php
Normal file
@ -0,0 +1,206 @@
|
||||
<?php
|
||||
/**
|
||||
* db.php —— SQLite 连接与首次初始化
|
||||
* 提供 db() 单例;首次运行时自动建库建表并写入默认分类与设置。
|
||||
*/
|
||||
defined('ROOT_DIR') || define('ROOT_DIR', dirname(__DIR__));
|
||||
defined('DATA_DIR') || define('DATA_DIR', ROOT_DIR . '/data');
|
||||
|
||||
if (!is_dir(DATA_DIR)) {
|
||||
@mkdir(DATA_DIR, 0777, true);
|
||||
}
|
||||
|
||||
function db(): PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
if ($pdo instanceof PDO) {
|
||||
return $pdo;
|
||||
}
|
||||
$file = DATA_DIR . '/homepage.db';
|
||||
try {
|
||||
$pdo = new PDO('sqlite:' . $file);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$pdo->exec('PRAGMA journal_mode=WAL;');
|
||||
db_init($pdo);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
die('数据库初始化失败:' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'));
|
||||
}
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
/** 若旧表缺少某列则补充(SQLite 兼容迁移) */
|
||||
function db_ensure_column(PDO $pdo, string $table, string $column, string $definition): void
|
||||
{
|
||||
$cols = $pdo->query('PRAGMA table_info(' . $table . ')')->fetchAll();
|
||||
foreach ($cols as $col) {
|
||||
if (strtolower((string)$col['name']) === strtolower($column)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
$pdo->exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $column . ' ' . $definition);
|
||||
}
|
||||
|
||||
function db_init(PDO $pdo): void
|
||||
{
|
||||
$pdo->exec('CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
)');
|
||||
$pdo->exec('CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)');
|
||||
$pdo->exec('CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT DEFAULT \'\',
|
||||
description TEXT DEFAULT \'\',
|
||||
sort INTEGER DEFAULT 0
|
||||
)');
|
||||
$pdo->exec('CREATE TABLE IF NOT EXISTS nav_blocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cat_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
sort INTEGER DEFAULT 0
|
||||
)');
|
||||
$pdo->exec('CREATE TABLE IF NOT EXISTS nav_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
block_id INTEGER NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
note TEXT DEFAULT \'\',
|
||||
sort INTEGER DEFAULT 0
|
||||
)');
|
||||
$pdo->exec('CREATE TABLE IF NOT EXISTS func_tools (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT DEFAULT \'\',
|
||||
note TEXT DEFAULT \'\',
|
||||
description TEXT DEFAULT \'\',
|
||||
url TEXT NOT NULL DEFAULT \'\',
|
||||
is_external INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort INTEGER NOT NULL DEFAULT 0
|
||||
)');
|
||||
$pdo->exec('CREATE TABLE IF NOT EXISTS quick_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL DEFAULT \'\',
|
||||
icon TEXT DEFAULT \'\',
|
||||
note TEXT DEFAULT \'\',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort INTEGER NOT NULL DEFAULT 0
|
||||
)');
|
||||
|
||||
// 兼容旧库:为已存在的 categories 表补充 icon 列
|
||||
db_ensure_column($pdo, 'categories', 'icon', "TEXT DEFAULT ''");
|
||||
// 兼容旧库:quick_links / func_tools 若缺列一并补齐
|
||||
db_ensure_column($pdo, 'quick_links', 'note', "TEXT DEFAULT ''");
|
||||
db_ensure_column($pdo, 'func_tools', 'note', "TEXT DEFAULT ''");
|
||||
|
||||
// ---------- 默认分类种子 ----------
|
||||
$count = (int)$pdo->query('SELECT COUNT(*) FROM categories')->fetchColumn();
|
||||
if ($count === 0) {
|
||||
$seedCats = [
|
||||
['popular', '科普库', '📖', 1, '面向大众与初学者的计算机科学、网络安全与前沿技术科普,帮你建立体系化的安全认知。'],
|
||||
['red', '红队库', '⚔️', 2, '面向渗透测试与攻防演练的资源集,汇聚漏洞利用、武器化、情报收集与红队基础设施相关的高质量工具与文档。'],
|
||||
['blue', '蓝队库', '🛡️', 3, '面向防御侧的资源集,汇集应急响应、威胁狩猎、流量分析与取证溯源相关的工具与知识。'],
|
||||
['tool', '工具库', '🧰', 4, '日常效率工具与安全实用小工具都在这里,点击直达功能区。'],
|
||||
];
|
||||
$st = $pdo->prepare('INSERT INTO categories (key, name, icon, sort, description) VALUES (?,?,?,?,?)');
|
||||
foreach ($seedCats as $c) {
|
||||
$st->execute($c);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 默认设置种子 ----------
|
||||
$hasSettings = (int)$pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn();
|
||||
if ($hasSettings === 0) {
|
||||
$defaults = [
|
||||
'site_name' => '知识导航站',
|
||||
'site_slogan' => '汇聚科普、攻防与效率工具的实用首页',
|
||||
'site_logo' => '', // 空 = 使用默认 assets/img/logo.svg
|
||||
'icp_no' => '京ICP备00000000号-1',
|
||||
'gongan_no' => '京公网安备11000000000000号',
|
||||
'gongan_link' => '', // 公安备案号链接(可空则不显示为链接)
|
||||
'copyright' => 'Copyright © 2026 知识导航站 版权所有。本站内容仅供学习研究使用。',
|
||||
'footer_text' => '',
|
||||
'search_engines'=> '', // 搜索引擎列表 JSON;空 = 使用默认 4 个
|
||||
'last_updated' => '',
|
||||
'hero_bg' => '', // 首页顶部标识栏背景(纯色 / CSS 渐变),空 = 主题默认
|
||||
'cdn_ranges' => '', // CDN 段库文本;空 = 使用默认内置列表
|
||||
];
|
||||
$st = $pdo->prepare('INSERT INTO settings (key, value) VALUES (?,?)');
|
||||
foreach ($defaults as $k => $v) {
|
||||
$st->execute([$k, $v]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 默认管理员 ----------
|
||||
$userCount = (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||
if ($userCount === 0) {
|
||||
// 密码算法:先 base64 再 md5(按原始需求实现)
|
||||
$st = $pdo->prepare('INSERT INTO users (username, password_hash) VALUES (?,?)');
|
||||
$st->execute(['admin', md5(base64_encode('admin123'))]);
|
||||
}
|
||||
|
||||
// ---------- 功能区工具默认种子 ----------
|
||||
$toolCount = (int)$pdo->query('SELECT COUNT(*) FROM func_tools')->fetchColumn();
|
||||
if ($toolCount === 0) {
|
||||
$seedTools = [
|
||||
['编码 / 加解密', '🔐', 'Base64 / Base32 / URL / Unicode / MD5 / SHA 系列', 'codec.php', 0],
|
||||
['随机密码生成', '🔑', '自定义字符集与长度,一次生成多组', 'password.php', 0],
|
||||
['二维码生成器', '▦', '任意内容生成二维码,可放大并下载 PNG', 'qrcode.php', 0],
|
||||
['杀软识别', '🛡', '粘贴 tasklist 输出,匹配进程对应的安全软件', 'av.php', 0],
|
||||
];
|
||||
$st = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, sort) VALUES (?,?,?,?,?,1,?)');
|
||||
$sort = 0;
|
||||
foreach ($seedTools as $t) {
|
||||
$st->execute([$t[0], $t[1], $t[2], $t[3], $t[4], ++$sort]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 后期新增功能页默认登记(按 url 去重,允许后台再停用/删除) ----------
|
||||
$extraTools = [
|
||||
['国密加解密', '🔏', 'SM2 非对称加解密 / SM3 摘要 / SM4 对称加解密(SM1 为不公开硬件算法)', 'gmcodec.php'],
|
||||
['IP 地址处理', '🌐', '提取日志中的全部 IPv4 并统计出现次数,再按内网 / CDN / 公网区分展示', 'ip.php'],
|
||||
];
|
||||
$chkUrl = $pdo->prepare('SELECT COUNT(*) FROM func_tools WHERE url = ?');
|
||||
$insTool = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, sort) VALUES (?,?,?,?,0,1,?)');
|
||||
foreach ($extraTools as $t) {
|
||||
$chkUrl->execute([$t[3]]);
|
||||
if ((int)$chkUrl->fetchColumn() === 0) {
|
||||
$mx = (int)$pdo->query('SELECT COALESCE(MAX(sort), 0) FROM func_tools')->fetchColumn();
|
||||
$insTool->execute([$t[0], $t[1], $t[2], $t[3], $mx + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 标记内容已更新(首页展示"上次更新时间") */
|
||||
function touch_last_updated(): void
|
||||
{
|
||||
try {
|
||||
$st = db()->prepare('INSERT INTO settings (key, value) VALUES (\'last_updated\', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value');
|
||||
$st->execute([date('Y-m-d H:i:s')]);
|
||||
} catch (Throwable $e) {
|
||||
// 不阻断主流程
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取一条设置 */
|
||||
function setting_get(string $key, string $default = ''): string
|
||||
{
|
||||
static $cache = null;
|
||||
if ($cache === null) {
|
||||
$cache = [];
|
||||
foreach (db()->query('SELECT key, value FROM settings') as $row) {
|
||||
$cache[$row['key']] = (string)$row['value'];
|
||||
}
|
||||
}
|
||||
return array_key_exists($key, $cache) ? $cache[$key] : $default;
|
||||
}
|
||||
208
includes/layout.php
Normal file
208
includes/layout.php
Normal file
@ -0,0 +1,208 @@
|
||||
<?php
|
||||
/**
|
||||
* layout.php —— 页面公共输出组件
|
||||
* 约定:每个页面在引入本文件前需定义全局 $P(资源前缀,根目录为 '',func/、admin/ 子目录为 '../')。
|
||||
*/
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
if (!isset($P)) {
|
||||
$P = '';
|
||||
}
|
||||
|
||||
/** HTML 转义 */
|
||||
function he($s): string
|
||||
{
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/** 当前 logo 的站点相对路径(后台配置优先,空则默认) */
|
||||
function site_logo_rel(): string
|
||||
{
|
||||
$logo = setting_get('site_logo', '');
|
||||
return $logo !== '' ? $logo : 'assets/img/logo.svg';
|
||||
}
|
||||
|
||||
/** 输出页面 <head>:标题/favicon/主题防闪烁/公共样式 */
|
||||
function layout_head(string $title = ''): void
|
||||
{
|
||||
global $P;
|
||||
$site = setting_get('site_name', '知识导航站');
|
||||
$logo = site_logo_rel();
|
||||
$fullTitle = ($title !== '' ? $title . ' - ' : '') . $site;
|
||||
$prefix = $P;
|
||||
echo '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">';
|
||||
echo '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
|
||||
echo '<meta name="renderer" content="webkit">';
|
||||
// 主题防闪烁:在任何样式加载前应用
|
||||
echo '<script>(function(){var t;try{t=localStorage.getItem("hp-theme")}catch(e){}';
|
||||
echo 'if(!t){t=(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)?"dark":"light"}';
|
||||
echo 'document.documentElement.setAttribute("data-theme",t);})();</script>';
|
||||
echo '<title>' . he($fullTitle) . '</title>';
|
||||
echo '<link rel="icon" href="' . he($prefix . $logo) . '">';
|
||||
echo '<link rel="stylesheet" href="' . he($prefix) . 'assets/css/common.css">';
|
||||
echo '</head><body>' . "\n";
|
||||
}
|
||||
|
||||
/** 昼夜切换浮动按钮 */
|
||||
function layout_theme_fab(): void
|
||||
{
|
||||
echo '<button type="button" class="theme-fab js-theme-btn" title="切换昼夜模式" aria-label="切换昼夜模式">';
|
||||
echo '<span class="js-theme-icon"></span></button>' . "\n";
|
||||
}
|
||||
|
||||
/** 站内 logo 图片(内容区使用,同 favicon) */
|
||||
function layout_logo_img(string $cls = 'logo', string $alt = 'logo'): void
|
||||
{
|
||||
global $P;
|
||||
echo '<img class="' . he($cls) . '" src="' . he($P . site_logo_rel()) . '" alt="' . he($alt) . '">';
|
||||
}
|
||||
|
||||
/** 全站页脚 */
|
||||
function layout_footer(): void
|
||||
{
|
||||
global $P;
|
||||
$site = setting_get('site_name', '知识导航站');
|
||||
$icp = setting_get('icp_no', '');
|
||||
$gongan = setting_get('gongan_no', '');
|
||||
$gonganLink = setting_get('gongan_link', '');
|
||||
$copyright = setting_get('copyright', '');
|
||||
$extra = setting_get('footer_text', '');
|
||||
echo '<footer class="footer"><div class="wrap footer-inner">';
|
||||
if ($icp !== '') {
|
||||
echo '<div><a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" style="color:inherit">' . he($icp) . '</a></div>';
|
||||
}
|
||||
if ($gongan !== '') {
|
||||
if ($gonganLink !== '') {
|
||||
echo '<div><a href="' . he($gonganLink) . '" target="_blank" rel="noopener noreferrer" style="color:inherit">' . he($gongan) . '</a></div>';
|
||||
} else {
|
||||
echo '<div>' . he($gongan) . '</div>';
|
||||
}
|
||||
}
|
||||
if ($copyright !== '') {
|
||||
echo '<div>' . he(str_replace('{site_name}', $site, $copyright)) . '</div>';
|
||||
}
|
||||
if ($extra !== '') {
|
||||
echo '<div>' . he($extra) . '</div>';
|
||||
}
|
||||
echo '<div><a class="admin-link" href="' . he($P) . 'admin/login.php">后台管理</a></div>';
|
||||
echo '</div></footer>' . "\n";
|
||||
}
|
||||
|
||||
/** 读取首页展示的名言(quota.txt 随机一行) */
|
||||
function home_quote(): string
|
||||
{
|
||||
$file = DATA_DIR . '/quota.txt';
|
||||
$lines = [];
|
||||
if (is_file($file)) {
|
||||
$raw = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if (is_array($raw)) {
|
||||
foreach ($raw as $ln) {
|
||||
$ln = trim($ln);
|
||||
if ($ln !== '' && strpos($ln, '#') !== 0) {
|
||||
$lines[] = $ln;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$lines) {
|
||||
return '知识就是力量。';
|
||||
}
|
||||
return $lines[array_rand($lines)];
|
||||
}
|
||||
|
||||
/** 图标值是否属于图片(dataURL / http / 常见图片扩展 / 相对路径) */
|
||||
function icon_is_image(string $raw): bool
|
||||
{
|
||||
$v = trim($raw);
|
||||
if ($v === '') {
|
||||
return false;
|
||||
}
|
||||
if (preg_match('/^data:image\//i', $v) || preg_match('#^https?://#i', $v)) {
|
||||
return true;
|
||||
}
|
||||
if (preg_match('/\.(png|jpe?g|gif|svg|webp)(\?|#|$)/i', $v)) {
|
||||
return true;
|
||||
}
|
||||
return $v[0] === '/' || strpos($v, './') === 0 || strpos($v, '../') === 0;
|
||||
}
|
||||
|
||||
/** 渲染图标:图片返回 <img>,否则返回转义文本(供前台/后台预览复用) */
|
||||
function site_icon(?string $raw, string $alt = 'icon'): string
|
||||
{
|
||||
$v = trim((string)$raw);
|
||||
if ($v === '') {
|
||||
return '';
|
||||
}
|
||||
if (icon_is_image($v)) {
|
||||
return '<img class="icon-img" src="' . he($v) . '" alt="' . he($alt) . '" loading="lazy">';
|
||||
}
|
||||
return he($v);
|
||||
}
|
||||
|
||||
/** 默认 CDN IPv4 段库(每行:CIDR + 空格 + 厂商名;# 开头为注释,由后台可覆盖) */
|
||||
function default_cdn_ranges(): string
|
||||
{
|
||||
return implode("\n", [
|
||||
'# 内置默认:Cloudflare 官方 IPv4 段(可在后台“CDN IP 段库”补充其它厂商)',
|
||||
'103.21.244.0/22 Cloudflare',
|
||||
'103.22.200.0/22 Cloudflare',
|
||||
'103.31.4.0/22 Cloudflare',
|
||||
'104.16.0.0/13 Cloudflare',
|
||||
'104.24.0.0/14 Cloudflare',
|
||||
'108.162.192.0/18 Cloudflare',
|
||||
'131.0.72.0/22 Cloudflare',
|
||||
'141.101.64.0/18 Cloudflare',
|
||||
'162.158.0.0/15 Cloudflare',
|
||||
'172.64.0.0/13 Cloudflare',
|
||||
'173.245.48.0/20 Cloudflare',
|
||||
'188.114.96.0/20 Cloudflare',
|
||||
'190.93.240.0/20 Cloudflare',
|
||||
'197.234.240.0/22 Cloudflare',
|
||||
'198.41.128.0/17 Cloudflare',
|
||||
]);
|
||||
}
|
||||
|
||||
/** 当前生效的 CDN 段库文本(后台配置优先,为空回退默认内置列表) */
|
||||
function cdn_ranges_text(): string
|
||||
{
|
||||
$v = setting_get('cdn_ranges', '');
|
||||
return $v !== '' ? $v : default_cdn_ranges();
|
||||
}
|
||||
|
||||
/** 默认搜索引擎列表(后台未配置时的兜底) */
|
||||
function default_search_engines(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'bing', 'name' => 'Bing', 'icon' => 'B', 'url' => 'https://www.bing.com/search?q={kw}'],
|
||||
['key' => 'baidu', 'name' => '百度', 'icon' => '度', 'url' => 'https://www.baidu.com/s?wd={kw}'],
|
||||
['key' => 'github', 'name' => 'GitHub', 'icon' => 'G', 'url' => 'https://github.com/search?q={kw}'],
|
||||
['key' => 'google', 'name' => 'Google', 'icon' => 'G', 'url' => 'https://www.google.com/search?q={kw}'],
|
||||
];
|
||||
}
|
||||
|
||||
/** 读取当前生效的搜索引擎列表(优先后台配置,无效或为空回退默认) */
|
||||
function search_engines(): array
|
||||
{
|
||||
$raw = setting_get('search_engines', '');
|
||||
if ($raw === '') {
|
||||
return default_search_engines();
|
||||
}
|
||||
$arr = json_decode($raw, true);
|
||||
if (!is_array($arr) || count($arr) === 0) {
|
||||
return default_search_engines();
|
||||
}
|
||||
$out = [];
|
||||
foreach ($arr as $i => $e) {
|
||||
if (!is_array($e)) continue;
|
||||
$name = trim((string)($e['name'] ?? ''));
|
||||
$url = trim((string)($e['url'] ?? ''));
|
||||
$icon = trim((string)($e['icon'] ?? ''));
|
||||
if ($name === '' || $url === '' || strpos($url, '{kw}') === false) continue;
|
||||
$key = trim((string)($e['key'] ?? ''));
|
||||
if ($key === '') {
|
||||
$key = 'e' . ((int)$i + 1);
|
||||
}
|
||||
$out[] = ['key' => $key, 'name' => $name, 'icon' => ($icon !== '' ? $icon : 'S'), 'url' => $url];
|
||||
}
|
||||
return $out !== [] ? $out : default_search_engines();
|
||||
}
|
||||
143
index.php
Normal file
143
index.php
Normal file
@ -0,0 +1,143 @@
|
||||
<?php
|
||||
/**
|
||||
* index.php —— 首页区(最前置页面)
|
||||
* 结构:Hero 展示栏 / 名言条 / 四大板块 / 页脚
|
||||
*/
|
||||
$P = '';
|
||||
require_once __DIR__ . '/includes/layout.php';
|
||||
|
||||
$siteName = setting_get('site_name', '知识导航站');
|
||||
$siteSlogan = setting_get('site_slogan', '');
|
||||
$heroBg = setting_get('hero_bg', ''); // 后台自定义顶部背景(纯色 / CSS 渐变)
|
||||
$lastUpdated = setting_get('last_updated', '');
|
||||
if ($lastUpdated === '') {
|
||||
$lastUpdated = '暂无更新记录';
|
||||
}
|
||||
|
||||
// 名言:服务端随机一条 + 全量池(供点击格言本身切换)
|
||||
$quoteFile = DATA_DIR . '/quota.txt';
|
||||
$quotes = [];
|
||||
if (is_file($quoteFile)) {
|
||||
$raw = @file($quoteFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if (is_array($raw)) {
|
||||
foreach ($raw as $ln) {
|
||||
$ln = trim($ln);
|
||||
if ($ln !== '' && strpos($ln, '#') !== 0) {
|
||||
$quotes[] = $ln;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$quoteText = $quotes ? $quotes[array_rand($quotes)] : '知识就是力量。';
|
||||
|
||||
// 四大板块(按后台排序输出)
|
||||
$cats = db()->query('SELECT * FROM categories ORDER BY sort ASC, id ASC')->fetchAll();
|
||||
// 首页快捷站点(后台配置,新标签页跳转本域名其它网站)
|
||||
$quickLinks = db()->query('SELECT * FROM quick_links WHERE enabled = 1 ORDER BY sort ASC, id ASC')->fetchAll();
|
||||
|
||||
$catLink = [
|
||||
'popular' => 'nav.php?cat=popular',
|
||||
'red' => 'nav.php?cat=red',
|
||||
'blue' => 'nav.php?cat=blue',
|
||||
'tool' => 'func/index.php',
|
||||
];
|
||||
$catIcon = [
|
||||
'popular' => '📖',
|
||||
'red' => '⚔️',
|
||||
'blue' => '🛡️',
|
||||
'tool' => '🧰',
|
||||
];
|
||||
$catHint = [
|
||||
'popular' => '入门·科普·前沿',
|
||||
'red' => '攻防演练资源',
|
||||
'blue' => '防御·应急·溯源',
|
||||
'tool' => '进入功能区',
|
||||
];
|
||||
|
||||
layout_head('');
|
||||
?>
|
||||
<!-- Hero 顶部展示栏 -->
|
||||
<header class="hero"<?php if ($heroBg !== ''): ?> style="background:<?= he($heroBg) ?>"<?php endif; ?>>
|
||||
<div class="wrap hero-inner">
|
||||
<div class="hero-top">
|
||||
<div class="brand">
|
||||
<?php layout_logo_img('logo', $siteName); ?>
|
||||
<div>
|
||||
<div class="site-name"><?= he($siteName) ?></div>
|
||||
<?php if ($siteSlogan !== ''): ?>
|
||||
<div class="site-slogan"><?= he($siteSlogan) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<div class="hm-row"><span>当前时间 </span><span class="val js-clock">--</span></div>
|
||||
<div class="hm-row"><span>上次更新 </span><span class="val"><?= he($lastUpdated) ?></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="weather-box">
|
||||
<iframe width="800" height="150" src="https://i.tianqi.com/?c=code&a=getcode&id=48&num=6&icon=1" frameborder="0"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 名言条 -->
|
||||
<div class="quote-bar">
|
||||
<div class="wrap quote-inner">
|
||||
<span class="quote-mark"></span>
|
||||
<span class="quote-text js-quote" data-current="0" title="点击换一条"><?= he($quoteText) ?></span>
|
||||
<span class="quote-mark"></span>
|
||||
</div>
|
||||
<div class="quote-pool js-quote-pool">
|
||||
<?php foreach ($quotes as $q): ?>
|
||||
<span><?= he($q) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 四大板块区 -->
|
||||
<section class="board-section">
|
||||
<div class="wrap">
|
||||
<?php if ($quickLinks): ?>
|
||||
<div class="quick-strip">
|
||||
<?php foreach ($quickLinks as $ql):
|
||||
$qIcon = trim((string)($ql['icon'] ?? ''));
|
||||
$qIcon = $qIcon !== '' ? $qIcon : '🔗';
|
||||
$qNote = trim((string)($ql['note'] ?? ''));
|
||||
?>
|
||||
<a class="quick-chip" href="<?= he((string)$ql['url']) ?>" target="_blank" rel="noopener noreferrer"<?php if ($qNote !== ''): ?> data-note="<?= he($qNote) ?>"<?php endif; ?>>
|
||||
<span class="q-icon"><?= site_icon($qIcon, '') ?></span>
|
||||
<span><?= he((string)$ql['name']) ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="boards js-boards">
|
||||
<!-- 展开说明浮层(不参与布局,不可点击) -->
|
||||
<div class="board-panel js-board-panel" aria-hidden="true">
|
||||
<div class="board-panel-title js-board-panel-title"></div>
|
||||
<div class="board-panel-desc js-board-panel-desc"></div>
|
||||
</div>
|
||||
<?php foreach ($cats as $c):
|
||||
$k = (string)$c['key'];
|
||||
$href = $catLink[$k] ?? 'index.php';
|
||||
$bIcon = trim((string)($c['icon'] ?? ''));
|
||||
$bIcon = $bIcon !== '' ? $bIcon : ($catIcon[$k] ?? '●');
|
||||
$hint = $catHint[$k] ?? '进入';
|
||||
?>
|
||||
<a class="board-card js-board-card" href="<?= he($href) ?>"
|
||||
data-name="<?= he($c['name']) ?>" data-desc="<?= he((string)$c['description']) ?>">
|
||||
<span class="board-icon"><?= site_icon($bIcon, '') ?></span>
|
||||
<span class="board-name"><?= he($c['name']) ?></span>
|
||||
<span class="board-hint"><?= he($hint) ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php layout_theme_fab(); ?>
|
||||
<?php layout_footer(); ?>
|
||||
|
||||
<script src="assets/js/common.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
145
nav.php
Normal file
145
nav.php
Normal file
@ -0,0 +1,145 @@
|
||||
<?php
|
||||
/**
|
||||
* nav.php —— 导航区模板(科普库 / 红队库 / 蓝队库共用)
|
||||
* URL 参数:cat=popular | red | blue
|
||||
* 布局:顶部留白区(含小型品牌条)→ 搜索栏 → 三大列导航块区
|
||||
*/
|
||||
$P = '';
|
||||
require_once __DIR__ . '/includes/layout.php';
|
||||
|
||||
$catKey = isset($_GET['cat']) ? (string)$_GET['cat'] : '';
|
||||
$allowed = ['popular', 'red', 'blue'];
|
||||
if (!in_array($catKey, $allowed, true)) {
|
||||
// 非法或缺失分类:默认指向科普库更友好(导航页只承载三大知识库)
|
||||
$catKey = 'popular';
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
$st = $pdo->prepare('SELECT * FROM categories WHERE key = ? LIMIT 1');
|
||||
$st->execute([$catKey]);
|
||||
$cat = $st->fetch();
|
||||
if (!$cat) {
|
||||
http_response_code(404);
|
||||
layout_head('导航页不存在');
|
||||
echo '<main class="page-main"><div class="wrap"><div class="err-msg">导航分类不存在,请返回首页。</div></div></main>';
|
||||
layout_theme_fab();
|
||||
layout_footer();
|
||||
echo '<script src="assets/js/common.js"></script></body></html>';
|
||||
exit;
|
||||
}
|
||||
|
||||
// 块与按钮数据
|
||||
$st = $pdo->prepare('SELECT * FROM nav_blocks WHERE cat_id = ? ORDER BY sort ASC, id ASC');
|
||||
$st->execute([(int)$cat['id']]);
|
||||
$blocks = $st->fetchAll();
|
||||
|
||||
$itemsByBlock = [];
|
||||
if ($blocks) {
|
||||
$ids = array_map(function ($b) { return (int)$b['id']; }, $blocks);
|
||||
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||
$st = $pdo->prepare("SELECT * FROM nav_items WHERE block_id IN ($in) ORDER BY sort ASC, id ASC");
|
||||
$st->execute($ids);
|
||||
foreach ($st->fetchAll() as $item) {
|
||||
$itemsByBlock[(int)$item['block_id']][] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
// 三列轮转分配(保证列内块数均衡)
|
||||
$cols = [[], [], []];
|
||||
$idx = 0;
|
||||
foreach ($blocks as $b) {
|
||||
$cols[$idx % 3][] = $b;
|
||||
$idx++;
|
||||
}
|
||||
$totalItems = 0;
|
||||
foreach ($itemsByBlock as $list) {
|
||||
$totalItems += count($list);
|
||||
}
|
||||
|
||||
$siteName = setting_get('site_name', '知识导航站');
|
||||
$engines = search_engines();
|
||||
$defaultEngine = $engines[0]['key'] ?? 'bing';
|
||||
$enginesJson = json_encode($engines, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
layout_head((string)$cat['name']);
|
||||
?>
|
||||
<!-- 顶部留白 + 品牌条 -->
|
||||
<div class="nav-top">
|
||||
<div class="wrap" style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;flex-wrap:wrap;">
|
||||
<a class="brand brand-sm" href="index.php" title="返回首页">
|
||||
<?php layout_logo_img('logo', $siteName); ?>
|
||||
<span class="site-name"><?= he($siteName) ?></span>
|
||||
</a>
|
||||
<span class="board-tag" style="font-size:13px;color:var(--text-3);padding-top:10px;"><?= he((string)$cat['name']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-zone">
|
||||
<div class="wrap">
|
||||
<div class="search-box js-search" data-engine="<?= he($defaultEngine) ?>" data-engines="<?= he($enginesJson) ?>">
|
||||
<button type="button" class="engine-btn js-engine-btn" title="切换搜索引擎">
|
||||
<span class="js-engine-name"><?= he($engines[0]['icon'] ?? 'S') ?></span>
|
||||
<span class="arrow">▼</span>
|
||||
<ul class="engine-menu js-engine-menu"></ul>
|
||||
</button>
|
||||
<div class="search-field">
|
||||
<input type="text" class="js-search-input" placeholder="输入关键词,回车或点击搜索…" autocomplete="off">
|
||||
<button type="button" class="search-clear js-search-clear" title="清除">✕</button>
|
||||
</div>
|
||||
<button type="button" class="search-go js-search-go">搜索</button>
|
||||
</div>
|
||||
<p class="search-title">可切换搜索引擎进行搜索(<?= count($engines) ?> 个可用,后台可维护)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导航内容区:左右各留白 15%,中间三大列 -->
|
||||
<div class="nav-area">
|
||||
<div class="nav-area-head">
|
||||
<span class="nav-area-title"><?= he((string)$cat['name']) ?></span>
|
||||
<span class="nav-area-count">共 <?= count($blocks) ?> 个导航块 / <?= $totalItems ?> 个链接</span>
|
||||
</div>
|
||||
|
||||
<?php if (!$blocks): ?>
|
||||
<div class="panel-card"><div class="tip">该库还没有导航内容,可在后台"导航内容管理"中添加。</div></div>
|
||||
<?php else: ?>
|
||||
<div class="nav-cols">
|
||||
<?php foreach ($cols as $colIdx => $colBlocks): ?>
|
||||
<div class="nav-col">
|
||||
<?php foreach ($colBlocks as $b):
|
||||
$items = $itemsByBlock[(int)$b['id']] ?? [];
|
||||
$show = array_slice($items, 0, 15);
|
||||
?>
|
||||
<div class="nav-block">
|
||||
<div class="nav-block-title"><?= he((string)$b['title']) ?></div>
|
||||
<div class="nav-block-grid">
|
||||
<?php for ($i = 0; $i < 15; $i++): ?>
|
||||
<?php if (isset($show[$i])):
|
||||
$it = $show[$i];
|
||||
$label = (string)$it['label'];
|
||||
$url = trim((string)$it['url']);
|
||||
$note = trim((string)$it['note']);
|
||||
if ($url !== ''): ?>
|
||||
<a class="nav-btn" href="<?= he($url) ?>" target="_blank" rel="noopener noreferrer"<?php if ($note !== ''): ?> data-note="<?= he($note) ?>"<?php endif; ?>><?= he($label ?: $url) ?></a>
|
||||
<?php else: ?>
|
||||
<span class="nav-btn is-off" title="链接未配置"><?= he($label) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<span class="nav-btn is-empty"></span>
|
||||
<?php endif; ?>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php layout_theme_fab(); ?>
|
||||
<?php layout_footer(); ?>
|
||||
|
||||
<script src="assets/js/common.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
0
nginx.htaccess
Normal file
0
nginx.htaccess
Normal file
50
plan.txt
Normal file
50
plan.txt
Normal file
@ -0,0 +1,50 @@
|
||||
我现在需要你去写一个网站首页,要求如下:
|
||||
|
||||
页面分为四种:
|
||||
1.首页区
|
||||
2.导航区
|
||||
3.功能区
|
||||
4.后台区
|
||||
|
||||
技术栈:
|
||||
常见网页技术都行:包括但不限于js、html、php、css。
|
||||
layui等样式均可使用
|
||||
|
||||
基础页面展示要求:
|
||||
1.首页区要求
|
||||
主内容占据页面60%内容
|
||||
主内容为:占据页面20%的展示栏位于上方,下方40%为导航区块,用户可自行选择四大板块
|
||||
分别为:科普库,蓝队库,红队库,工具库
|
||||
剩余40%空间,上部10%放置名人名言(从quota.txt中获取),(中部为主内容),下部放置ICP备案号,公安备案号,版权声明和其他内容
|
||||
2.导航区要求
|
||||
最大限度保证信息密度和美观度,同时页面分为两部分,上半部分占据20%,为搜索栏
|
||||
(注意,最上方留白10%)
|
||||
下半部分为导航区域
|
||||
导航区域要求:
|
||||
PC页面两侧各留白15%,中间划分为三大列,导航块内容分别置于各列中(大列无标题)
|
||||
导航块为固定一标题加三小列五行的格式,标题加粗居左,行列内容为导航按钮块,鼠标移动上方时变色(若存在备注信息则会在按钮上分蹦出来一个语言栏,没有则仅变色),点击后新建一个页面打开该链接
|
||||
每个导航块大小固定,在移动设备页面中,单小列显示
|
||||
3.功能区要求
|
||||
设计一个功能区首页(要求与其他页面的风格一致)
|
||||
顶部居中大展示栏,最右侧为首页按钮,点击返回首页区,最左侧为返回功能区首页按钮,中间为本功能页标题
|
||||
你需要设计以下功能:
|
||||
编码/加解密页:Base64编解码,Base32编解码,md5加密,url编解码,unicode编解码(需支持中文以及其他复杂内容的编解码),各类sha加密
|
||||
注意:页面需同时具备可自由编辑输入的输入和输出区块
|
||||
随机密码生成页:用户自由选择大写、小写字母,数字和特殊符号或者自定义内容并自己选择密码长度进行随机密码生成
|
||||
二维码生成器:用户可放入内容用以生成二维码(可自由放大图片并下载)
|
||||
杀软识别:识别输入框内的tasklist内容,随后在avlist.js中匹配内容,以表格形式输出匹配结果
|
||||
格式示例:avList = {"360tray.exe": "360 安全卫士 - 实时保护",....}
|
||||
4.后台区要求
|
||||
具备对其他页面进行调整的功能
|
||||
1.顺序排布
|
||||
2.内容设计
|
||||
3.导航区导航内容的增删改查
|
||||
|
||||
使用sqlite存储登录密码以及其他内容(密码加密格式:先base64再进行md5)
|
||||
|
||||
高级要求:
|
||||
展示栏:一个居中小区块,左侧为logo,右侧为当前时间和上次更新时间,在其下还有天气区块(<iframe width="500" height="40" src="https://i.tianqi.com/?c=code&a=getcode&id=173&icon=1" frameborder="0"></iframe>)
|
||||
搜索栏:最左侧为切换按钮,可自由切换导航引擎为bing,baidu,github等,中间为输入框,最右侧为搜索按钮,存在输入内容时在输入框最右侧显示清除按钮,用户可点击搜索按钮或者直接回车打开新页面搜索内容
|
||||
首页四大板块在用户鼠标移动至上方时用延申文本板块,覆盖其他几个板块并展示说明,该延申文本不具备点击效果以及其他特性,用户鼠标移出该四大板块区域时消失,或者移动至其他板块时以同样的效果展开显示
|
||||
注意,若鼠标所在的板块不在最左侧或最右侧,则在展开说明时移动至旁侧(即最左侧或者最右侧),这时,实际板块位置不变,若用户将鼠标移至其他板块上方,则对该板块进行展开
|
||||
各类输入输出框:支持拉动右下侧边角进行区域扩大(竖向扩大,横向不变),搜索框除外
|
||||
546
plan_v2.md
Normal file
546
plan_v2.md
Normal file
@ -0,0 +1,546 @@
|
||||
# 网站首页修订版 Plan(v2)
|
||||
|
||||
> 本文件在 `plan.txt` 基础上修订:补全缺失说明、澄清歧义、统一各处术语。
|
||||
> 凡原 plan 未说明而本文给出默认值的,均已标注 **【决策】**,可自行调整。
|
||||
> 本版以"可直接指导编码实现"为标准编写,页面清单、数据模型、布局规格均已明确。
|
||||
|
||||
---
|
||||
|
||||
## 1. 修订要点总览(原 plan 问题 → 本版处理)
|
||||
|
||||
| # | 原 plan 问题 | 本版处理 |
|
||||
| --- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| 1 | 页面体系与跳转关系未定义 | §2 站点地图,明确首页为最前置页,三大知识库各有独立导航区,工具库跳转功能区 |
|
||||
| 2 | 首页百分比布局自相矛盾(60% 主内容 / 20% 展示栏 / 40% 导航 / 剩余 40% 里"中部主内容") | §4 重构为自洽的分区模型,保留 20% 展示栏 + 40% 板块区意图 |
|
||||
| 3 | 技术栈表述笼统 | §3 确定 PHP + SQLite(PDO) + 原生 HTML/CSS/JS,可加 layui |
|
||||
| 4 | 导航区"留白 10% / 上半部 20%"表述含糊 | §5 给出明确高度计算规则 |
|
||||
| 5 | "按钮上分蹦出来一个语言栏"(疑似笔误) | §5 定义为悬浮气泡 tooltip 显示备注 |
|
||||
| 6 | 导航块数据来源、后台归属、超量排布未定义 | §5.3 + §7 数据模型 + §6.4 分栏规则 |
|
||||
| 7 | 功能区首页布局缺失、顶部组件关系不清 | §6 定义功能区首页网格布局 + 三类顶部组件分工 |
|
||||
| 8 | 后台"顺序排布 / 内容设计"对象空泛 | §7.3 拆解为可操作的具体配置项 |
|
||||
| 9 | 登录会话 / 初始管理员 / 越权访问缺失 | §7.1、§7.2 补齐 |
|
||||
| 10 | quota.txt、avlist.js 格式未约定 | §10 明确格式 |
|
||||
| 11 | 备案号、版权文本来源未说明 | §7.3.2 后台可配置 + 默认占位 |
|
||||
| 12 | 移动端适配规则不全 | §9 响应式断点表 |
|
||||
| 13 | 目录结构缺失 | §8 目录规划 |
|
||||
| 14 | "上次更新时间"无定义 | §4.5 定义数据来源与刷新逻辑 |
|
||||
| 15 | 密码"base64+md5"安全局限 | §7.1 注明局限(按需求实现) |
|
||||
| 16 | 板块 hover 展开交互描述绕口 | §4.6 重写为实现级规格 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 站点地图与页面跳转关系
|
||||
|
||||
站点逻辑结构(已确认):
|
||||
|
||||
```
|
||||
首页区(index)
|
||||
├─ 科普库 → 科普导航页
|
||||
├─ 红队库 → 红队导航页
|
||||
├─ 蓝队库 → 蓝队导航页
|
||||
└─ 工具库 → 功能区
|
||||
功能区首页
|
||||
├─ 编码/加解密页
|
||||
├─ 随机密码生成页
|
||||
├─ 二维码生成器
|
||||
└─ 杀软识别页
|
||||
后台区(/admin,需登录)
|
||||
```
|
||||
|
||||
页面清单:
|
||||
|
||||
| 区域 | 页面 | 文件(建议) | 说明 |
|
||||
| -------- | ------------------------ | --------------------- | ----------------------------------------------------------------------------- |
|
||||
| 首页区 | 首页 | `index.php` | 最前置页面 |
|
||||
| 导航区 | 科普库导航页 | `nav.php?cat=popular` | 科普 / 红队 / 蓝队 三个导航页共用同一模板,按分类参数取数,等价于三个独立页面 |
|
||||
| 导航区 | 红队库导航页 | `nav.php?cat=red` | 同上 |
|
||||
| 导航区 | 蓝队库导航页 | `nav.php?cat=blue` | 同上 |
|
||||
| 功能区 | 功能区首页 | `func/index.php` | 工具入口列表 |
|
||||
| 功能区 | 编码/加解密 | `func/codec.php` | |
|
||||
| 功能区 | 随机密码生成 | `func/password.php` | |
|
||||
| 功能区 | 二维码生成器 | `func/qrcode.php` | |
|
||||
| 功能区 | 杀软识别 | `func/av.php` | |
|
||||
| 后台区 | 登录页 | `admin/login.php` | |
|
||||
| 后台区 | 导航内容管理(增删改查) | `admin/nav.php` | 含排序 |
|
||||
| 后台区 | 功能区管理(工具入口) | `admin/tools.php` | 工具增删改/排序/启停,见 §6.2 |
|
||||
| 后台区 | 顺序排布 / 内容设计 | `admin/content.php` | 见 §7.3 |
|
||||
| 数据接口 | JSON 输出 | `api/nav.php` 等 | 供页面 JS 取导航数据 |
|
||||
|
||||
> **【决策】文件全部采用 `.php`**:便于统一 include 顶部栏/页脚、做导航数据渲染、后台鉴权;
|
||||
> 纯前端静态页若要读 sqlite 也必须走后端接口,统一为 PHP 最省事。
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术栈与运行环境
|
||||
|
||||
- 后端:**PHP ≥ 7.4 + PDO_SQLITE 扩展**,数据库 `data/homepage.db`(SQLite3 文件)。
|
||||
- 前端:原生 HTML5 + CSS3 + 原生 JS;允许引入 layui 或轻量库(见 §6 工具页选型)。
|
||||
- 编码类算法:优先原生 Web Crypto(SHA);UTF-8 编解码用 `TextEncoder`/`TextDecoder`;如需统一兼容 IE 类场景才引入 `crypto-js`。**【决策:现代浏览器优先,不兼容老 IE】**
|
||||
- 二维码:引入 `qrcodejs`(单文件 JS 库)。
|
||||
- 部署形态:最终放置于服务器(Apache/Nginx + PHP),文档根目录即本工程目录。
|
||||
|
||||
> 实现约束:编码/加解密、密码生成等均为纯前端 JS 计算,不产生请求;仅导航数据与后台写库走 PHP。
|
||||
|
||||
---
|
||||
|
||||
## 4. 首页区规格(index.php)
|
||||
|
||||
### 4.1 整体分区(自洽版布局)
|
||||
|
||||
页面允许纵向滚动(**不用 JS 死锁 100vh**,避免小屏挤压)。各区块高度按"视口高度比例(vh)+ 自然内容高度"混用,规格如下:
|
||||
|
||||
| 区块 | 顺序 | 高度规格 | 内容 |
|
||||
| ------------- | ---- | ----------------------------------- | ----------------- |
|
||||
| A. 顶部展示栏 | 1 | 约 20vh(自适应,最小不低于 120px) | 见 §4.2 |
|
||||
| B. 名言条 | 2 | 内容自适应(约占 10% 视口) | 随机名言,见 §4.3 |
|
||||
| C. 四大板块区 | 3 | 约 40vh 起,内容超高可自然延伸 | 见 §4.4 / §4.6 |
|
||||
| D. 页脚 | 4 | 内容自适应 | 见 §4.7 |
|
||||
|
||||
> 原 plan"主内容占 60%(20% 展示栏 + 40% 板块)+ 剩余 40% 内 10% 名言 + 下部备案"存在加总冲突,
|
||||
> 本版将"20% 展示栏 + 40% 板块 + 10% 名言"作为主要视口内规划(合计约 70%,留有余量),备案等落入页脚区块,不再要求凑满 100vh。
|
||||
|
||||
### 4.2 顶部展示栏(首页版)
|
||||
|
||||
- 整体居中布局,内部水平分布:
|
||||
- **左侧**:站点 Logo + 站点名称/副标题。
|
||||
- **右侧**:当前时间(实时刷新,格式 `YYYY-MM-DD HH:mm:ss`)+ 上次更新时间(见 §4.5)。
|
||||
- **下方居中**:天气区块:`<iframe width="500" height="40" src="https://i.tianqi.com/?c=code&a=getcode&id=173&icon=1" frameborder="0"></iframe>`(在移动端可改为宽度 100% 居中,iframe 原样保留尺寸缩放)。
|
||||
- 展示栏背景与整体风格统一(§4.8)。
|
||||
|
||||
### 4.3 名言条(B 区块)
|
||||
|
||||
- 内容源:`data/quota.txt`,每次**刷新页面随机取一条**(也可用 JS 刷新按钮换一条)。
|
||||
- 文件格式见 §10.1。
|
||||
- 名言为空/文件缺失时显示默认文案,页面不报错。
|
||||
|
||||
### 4.4 四大板块区(C 区块)
|
||||
|
||||
- 四个板块横排均分,按后台 categories 顺序渲染:
|
||||
**科普库 → 科普导航页**、**红队库 → 红队导航页**、**蓝队库 → 蓝队导航页**、**工具库 → 功能区首页**。
|
||||
- 板块区上方不再显示“四大板块”大标题,改为**快捷站点胶囊条**(数据表 `quick_links`,后台“内容与顺序”维护):
|
||||
- 用于放置本域名下其它网站,点击 `target="_blank"` 新标签页打开且保留本页;
|
||||
- 无配置时不显示该条,板块区保持居中填满布局。
|
||||
- 每个板块卡片内容:图标 + 名称 + 一句话简介(文案后台可配,见 §7.3.2)。
|
||||
- 点击行为:科普/红/蓝打开对应导航页;工具库打开功能区首页。
|
||||
- 基础悬停:变色/放大反馈;高级展开说明见 §4.6。
|
||||
|
||||
### 4.5 "上次更新时间"定义(【决策】)
|
||||
|
||||
含义 = **导航库内容最近一次被后台修改的时间**。
|
||||
|
||||
- 后台任何写库操作成功后,同步写入 `settings` 表键 `last_updated`(格式时间戳)。
|
||||
- 页面通过 PHP 端直接输出该值;无记录时显示"暂无更新记录"。
|
||||
- 不跟随"站点上线时间",只代表内容数据新鲜度。
|
||||
|
||||
### 4.6 板块 hover 展开说明(高级要求,实现级规格)
|
||||
|
||||
目的:鼠标悬停某板块时,弹出**只读说明层**,覆盖在板块区上方,用于解释该库是什么;该层**不可点击、不参与布局**(position: absolute 叠层)。
|
||||
|
||||
规则:
|
||||
|
||||
1. 悬停任一板块 → 生成该板块说明浮层,覆盖其上方并向外**延展**,延展优先方向:
|
||||
- 处于最左侧板块:向右延展;处于最右侧板块:向左延展;
|
||||
- 处于中间板块时:浮层**移位显示到距其最近的边缘空白侧**(第 2 块→吸附左侧边缘展开,第 3 块→吸附右侧边缘展开)。
|
||||
2. 所有板块的**实际位置在展开过程中不移动**(视觉上"旁侧浮现"由浮层绝对定位实现,不做网格挤压)。
|
||||
3. 鼠标移出四大板块整体区域 → 浮层消失。
|
||||
4. 鼠标从一块移到另一块 → 浮层内容与吸附位置即时切换为该板块。
|
||||
5. 浮层 z-index 高于板块卡片;浮层 pointer-events 不拦截,防止遮挡后无法移动鼠标。
|
||||
|
||||
> 说明文案来源:categories.description,可在后台"内容设计"中编辑。**【决策:文案每块 1~3 句】**
|
||||
|
||||
### 4.7 页脚(D 区块)
|
||||
|
||||
内容:ICP 备案号(`备案号`占位,后台可配)、公安备案号(后台可配,含图标)、版权声明(默认 `© 2026 <站点名> 版权所有`,后台可配)、站点名/其他自定义文本(后台可配,见 §7.3.2)。底部同时放一个低调的"后台管理"入口链接(指向 `admin/login.php`)。
|
||||
|
||||
### 4.8 统一视觉规范(全局)
|
||||
|
||||
| 项 | 约定 | 备注 |
|
||||
| ------------ | --------------------------------------------- | ---------------------- |
|
||||
| 主色 | 深空蓝 `#1f3a5f` + 强调蓝 `#2563eb` | 各页统一 |
|
||||
| 背景 | 浅灰 `#f5f7fa`,卡片白底 | |
|
||||
| 字体 | 系统字体栈 + 中文回退(`Microsoft YaHei` 等) | 由 CSS 变量统一 |
|
||||
| 圆角/阴影 | 卡片统一 8px 圆角、轻投影 | |
|
||||
| 全局公共样式 | 独立 `assets/css/common.css` | 各页引入,保证风格一致 |
|
||||
| favicon/标题 | 站点名作为 `<title>` 统一后缀 | 后台可改站点名 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 导航区规格(nav.php,科普/红队/蓝队共用)
|
||||
|
||||
### 5.1 页面纵向结构
|
||||
|
||||
自顶向下依次为:
|
||||
|
||||
| 区块 | 规则 | 备注 |
|
||||
| ---------- | ---------------------------------------------------- | ------------------------------------------------ |
|
||||
| 顶部留白 | 约 **10vh**(不小于 60px) | 纯背景留白,不做内容 |
|
||||
| 搜索栏 | 位于留白之下,高度 **20vh**(下限 72px、上限 160px) | 即原 plan"上半部 20% + 最上方留白 10%"的明确算法 |
|
||||
| 导航内容区 | 占据剩余空间,内容超高时**整页纵向滚动** | 见 5.3 |
|
||||
|
||||
### 5.2 搜索栏规格(高级要求)
|
||||
|
||||
- 结构(水平一行,居中,定宽容器内):**引擎切换按钮(左)|输入框(中)|搜索按钮(右)**。
|
||||
- 引擎切换:点击循环切换或下拉选择,引擎列表默认:`bing / baidu / github / google`(**【决策】可通过后端接口/常量维护,便于增删**)。
|
||||
- 搜索 URL 模板(选中引擎后拼 `关键词` 后 `target="_blank"` 新开页):
|
||||
- bing: `https://www.bing.com/search?q={kw}`
|
||||
- baidu: `https://www.baidu.com/s?wd={kw}`
|
||||
- github: `https://github.com/search?q={kw}`
|
||||
- google: `https://www.google.com/search?q={kw}`
|
||||
- 输入框**有内容**时,最右侧出现"清除(×)"按钮;点击清空并聚焦。
|
||||
- 触发方式:点击搜索按钮或**回车**。
|
||||
- 关键词需 `encodeURIComponent` 处理。
|
||||
|
||||
### 5.3 导航内容区(主体)
|
||||
|
||||
- 桌面(≥1200px):
|
||||
- 页面左右两侧各留白 **15%**;
|
||||
- 中间 70% 区域划分为**三大列**(列宽相等、列间有间距);
|
||||
- 大列**无标题**,仅是布局容器。
|
||||
- 导航块(最小管理单元):
|
||||
- 每个块 = **1 个块标题(加粗居左)+ 内部 3 小列 × 5 行**(即每块最多 15 个按钮位);
|
||||
- 块标题、按钮是否填满不限制(3 列 5 行是最大学位,不足则留空)。
|
||||
- 块尺寸固定(宽度=所在列宽,高度固定,各按钮行高统一)。
|
||||
- 分列规则(【决策】):取当前分类下所有导航块(按 sort 排序),**按序轮转填充三列**(第 1 块→列1,第 2 块→列2,第 3 块→列3,第 4 块→列1……),保证三列块数均衡;列内纵向堆叠,超高则整页滚动。
|
||||
- 按钮交互:
|
||||
- 鼠标移上 → 按钮变色(统一高亮样式);
|
||||
- 该按钮存在备注信息时 → **浮出悬浮气泡 tooltip** 显示备注,移出消失;无备注仅变色;
|
||||
- 点击 → `target="_blank"` 新标签页打开链接(加 `rel="noopener noreferrer"`)。
|
||||
- 链接规范:允许完整 URL(http/https);后台录入时为空则按钮置灰不可点。
|
||||
- 内容来源:按当前 `cat`(popular/red/blue)读取 `nav_blocks`、`nav_items`(§7.4)。
|
||||
- 空状态:无任何块时显示占位提示,不报错。
|
||||
|
||||
### 5.4 移动端
|
||||
|
||||
见 §9 响应式;核心:中间区域改单列,导航块铺满屏宽,块内 3 小列在 ≥600px 保持 3 列、<600px 折叠为 1 列或 2 列(【决策:<600px 时单小列显示,即一行一个按钮】)。导航块标题列布局保留。
|
||||
|
||||
---
|
||||
|
||||
## 6. 功能区规格
|
||||
|
||||
### 6.1 页面清单与公共头部(功能区统一)
|
||||
|
||||
功能区包含 1 个入口首页 + 4 个工具页。所有功能区页面使用统一的**功能页头部条**(区别于首页展示栏):
|
||||
|
||||
```
|
||||
[ ← 返回功能区首页 ] 本功能页标题(居中) [ 首页 ]
|
||||
```
|
||||
|
||||
- 最左侧按钮 → 返回 `func/index.php`(功能子页上);在功能区首页本身时该按钮隐藏或置灰。
|
||||
- 最右侧按钮 → 返回站点首页 `index.php`。
|
||||
- 中间标题 → 当前功能页名称。
|
||||
- 功能区首页的头部条中间显示"功能区"。
|
||||
|
||||
### 6.2 功能区首页(func/index.php)
|
||||
|
||||
- 顶部为风格统一的大展示横幅(复用首页视觉,可含功能区简介)。
|
||||
- 主体为工具**卡片网格**:每张卡片 = 图标 + 工具名 + 一句话说明,点击进入对应子页。
|
||||
- 工具卡片列表**由 `func_tools` 表驱动,后台“功能区管理”(admin/tools.php)可增删改/排序/启停**:
|
||||
- 站内工具(如 codec.php):需先在 `func/` 下开发对应页面,再到后台登记入口;
|
||||
- 外链工具:填写完整 http(s) 地址,作为新窗口卡片直接使用,无需写代码;
|
||||
- 默认预置:编码/加解密、随机密码生成、二维码生成器、杀软识别四张卡片。
|
||||
|
||||
### 6.3 编码/加解密页(func/codec.php)
|
||||
|
||||
- **布局**:左侧算法/方向选择区 + 上下(或左右)两个**自由编辑**文本区(输入区、输出区)。
|
||||
- 两个文本区都允许用户自由编辑;输出区不设只读。
|
||||
- 两个文本区均支持 **右下角拖拽竖向放大(resize: vertical),横向宽度不变**。
|
||||
- 提供"交换 / 回填"便捷操作:将输出内容一键放回输入、或将输入/输出一键互换(【决策】)。
|
||||
- **支持算法与方向**:
|
||||
|
||||
| 算法 | 编码方向 | 解码方向 | 实现要求 |
|
||||
| --------------------- | -------------------- | -------------------- | --------------------------------------------------------------------------- |
|
||||
| Base64 | 编码 | 解码 | 支持中文等 Unicode:先 UTF-8 再 Base64 |
|
||||
| Base32 | 编码 | 解码 | 同上 |
|
||||
| MD5 | — | 不可逆 | 输出 32 位十六进制小写 |
|
||||
| SHA-1/224/256/384/512 | — | 不可逆 | 输出十六进制小写 |
|
||||
| URL | `encodeURIComponent` | `decodeURIComponent` | 整段处理 |
|
||||
| Unicode | 中→`\uXXXX` 序列 | `\uXXXX`→文本 | 需支持中文;emoji/增补字符按 UTF-16 代理对编码成两个 `\uXXXX`,解码正确还原 |
|
||||
|
||||
- 交互模型:选算法 + 选方向(编码/解码/加密哈希等按需)→ 点击"执行" → 结果写入输出区;错误(非法输入等)以页面内提示展示,不弹窗。
|
||||
- 执行按钮文案随方向变化(如"编码""解码""加密")。
|
||||
|
||||
### 6.4 随机密码生成页(func/password.php)
|
||||
|
||||
- 字符集选项(多选/开关):
|
||||
- 大写字母 `A-Z`、小写字母 `a-z`、数字 `0-9`、特殊符号(建议默认 `!@#$%^&*()-_=+[]{};:,.<>?`);
|
||||
- **自定义字符集输入框**:勾选后启用,用户自定义任意字符(去重);勾选"自定义"时以自定义为准,忽略其余开关。
|
||||
- 长度:滑块或数字输入,范围 **4~64**,默认 16。
|
||||
- 生成:一次生成 **多组(默认 5 条)**【决策】,逐条显示在可编辑文本区/列表,每条后带"复制"按钮。
|
||||
- 结果文本区同样支持竖向拖拽放大。
|
||||
- 至少选一个字符集且长度合法才可生成,否则提示。
|
||||
|
||||
### 6.5 二维码生成器(func/qrcode.php)
|
||||
|
||||
- 输入框 + "生成"按钮;使用 `qrcodejs` 渲染。
|
||||
- **放大预览**:提供尺寸滑块(如 128~512px)或点击图片弹出大图预览(大图可在新层中查看);**下载**:提供下载按钮,导出为 PNG(canvas 转 dataURL 或库的 toDataURL)。
|
||||
- 边界处理:内容为空禁止生成;内容过长超出二维码容量时提示(库报错或预检测)。
|
||||
- 空白页签、无输入时不展示二维码区。
|
||||
|
||||
### 6.6 杀软识别页(func/av.php)
|
||||
|
||||
- 输入:粘贴 `tasklist` 命令输出文本的 textarea(支持竖向放大,横向不可变)。
|
||||
- 匹配库:`data/avlist.js`(§10.2)。
|
||||
- 解析规则:
|
||||
- 按行读取,忽略表头行(含"映像名称/Image Name/PID/===")、空行;
|
||||
- 每行取**首个 token** 为进程名(空格/制表分隔);
|
||||
- 与 `avList` 键匹配,**大小写不敏感**;key 若不带扩展名,比较时双方都去掉扩展名后再比一次(【决策】兼容两类写法)。
|
||||
- 输出:匹配结果**表格**,列:`进程名 | 匹配结果 | 产品/说明`;未匹配的行结果列显示"未识别",说明列留空。
|
||||
- 附加统计:总进程数、匹配数、未识别数。
|
||||
- 前端直接 `<script src="../data/avlist.js">` 引入全局 `avList` 对象即可,无需后端。
|
||||
|
||||
---
|
||||
|
||||
## 7. 后台区规格(/admin)
|
||||
|
||||
### 7.1 登录与会话
|
||||
|
||||
- 登录页:用户名 + 密码;错误统一提示"用户名或密码错误"。
|
||||
- 密码存储算法:**按需求:`md5(base64(明文密码))`**。
|
||||
- ⚠️ 注明局限:该方案是弱单向哈希(无盐),仅用于低安全要求的个人站;不用于高价值系统。若后期可接受,建议换 `password_hash()`(bcrypt)升级,本版实现严格按需求算法。
|
||||
- 会话管理:PHP `session`,登录成功后写入 `$_SESSION['admin']`;后台所有页面(除 login 外)在顶部做鉴权:
|
||||
- 未登录访问后台页 → 302 跳转 `login.php`;
|
||||
- 退出登录 → `logout.php`(销毁 session 并跳登录页);
|
||||
- 会话空闲超时(**【决策】30 分钟**)后失效,需重新登录。
|
||||
- 初始管理员:首次启动数据库不存在用户时,由 `install` 逻辑自动创建默认账号(**【决策】默认账号 `admin` / 密码 `admin123`,首次登录后引导修改**)。
|
||||
- CSRF 防护:后台写操作表单带一次性 token(session 存),提交校验(低成本基础防护)。
|
||||
- 登录尝试不做复杂限流,仅简单延时提示(个人站可接受)。
|
||||
|
||||
### 7.2 后台整体布局
|
||||
|
||||
- 后台顶部导航条:后台名称 + 快捷入口(导航内容管理 / 顺序排布与内容设计 / 返回前台首页)+ 当前管理员 + 退出。
|
||||
- 后台页面自身不引入首页天气/名言等前台组件,保持简洁(风格色板仍与前台统一)。
|
||||
- 所有表单保存成功后给反馈(保存成功条/提示),失败显示原因(如 SQL 错误信息可折叠展示,便于排查)。
|
||||
|
||||
### 7.3 后台功能拆解(原 plan 空泛项的落地)
|
||||
|
||||
原 plan 只写了"1.顺序排布 2.内容设计 3.导航内容增删改查",本版将其拆成可操作模块:
|
||||
|
||||
#### 7.3.1 导航内容管理(admin/nav.php)
|
||||
|
||||
- 顶部先选择操作分类:**科普库 / 红队库 / 蓝队库**(工具库对应功能区,不在导航内容范围内)。
|
||||
- 块级操作(针对 nav_blocks):新增块、删除块、编辑块标题、块上移/下移(调整在页面中的先后);
|
||||
- 按钮位操作(针对 nav_items):在每个块内新增/编辑/删除按钮,字段:
|
||||
- 文字(必填)、URL(必填)、备注(可空,空则不显示 tooltip)、序号;
|
||||
- 按钮在块内"3 小列 × 5 行"中的位置可由排序决定(**【决策】按 sort 顺序自动排列,不足位留空,不做拖拽定位到格**);
|
||||
- 前端交互:用可展开的块列表展示,每个块内按钮表格行式管理,保存按钮批量提交(一次提交当前分类全部变更)。
|
||||
- 删除块/按钮需二次确认。
|
||||
|
||||
#### 7.3.2 内容设计(admin/content.php)
|
||||
|
||||
统一为一个"站点内容与设置"页,按分组编辑并保存到 `settings` 表:
|
||||
|
||||
| 分组 | 可编辑项 | 存储 | 用途 |
|
||||
| -------- | ------------------------------------------------------------ | ---------------------------------- | ------------------------------- |
|
||||
| 站点信息 | 站点名、副标题 | settings | 首页展示栏、`<title>` 后缀 |
|
||||
| 板块文案 | 四大板块各自的图标选择/文案/一句话简介 | settings 或 categories.description | 首页四大板块卡片与 hover 说明层 |
|
||||
| 底部信息 | ICP 备案号、公安备案号、版权行、自定义底部文本 | settings | 首页/全站页脚 |
|
||||
| 名言库 | 名言**新增 / 删除 / 清空**(文本框逐行维护,写回 quota.txt) | data/quota.txt | 首页名言条 |
|
||||
| 其他 | 上次更新时间说明(只读展示) | — | — |
|
||||
|
||||
- 底部自定义文本支持纯文本与单条链接(安全起见只允许 http(s) 开头的 `<a>` 形态,**不做富文本/HTML 注入**)。
|
||||
|
||||
#### 7.3.3 顺序排布(admin/content.php 或独立区)
|
||||
|
||||
明确排序对象与层级(自外向内):
|
||||
|
||||
1. **分类(四大板块)顺序** → 决定首页四大板块横排顺序;
|
||||
2. **块顺序**(每个分类内)→ 决定导航页块的前后;
|
||||
3. **块内按钮顺序** → 决定按钮在块内 3×5 格中的先后。
|
||||
|
||||
排序交互统一提供"上移/下移 + 序号输入"两种方式(拖拽列为可选项)。
|
||||
|
||||
### 7.4 数据模型(SQLite DDL)
|
||||
|
||||
```sql
|
||||
-- 管理员表
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL -- md5(base64(明文))
|
||||
);
|
||||
|
||||
-- 键值设置表(站点名、备案号、版权、板块文案、last_updated 等)
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
-- 四大板块分类(popular/red/blue/tool)
|
||||
CREATE TABLE categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL, -- popular/red/blue/tool
|
||||
name TEXT NOT NULL, -- 科普库/红队库/蓝队库/工具库
|
||||
description TEXT DEFAULT '', -- 首页卡片一句话简介 + hover 说明文案
|
||||
sort INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- 导航块(仅 popular/red/blue 三类使用)
|
||||
CREATE TABLE nav_blocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cat_id INTEGER NOT NULL REFERENCES categories(id),
|
||||
title TEXT NOT NULL,
|
||||
sort INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- 块内按钮位(每块最多 3×5=15,由 sort 顺序占位)
|
||||
CREATE TABLE nav_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
block_id INTEGER NOT NULL REFERENCES nav_blocks(id),
|
||||
label TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
note TEXT DEFAULT '', -- 备注;空则按钮 hover 不弹气泡
|
||||
sort INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- 功能区工具入口(后台“功能区管理”维护)
|
||||
CREATE TABLE func_tools (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT DEFAULT '', -- 字符/emoji 图标
|
||||
description TEXT DEFAULT '', -- 一句话说明
|
||||
url TEXT NOT NULL DEFAULT '', -- 站内相对地址 或 外链 http(s)
|
||||
is_external INTEGER NOT NULL DEFAULT 0,-- 1=外链(新窗口)
|
||||
enabled INTEGER NOT NULL DEFAULT 1,-- 1=前台展示
|
||||
sort INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- 首页快捷站点(本域名其它网站,target=_blank;后台“内容与顺序”维护)
|
||||
CREATE TABLE quick_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL DEFAULT '', -- 完整 http(s) 地址
|
||||
icon TEXT DEFAULT '', -- 字符/emoji 图标
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
```
|
||||
|
||||
> 名言不建表(保持 quota.txt 为数据源);`categories` 四行 + `settings` 基础键在首次运行时自动初始化(install 脚本)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 目录结构规划
|
||||
|
||||
```
|
||||
HomePage/ # 网站根(部署时即站点根)
|
||||
├── index.php # 首页区
|
||||
├── nav.php # 导航区模板(?cat=popular|red|blue)
|
||||
├── func/
|
||||
│ ├── index.php # 功能区首页
|
||||
│ ├── codec.php # 编码/加解密
|
||||
│ ├── password.php # 随机密码
|
||||
│ ├── qrcode.php # 二维码
|
||||
│ └── av.php # 杀软识别
|
||||
├── admin/
|
||||
│ ├── login.php # 后台登录
|
||||
│ ├── logout.php
|
||||
│ ├── nav.php # 导航内容管理 + 排序
|
||||
│ ├── tools.php # 功能区工具入口管理
|
||||
│ ├── content.php # 内容设计 / 站点设置
|
||||
│ └── _guard.php # 鉴权 include(require 到各后台页顶部)
|
||||
├── api/
|
||||
│ └── nav.php # 输出导航 JSON(前台导航页/首页读取用)
|
||||
├── includes/
|
||||
│ ├── db.php # PDO + SQLite 连接与 install 初始化
|
||||
│ ├── auth.php # 登录/校验/session 工具
|
||||
│ └── settings.php # settings 读写封装
|
||||
├── assets/
|
||||
│ ├── css/common.css # 全局样式(统一风格、响应式断点)
|
||||
│ ├── js/common.js # 通用组件(tooltip、竖向 resize 类等)
|
||||
│ ├── js/codec.js # 编码算法(base64/base32/url/unicode)
|
||||
│ ├── js/sha.js # SHA 系列
|
||||
│ ├── js/qrcode.min.js # qrcodejs
|
||||
│ └── img/ # logo/图标
|
||||
├── data/
|
||||
│ ├── homepage.db # SQLite(运行时自动创建)
|
||||
│ ├── quota.txt # 名人名言(行式,后台可编辑)
|
||||
│ └── avlist.js # 杀软进程映射表
|
||||
└── plan.txt # 原始 plan(保留存档)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 响应式断点规划
|
||||
|
||||
| 断点 | 首页 | 导航区 | 功能区 |
|
||||
| ------------------ | ------------------------------------- | --------------------------------------------------------------------------- | ----------------------------------- |
|
||||
| ≥1200px(桌面) | 四板块横排;天气 iframe 500×40 原尺寸 | 左右各留白 15%,中间三列 | 工具卡片多列;编码页输入/输出可并排 |
|
||||
| 768–1199px(平板) | 四板块两行两列或自适应横排 | 左右留白收窄至 5%,仍三列(列宽收紧) | 卡片两列 |
|
||||
| <768px(手机) | 板块单列或 2×2 网格;名言条自然换行 | **单列**:中间区全宽,导航块一列、块内小列 1 列显示(原要求);搜索栏自适应 | 卡片单列 |
|
||||
|
||||
补充规则:
|
||||
|
||||
- 顶部展示栏/搜索栏/功能页头部条在移动端自动收缩高度与内边距,天气 iframe 宽度等比缩放居中。
|
||||
- 编码/二维码等页面在 <768px 时输入输出区改为上下堆叠。
|
||||
- 底部备案等信息移动端居中换行。
|
||||
|
||||
---
|
||||
|
||||
## 10. 数据文件格式约定
|
||||
|
||||
### 10.1 data/quota.txt(名人名言)
|
||||
|
||||
- 编码:UTF-8(无 BOM)。
|
||||
- 每行一条名言,**空行或 `#` 开头为注释**(后台编辑时自动忽略)。
|
||||
- 首次实现时预置 5~10 条示例。
|
||||
- 读取:后端 PHP 读取并随机输出一条(首页渲染),避免名言文件被直接下载的路径泄露问题不在此列(公开文件无碍)。
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
# 示例名言
|
||||
知彼知己,百战不殆。
|
||||
知识就是力量。
|
||||
```
|
||||
|
||||
### 10.2 data/avlist.js(杀软进程映射表)
|
||||
|
||||
- 全局对象写法(沿用原示例结构),首次实现预置若干常见条目:
|
||||
|
||||
```js
|
||||
var avList = {
|
||||
"360tray.exe": "360 安全卫士 - 实时保护",
|
||||
"360safe.exe": "360 安全卫士",
|
||||
"QQPCRTP.exe": "QQ电脑管家",
|
||||
"kxescore.exe": "金山毒霸",
|
||||
"ekrn.exe": "ESET NOD32",
|
||||
"MsMpEng.exe": "Windows Defender",
|
||||
};
|
||||
```
|
||||
|
||||
- 约定:value 统一为 `产品名 - 组件描述` 风格;key 含扩展名;匹配时忽略大小写。
|
||||
- 该文件由实现时创建,**不进后台管理**(如需可扩展)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 部署与初始化说明
|
||||
|
||||
1. 环境要求:PHP ≥ 7.4(启用 PDO_SQLITE)、Apache/Nginx;无需额外安装数据库服务。
|
||||
2. 将工程目录整体上传至服务器站点根目录;`data/` 目录需 PHP 可写(用于创建/写入 `homepage.db` 与 `quota.txt`)。
|
||||
3. 首次访问任意页面触发自动初始化:建库建表、写入 `categories` 四行、默认 settings、默认管理员 `admin / admin123`、生成 quota.txt/avlist.js 示例(若不存在)。
|
||||
4. 首登后台后请修改默认密码(后台导航条提供修改密码入口)。
|
||||
5. 天气 iframe 依赖外网(`i.tianqi.com`),离线环境下该区块显示占位不阻塞页面。
|
||||
|
||||
---
|
||||
|
||||
## 12. 实施顺序与验收要点(供编码阶段使用)
|
||||
|
||||
| 阶段 | 交付物 | 验收标准 |
|
||||
| ----------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 1. 框架 | 目录结构、common.css/common.js、includes/db.php、install 初始化、数据示例 | 首次运行自动建库建表成功;首页可展示默认数据 |
|
||||
| 2. 首页 | index.php:展示栏/名言条/四板块 hover 展开/页脚 | 四板块正确跳转四大目标;hover 展开规则与 §4.6 一致;时间刷新、名言随机;布局符合 §4 |
|
||||
| 3. 导航区 | nav.php 三分类 + 搜索栏 + tooltip + 分列 | bing/baidu/github 搜索新开页;回车/清除按钮可用;导航块 hover 变色/tooltip 正确;三列轮转正确;空态正常 |
|
||||
| 4. 功能区 | func 五个页面 | 中文字符 Base64/Base32/Unicode 编解码正确;SHA/MD5 输出正确;密码可自定义生成;二维码生成/放大/下载;杀软识别匹配与统计正确;textarea 竖向可拉伸 |
|
||||
| 5. 后台 | 登录/导航管理/内容设计/排序 | 默认账号可登录;未登录访问被拦截;分类/块/按钮 CRUD 与排序即时反映到前台;备案/名言等设置生效;last_updated 更新并展示到首页 |
|
||||
| 6. 联调打磨 | 响应式与细节 | 三档断点符合 §9;外链新开页;风格一致;无 500/JS 报错 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 仍建议你确认的开放点(不影响启动开发)
|
||||
|
||||
1. 功能区头部条是否也带天气 iframe —— 本版默认**仅首页展示栏带天气**,功能页不带(避免重复加载)。
|
||||
2. 首页名言是否允许点击换一条 —— 默认刷新页面换一条。
|
||||
3. 导航页大列块超多时是否允许整页纵向滚动 —— 默认允许。
|
||||
4. 二维码是否提供"滚轮缩放" —— 默认提供尺寸滑块 + 点击放大预览两种。
|
||||
|
||||
> 若以上默认与你设想不同,修改对应【决策】标注即可,其余规格不受影响。
|
||||
Loading…
Reference in New Issue
Block a user