NewHome/admin/content.php

821 lines
40 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* admin/content.php —— 网站管理 / 站点设置
* 含站点信息、板块名称与说明、logo 上传favicon + 内容 logo 同源)、
* 名言库quota.txt、底部备案信息、分类顺序、修改密码。
*/
$P = '../';
require_once __DIR__ . '/_guard.php';
$pdo = db();
$catKeys = ['popular', 'red', 'blue', 'tool', 'article'];
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
{
// 改为右下角 Toast注入 hpToastMsg由 common.js 在页面加载后自动弹出
echo '<script>window.hpToastMsg = ' . json_encode(['kind' => $kind === 'ok' ? 'ok' : 'err', 'text' => $msg], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) . ';</script>';
}
$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'] ?? 'SecHome')));
settings_set('site_slogan', trim((string) ($_POST['site_slogan'] ?? '')));
touch_last_updated();
$msgKind = 'ok';
$msgText = '站点信息已保存。';
break;
case 'tips':
header('Content-Type: application/json; charset=utf-8');
settings_set('show_st_tips', (isset($_POST['show_tips']) && (string) $_POST['show_tips'] === '1') ? '1' : '0');
touch_last_updated();
echo json_encode(['ok' => true, 'msg' => '站长提示开关已保存。'], JSON_UNESCAPED_UNICODE);
exit;
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 'hero_mode':
$mode = (string) ($_POST['hero_mode'] ?? 'weather');
if (!in_array($mode, ['weather', 'info', 'blank'], true)) {
$mode = 'weather';
}
settings_set('hero_mode', $mode);
touch_last_updated();
$modeText = ['weather' => '天气模式', 'info' => '访客信息模式', 'blank' => '空白精简模式'][$mode];
$msgKind = 'ok';
$msgText = '首页顶部组件模式已保存:' . $modeText . '。';
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 'qtoggle':
header('Content-Type: application/json; charset=utf-8');
$qlId = (int) ($_POST['id'] ?? 0);
$qlEn = ((string) ($_POST['enabled'] ?? '') === '1') ? 1 : 0;
if ($qlId > 0) {
$pdo->prepare('UPDATE quick_links SET enabled = ? WHERE id = ?')->execute([$qlEn, $qlId]);
touch_last_updated();
echo json_encode(['ok' => true]);
} else {
echo json_encode(['ok' => false, 'msg' => '无效的记录 ID。'], JSON_UNESCAPED_UNICODE);
}
exit;
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' => '日常效率与安全实用小工具,直达功能区。',
'article' => '站长原创与整理收录的图文与 Markdown 文章合集。',
];
$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'] ?? 'SecHome';
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>
<?= csrf_field() ?>
<div class="field-row" style="align-items:center;margin:2px 0">
<label class="sw" title="点击立即启用/关闭站长提示(即时生效,无需保存)"><input type="checkbox" class="sw-in tips-toggle"
<?= ((string) ($all['show_st_tips'] ?? '1') === '1') ? ' checked' : '' ?>><span class="sw-b"><span
class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>
<span class="tip" style="margin:0">开启时,前台显示“可切换搜索引擎进行搜索…”等辅助旁白</span>
</div>
<p class="tip" style="margin-top:6px">前台类似“可切换搜索引擎进行搜索4 个可用,后台可维护)”、各分区说明条等辅助旁白,以及文案中带后台入口字样的引导句,均带
<code>st-tip</code>
标记;关闭后这些文案在全部前台页面统一隐藏,站点导航、搜索、内容等核心功能不受影响。各分区/页面在没有数据时展示的空态占位框(如“该库还没有导航内容”)不受本开关影响,会始终显示以避免空页突兀。</p>
</div>
<!-- 站点信息 -->
<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>
<!-- 站点 logofavicon + 页面内容 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">上传新 LOGOpng / 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" data-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">首页顶部背景</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>
<!-- 首页顶部组件模式Hero 下方横幅:天气 / 访客信息 / 空白) -->
<div class="fieldset-card">
<div class="fs-title">首页顶部组件</div>
<?php $hm = (string) ($all['hero_mode'] ?? 'weather'); ?>
<form method="post">
<?= csrf_field() ?><input type="hidden" name="act" value="hero_mode">
<div class="field-row" style="align-items:center">
<label class="seg-check"><input type="radio" name="hero_mode" value="weather" <?= $hm === 'weather' ? ' checked' : '' ?>> 🌤️ 天气模式(默认)</label>
<label class="seg-check"><input type="radio" name="hero_mode" value="info" <?= $hm === 'info' ? ' checked' : '' ?>> 🖥️ 访客信息模式</label>
<label class="seg-check"><input type="radio" name="hero_mode" value="blank" <?= $hm === 'blank' ? ' checked' : '' ?>> ⬜ 空白模式</label>
</div>
<div style="margin-top:12px"><button type="submit" class="btn btn-primary">保存顶部组件模式</button></div>
</form>
<p class="tip" style="margin:10px 0 0">
天气模式(默认):横幅展示第三方天气条;访客信息模式:横幅显示当前访问者 IP、浏览器、操作系统、内核、屏幕分辨率与系统语言空白模式隐藏横幅仅保留站点信息与时间信息顶部高度自动缩减。
</p>
</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" data-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">滚动信息栏</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库</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 class="fieldset-card col-span-2">
<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 col-span-2">
<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="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in l-enabled"
<?= (int) $ql['enabled'] === 1 ? ' checked' : '' ?>><span class="sw-b"><span class="sw-on">ON</span><span
class="sw-off">OFF</span></span></label>
<span class="ops">
<button type="button" class="btn btn-sm 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>
</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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
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="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in l-enabled" checked><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>' +
'<span class="ops">' +
'<button type="button" class="btn btn-sm 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')) {
(window.HomePage && HomePage.ask ? HomePage.ask('确认删除该快捷站点?', { okText: '删除' }) : Promise.resolve(window.confirm('确认删除该快捷站点?')))
.then(function (ok) { if (ok) 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());
});
qlRows.addEventListener('change', function (ev) {
var input = ev.target;
if (!input.classList.contains('l-enabled')) return;
var row = input.closest('.l-row');
var id = row ? parseInt(row.getAttribute('data-id'), 10) : 0;
var csrfEl = document.querySelector('input[name="csrf"]');
if (!csrfEl) return;
if (!id) {
input.checked = !input.checked;
alert('该行尚未保存过,请先点击「保存快捷站点」生成记录,之后即可直接拨动开关启停。');
return;
}
var fd = new FormData();
fd.append('act', 'qtoggle');
fd.append('csrf', csrfEl.value);
fd.append('id', String(id));
fd.append('enabled', input.checked ? '1' : '0');
fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); })
.then(function (j) {
if (!j || j.ok !== true) {
input.checked = !input.checked;
alert('启停失败:' + ((j && j.msg) || '未知错误'));
}
})
.catch(function () {
input.checked = !input.checked;
alert('网络错误,启停未保存。');
});
});
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>
<script>
(function () {
'use strict';
var tips = document.querySelector('.tips-toggle');
if (!tips) return;
tips.addEventListener('change', function () {
var csrfEl = document.querySelector('input[name="csrf"]');
if (!csrfEl) return;
var fd = new FormData();
fd.append('act', 'tips');
fd.append('csrf', csrfEl.value);
fd.append('show_tips', tips.checked ? '1' : '0');
fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); })
.then(function (j) {
if (!j || j.ok !== true) {
tips.checked = !tips.checked;
alert('开关更新失败:' + ((j && j.msg) || '未知错误'));
}
})
.catch(function () {
tips.checked = !tips.checked;
alert('网络错误,开关更新未保存。');
});
});
})();
</script>
</body>
</html>