diff --git a/.htaccess b/.htaccess deleted file mode 100644 index e69de29..0000000 diff --git a/admin/_guard.php b/admin/_guard.php index e0f8c17..52308d2 100644 --- a/admin/_guard.php +++ b/admin/_guard.php @@ -20,6 +20,7 @@ function admin_topbar(string $active = ''): void $links = [ 'nav' => ['nav.php', '导航内容管理'], 'tools' => ['tools.php', '功能区管理'], + 'articles' => ['articles.php', '文章管理'], 'content' => ['content.php', '内容与顺序'], ]; foreach ($links as $k => $lk) { diff --git a/admin/articles.php b/admin/articles.php new file mode 100644 index 0000000..fe23ff2 --- /dev/null +++ b/admin/articles.php @@ -0,0 +1,370 @@ +prepare('INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value'); + $st->execute([$key, $value]); +} + +function renumber_sorts(PDO $pdo): void +{ + $rows = $pdo->query('SELECT id, part, sort FROM articles ORDER BY part ASC, sort ASC, id ASC')->fetchAll(); + $idxByPart = []; + $st = $pdo->prepare('UPDATE articles SET sort = ? WHERE id = ?'); + foreach ($rows as $r) { + $key = (string)$r['part']; + if (!isset($idxByPart[$key])) { + $idxByPart[$key] = 1; + } + $st->execute([$idxByPart[$key], (int)$r['id']]); + $idxByPart[$key]++; + } +} + +function msg_block(string $kind, string $text): string +{ + return '
' . he($text) . '
'; +} + +$msgKind = ''; +$msgText = ''; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!csrf_verify()) { + $msgKind = 'err'; + $msgText = '安全校验失败,请刷新页面重试。'; + } else { + $act = (string)($_POST['act'] ?? ''); + switch ($act) { + case 'note': + settings_set('article_note', trim((string)($_POST['note'] ?? ''))); + touch_last_updated(); + $msgKind = 'ok'; + $msgText = '文章区首页注要已保存。'; + break; + case 'save': + $id = max(0, (int)($_POST['id'] ?? 0)); + $part = trim((string)($_POST['part'] ?? '')); + $title = trim((string)($_POST['title'] ?? '')); + $summary = trim((string)($_POST['summary'] ?? '')); + $md = (string)($_POST['markdown'] ?? ''); + $sort = max(0, (int)($_POST['sort'] ?? 0)); + $pinned = (isset($_POST['pinned']) && $_POST['pinned'] === '1') ? 1 : 0; + $enabled = (isset($_POST['enabled']) && $_POST['enabled'] === '1') ? 1 : 0; + if ($part === '') { + $part = '未分类'; + } + if ($title === '') { + $msgKind = 'err'; + $msgText = '文章标题不能为空。'; + } elseif ($id > 0) { + $st = $pdo->prepare('UPDATE articles SET part=?, title=?, summary=?, markdown=?, pinned=?, enabled=?, sort=?, updated_at=? WHERE id=?'); + $st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, date('Y-m-d H:i:s'), $id]); + $msgKind = 'ok'; + $msgText = '文章已保存。'; + } else { + $st = $pdo->prepare('INSERT INTO articles (part, title, summary, markdown, pinned, enabled, sort, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)'); + $now = date('Y-m-d H:i:s'); + $st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $now, $now]); + $id = (int)$pdo->lastInsertId(); + $msgKind = 'ok'; + $msgText = '文章已发布(可继续编辑)。'; + } + if ($msgKind === 'ok') { + renumber_sorts($pdo); + touch_last_updated(); + header('Location: articles.php?edit=' . $id . '&ok=1'); + exit; + } + break; + case 'del': + $id = (int)($_POST['id'] ?? 0); + if ($id > 0) { + $pdo->prepare('DELETE FROM articles WHERE id = ?')->execute([$id]); + renumber_sorts($pdo); + touch_last_updated(); + $msgKind = 'ok'; + $msgText = '文章已删除。'; + } + break; + case 'toggle': + $id = (int)($_POST['id'] ?? 0); + $field = (string)($_POST['field'] ?? ''); + if ($id > 0 && ($field === 'pinned' || $field === 'enabled')) { + $cur = (int)$pdo->query('SELECT ' . $field . ' FROM articles WHERE id = ' . $id)->fetchColumn(); + $pdo->prepare('UPDATE articles SET ' . $field . ' = ? WHERE id = ?')->execute([$cur ? 0 : 1, $id]); + $msgKind = 'ok'; + $msgText = $field === 'pinned' ? '置顶状态已更新。' : '启停状态已更新。'; + } + break; + case 'move': + $id = (int)($_POST['id'] ?? 0); + $dir = ((string)($_POST['dir'] ?? '') === 'down') ? 'down' : 'up'; + $rows = $pdo->query('SELECT id, part, sort FROM articles ORDER BY part ASC, sort ASC, id ASC')->fetchAll(); + $idx = -1; + foreach ($rows as $i => $r) { + if ((int)$r['id'] === $id) { + $idx = $i; + break; + } + } + if ($idx >= 0) { + $target = ($dir === 'down') ? $idx + 1 : $idx - 1; + if ($target >= 0 && $target < count($rows) && $rows[$target]['part'] === $rows[$idx]['part']) { + $tmp = $rows[$idx]; + $rows[$idx] = $rows[$target]; + $rows[$target] = $tmp; + renumber_sorts($pdo); + $msgKind = 'ok'; + $msgText = '顺序已调整。'; + } + } + break; + case 'upload': + // 图片上传(供编辑器 XHR 使用,返回 JSON) + header('Content-Type: application/json; charset=utf-8'); + $up = $_FILES['file'] ?? null; + if (!$up || ($up['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + echo json_encode(['ok' => false, 'msg' => '未收到文件'], JSON_UNESCAPED_UNICODE); + exit; + } + if (($up['size'] ?? 0) > 2 * 1024 * 1024) { + echo json_encode(['ok' => false, 'msg' => '图片不能超过 2MB'], JSON_UNESCAPED_UNICODE); + exit; + } + $extMap = ['png' => 'png', 'jpg' => 'jpg', 'jpeg' => 'jpg', 'gif' => 'gif', 'webp' => 'webp', 'svg' => 'svg']; + $ext = strtolower(pathinfo((string)$up['name'], PATHINFO_EXTENSION)); + if (!isset($extMap[$ext])) { + echo json_encode(['ok' => false, 'msg' => '仅支持 png / jpg / gif / webp / svg'], JSON_UNESCAPED_UNICODE); + exit; + } + $dir = DATA_DIR . '/articles/img'; + if (!is_dir($dir)) { + @mkdir($dir, 0777, true); + } + $filename = 'art_' . date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . $extMap[$ext]; + if (!@move_uploaded_file($up['tmp_name'], $dir . '/' . $filename)) { + echo json_encode(['ok' => false, 'msg' => '保存失败,请检查 data/articles/img 目录权限'], JSON_UNESCAPED_UNICODE); + exit; + } + echo json_encode(['ok' => true, 'file' => 'data/articles/img/' . $filename], JSON_UNESCAPED_UNICODE); + exit; + default: + $msgKind = 'err'; + $msgText = '未知操作。'; + } + } +} + +$savedFlag = isset($_GET['ok']); +$list = $pdo->query('SELECT * FROM articles ORDER BY part ASC, sort ASC, id ASC')->fetchAll(); +$noteText = setting_get('article_note', ''); + +$editing = false; +$row = null; +$editId = (int)($_GET['edit'] ?? 0); +if ($editId > 0) { + $st = $pdo->prepare('SELECT * FROM articles WHERE id = ?'); + $st->execute([$editId]); + $row = $st->fetch(); + if ($row) { + $editing = true; + } +} + +layout_head('文章管理'); +admin_topbar('articles'); +?> +
+
+

文章管理

+

文章库(第五大板块)的文章维护:正文为标准 Markdown,图片统一上传到 data/articles/img 目录。

+ + +
保存成功,文章库前台已同步更新。
+ + + + + (int)$row['id'], 'part' => (string)$row['part'], 'title' => (string)$row['title'], + 'summary' => (string)$row['summary'], 'md' => (string)$row['markdown'], + 'sort' => (int)$row['sort'], 'pinned' => (int)$row['pinned'], 'enabled' => (int)$row['enabled'], + ] : ['id' => 0, 'part' => '未分类', 'title' => '', 'summary' => '', 'md' => '', 'sort' => 0, 'pinned' => 0, 'enabled' => 1]; + // 分区下拉建议(仅列已有分区) + $partSuggest = []; + foreach ($list as $a2) { + $p2 = trim((string)$a2['part']); + if ($p2 !== '' && !in_array($p2, $partSuggest, true)) { + $partSuggest[] = $p2; + } + } + ?> +
+
+
+ + + +
+
+ + + + + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ + + 上传后自动在正文末尾插入 ![说明](data/articles/img/文件.png) 形式引用;引用路径相对站点根目录,渲染时自动补全前缀。 +
+
+
+ + 取消 / 返回列表 +
+
+
+ + + +
+
文章区首页注要(顶部展示给读者的说明文字)
+
+ + +
+
+
+ + +
+
+ 文章列表(共 篇) + + 新增文章 +
+ +

还没有文章,点击右上角「新增文章」开始写作。

+ +
+ + + + + + + + + + + + + +
分区标题状态排序操作
+ +
+
+ 置顶 + 可见隐藏 + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+

同分区内使用 ↑/↓ 调整顺序,置顶文章在列表中始终靠前。

+ +
+
+
+ + + + + + diff --git a/admin/content.php b/admin/content.php index fcaec43..8dd19ab 100644 --- a/admin/content.php +++ b/admin/content.php @@ -8,7 +8,7 @@ $P = '../'; require_once __DIR__ . '/_guard.php'; $pdo = db(); -$catKeys = ['popular', 'red', 'blue', 'tool']; +$catKeys = ['popular', 'red', 'blue', 'tool', 'article']; function settings_set(string $key, string $value): void { @@ -292,6 +292,7 @@ $boardDefaults = [ 'red' => '面向渗透测试与攻防演练的红队资源集。', 'blue' => '面向防御侧:应急响应、威胁狩猎、取证溯源。', 'tool' => '日常效率与安全实用小工具,直达功能区。', + 'article' => '站长原创与整理收录的图文与 Markdown 文章合集。', ]; $logo = setting_get('site_logo', ''); diff --git a/article/data.php b/article/data.php new file mode 100644 index 0000000..92c52dd --- /dev/null +++ b/article/data.php @@ -0,0 +1,70 @@ + false, 'msg' => '缺少文章 id']); + } + $st = $pdo->prepare('SELECT id, part, title, summary, markdown, pinned, created_at, updated_at + FROM articles WHERE id = ? AND enabled = 1 LIMIT 1'); + $st->execute([$id]); + $row = $st->fetch(); + if (!$row) { + json_out(['ok' => false, 'msg' => '文章不存在或已隐藏']); + } + json_out(['ok' => true, 'article' => $row]); +} + +// ---- 默认:list 分组元数据(分区顺序取各分区首篇 id 序;分区内置顶 → sort → id) ---- +$rows = $pdo->query('SELECT id, part, title, summary, pinned, sort, created_at, updated_at + FROM articles WHERE enabled = 1 ORDER BY id ASC')->fetchAll(); +$parts = []; +if ($rows) { + $partOrder = []; + $byPart = []; + foreach ($rows as $r) { + $part = trim((string)$r['part']); + if ($part === '') { + $part = '未分类'; + } + if (!isset($byPart[$part])) { + $byPart[$part] = []; + $partOrder[] = $part; + } + $byPart[$part][] = $r; + } + foreach ($partOrder as $part) { + $items = $byPart[$part]; + usort($items, static function (array $a, array $b): int { + if ((int)$a['pinned'] !== (int)$b['pinned']) { + return (int)$b['pinned'] - (int)$a['pinned']; + } + if ((int)$a['sort'] !== (int)$b['sort']) { + return (int)$a['sort'] <=> (int)$b['sort']; + } + return (int)$a['id'] <=> (int)$b['id']; + }); + $parts[] = ['part' => $part, 'articles' => $items]; + } +} +json_out(['ok' => true, 'parts' => $parts]); diff --git a/article/index.php b/article/index.php new file mode 100644 index 0000000..8203978 --- /dev/null +++ b/article/index.php @@ -0,0 +1,186 @@ +query('SELECT id, part, title, summary, sort, created_at, updated_at + FROM articles WHERE enabled = 1 ORDER BY id ASC')->fetchAll(); +$rows = []; +$partOrder = []; +$byPart = []; +foreach ($all as $r) { + $part = trim((string)$r['part']); + if ($part === '') { + $part = '未分类'; + } + if (!isset($byPart[$part])) { + $byPart[$part] = []; + $partOrder[] = $part; + } + $byPart[$part][] = $r; +} +foreach ($partOrder as $part) { + $items = $byPart[$part]; + usort($items, static function (array $a, array $b): int { + if ((int)$a['pinned'] !== (int)$b['pinned']) { + return (int)$b['pinned'] - (int)$a['pinned']; + } + if ((int)$a['sort'] !== (int)$b['sort']) { + return (int)$a['sort'] <=> (int)$b['sort']; + } + return (int)$a['id'] <=> (int)$b['id']; + }); + $rows[] = ['part' => $part, 'articles' => $items]; +} +$total = count($all); + +// 当前阅读文章 +$cur = null; +$cid = (int)($_GET['id'] ?? 0); +if ($cid > 0) { + $st = $pdo->prepare('SELECT * FROM articles WHERE id = ? AND enabled = 1 LIMIT 1'); + $st->execute([$cid]); + $cur = $st->fetch(); + if (!$cur) { + $cur = null; // 无效或已隐藏:回退到列表 + } +} + +layout_head($cur ? (string)$cur['title'] : '文章库'); +?> + +
+ +
+ +
+
+ + +
+
+ + +
+ + +
+ + + +
+ + + + +
+ + +
+ + +
+
+
+

+ +

+ + + + +
+
+ + +
+ +
+
+

全部文章 (共 篇)

+

点击任意文章开始阅读;左侧「文章目录」可随时切换文章,中间「段落目录」可快速跳转。

+
+ +

文章库还没有内容,站长可在后台「文章管理」中发布。

+ + +
+

+ +
+ +
+ +
+
+
+ + + + + + + diff --git a/assets/css/common.css b/assets/css/common.css index 5a1a82d..e0d1044 100644 --- a/assets/css/common.css +++ b/assets/css/common.css @@ -200,7 +200,7 @@ a.quick-chip[data-note]:hover::after { opacity: 1; transform: translateX(-50%) t .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; } +.boards { position: relative; display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 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); @@ -499,3 +499,165 @@ a.nav-btn[data-note]:hover::after { opacity: 1; transform: translateX(-50%) tran .nav-block-grid { grid-template-columns: 1fr; grid-auto-rows: auto; } .nav-btn { justify-content: flex-start; padding: 8px 12px; } } + +/* ============ 文章库(第五板块:三栏阅读区) ============ */ +.art-app { display: flex; flex-direction: column; height: 100vh; min-height: 0; } +.art-topbar { + display: flex; align-items: center; gap: 8px; + padding: 10px 14px; background: var(--card); + border-bottom: 1px solid var(--line); flex: 0 0 auto; +} +.art-topbar-nav { display: flex; gap: 6px; flex: 0 0 auto; min-width: 0; } +.art-topbar-nav .btn { white-space: nowrap; } +.art-topbar-title { + flex: 1 1 auto; text-align: center; font-size: 17px; font-weight: 600; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; max-width: 100%; +} +.art-topbar-acts { display: flex; gap: 6px; flex: 0 0 auto; min-width: 0; } +.art-topbar-acts .btn.active { + background: var(--accent-soft); border-color: var(--brand); color: var(--brand); +} +.art-note { + flex: 0 0 auto; padding: 8px 16px; text-align: center; + background: var(--accent-soft); color: var(--text-2); font-size: 13.5px; + border-bottom: 1px solid var(--line); +} +.art-frame { + flex: 1 1 auto; min-height: 0; display: flex; + --w-title: 15%; --w-toc: 15%; position: relative; +} +.art-col { + display: flex; flex-direction: column; min-height: 0; + background: var(--card); min-width: 0; +} +.art-col-title { flex: 0 0 var(--w-title, 15%); border-right: 1px solid var(--line); } +.art-col-toc { flex: 0 0 var(--w-toc, 15%); border-right: 1px solid var(--line); } +.art-col.art-col-hidden { display: none; } +/* 窄屏浮层显示态 */ +@media (max-width: 860px) { + .art-col-title, .art-col-toc { + position: absolute; top: 0; bottom: 0; z-index: 40; + width: min(80%, 300px); box-shadow: var(--shadow); display: none; + } + .art-col-title { left: 0; border-right: 1px solid var(--line); } + .art-col-toc { right: 0; border-left: 1px solid var(--line); } + .art-col-title.art-col-show-mobile, .art-col-toc.art-col-show-mobile { display: flex; } + .art-gutter { display: none !important; } +} +.art-col-head { + display: flex; align-items: center; justify-content: space-between; gap: 6px; + padding: 8px 12px; font-size: 13px; font-weight: 600; color: var(--text-2); + border-bottom: 1px solid var(--line); background: var(--bg-2); flex: 0 0 auto; +} +.art-mini { + border: none; background: transparent; cursor: pointer; color: var(--text-3); + font-size: 13px; line-height: 1; padding: 3px 6px; border-radius: 5px; +} +.art-mini:hover { background: var(--hover-bg); color: var(--danger); } +.art-nav, .art-toc { overflow-y: auto; flex: 1 1 auto; min-height: 0; padding: 6px 0; } +.art-nav-part { margin: 4px 0; } +.art-nav-part-name { + padding: 5px 12px 3px; font-size: 12px; color: var(--text-3); + position: sticky; top: 0; background: var(--card); font-weight: 600; +} +.art-nav-item { + display: block; padding: 6px 12px 6px 16px; font-size: 13.5px; + color: var(--text-2); border-left: 2px solid transparent; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.art-nav-item:hover { background: var(--hover-bg); color: var(--brand); } +.art-nav-item.active { + background: var(--accent-soft); color: var(--brand); border-left-color: var(--brand); +} +.art-toc-link { + display: block; padding: 5px 14px 5px 20px; font-size: 13px; color: var(--text-2); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; position: relative; +} +.art-toc-link:hover { color: var(--brand); background: var(--hover-bg); } +.art-toc-link.lv2 { padding-left: 30px; font-size: 12.5px; } +.art-toc-link.lv3 { padding-left: 42px; font-size: 12px; } +.art-toc-link.lv4 { padding-left: 54px; font-size: 11.5px; } +.art-gutter { + flex: 0 0 7px; cursor: col-resize; background: transparent; + position: relative; z-index: 5; +} +.art-gutter:hover, .art-gutter.dragging { background: var(--brand); opacity: .35; } +.art-body { + flex: 1 1 auto; min-width: 0; min-height: 0; overflow-y: auto; + background: var(--bg); +} + +/* 正文排版(Markdown 渲染区) */ +.art-article { max-width: 1000px; margin: 0 auto; padding: 26px 34px 60px; } +.art-article-head { border-bottom: 1px solid var(--line); padding-bottom: 16px; margin-bottom: 18px; } +.art-article-part { + display: inline-block; font-size: 12px; color: var(--brand); + background: var(--accent-soft); border-radius: 6px; padding: 2px 9px; margin-bottom: 8px; +} +.art-article-title { font-size: 26px; margin: 0 0 6px; line-height: 1.35; } +.art-article-sum { color: var(--text-3); font-size: 14px; margin: 0 0 8px; } +.art-article-meta { color: var(--text-3); font-size: 12px; } +.art-md { line-height: 1.75; font-size: 15px; color: var(--text); overflow-wrap: break-word; } +.art-md h1, .art-md h2, .art-md h3, .art-md h4, .art-md h5, .art-md h6 { + margin: 1.3em 0 .5em; line-height: 1.4; +} +.art-md h1 { font-size: 1.6em; border-bottom: 1px solid var(--line); padding-bottom: .25em; } +.art-md h2 { font-size: 1.4em; border-bottom: 1px solid var(--line); padding-bottom: .2em; } +.art-md h3 { font-size: 1.22em; } +.art-md h4 { font-size: 1.1em; } +.art-md p { margin: .6em 0; } +.art-md ul, .art-md ol { padding-left: 1.6em; margin: .5em 0; } +.art-md li { margin: .2em 0; } +.art-md blockquote { + margin: .8em 0; padding: 8px 14px; color: var(--text-2); + border-left: 4px solid var(--brand); background: var(--accent-soft); border-radius: 0 8px 8px 0; +} +.art-md code { + font-family: Consolas, Menlo, Monaco, monospace; font-size: .92em; + background: var(--code-bg); color: var(--code-text); border-radius: 4px; padding: 2px 6px; +} +.art-md pre { + background: var(--code-bg); border: 1px solid var(--line); border-radius: 10px; + padding: 14px 16px; overflow-x: auto; line-height: 1.6; +} +.art-md pre code { background: none; padding: 0; color: var(--code-text); } +.art-md img { max-width: 100%; border-radius: 8px; margin: .4em 0; } +.art-md a { text-decoration: underline; text-underline-offset: 2px; } +.art-md table { + border-collapse: collapse; margin: .8em 0; max-width: 100%; display: block; overflow-x: auto; +} +.art-md th, .art-md td { border: 1px solid var(--line-2); padding: 6px 12px; font-size: 14px; } +.art-md th { background: var(--bg-2); } +.art-md hr { border: none; border-top: 1px solid var(--line); margin: 1.6em 0; } + +/* 列表视图(先列表后阅读) */ +.art-list { max-width: 1000px; margin: 0 auto; padding: 24px 26px 60px; } +.art-list-head h1 { font-size: 24px; margin: 0 0 4px; } +.art-list-head .tip { margin: 0 0 10px; } +.art-part-block { margin-top: 18px; } +.art-part-title { + font-size: 17px; color: var(--text-2); margin: 0 0 10px; + padding-left: 10px; border-left: 4px solid var(--brand); +} +.art-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; } +.art-card { + display: flex; flex-direction: column; gap: 6px; padding: 14px 16px; + background: var(--card); border: 1px solid var(--line); border-radius: 12px; + color: var(--text); transition: all .15s ease; min-width: 0; +} +.art-card:hover { border-color: var(--brand); box-shadow: var(--shadow-sm); transform: translateY(-1px); } +.art-card-title { font-weight: 600; font-size: 15px; } +.art-card:hover .art-card-title { color: var(--brand); } +.art-card-sum { color: var(--text-3); font-size: 13px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.art-card-date { color: var(--text-3); font-size: 12px; } +@media (max-width: 600px) { + .art-cards { grid-template-columns: 1fr; } + .art-article { padding: 18px 18px 48px; } + .art-list { padding: 16px 14px 48px; } + .art-article-title { font-size: 21px; } + .art-topbar { gap: 5px; padding: 8px 8px; } + .art-topbar .btn { padding: 4px 8px; font-size: 12px; } + .art-topbar-title { font-size: 14px; } + .art-tg-txt { display: none; } + .art-topbar-nav { gap: 4px; } +} diff --git a/assets/js/article.js b/assets/js/article.js new file mode 100644 index 0000000..daae94b --- /dev/null +++ b/assets/js/article.js @@ -0,0 +1,272 @@ +/* article.js —— 文章库三栏阅读交互 + * 功能:三栏(标题栏 / 段落栏 / 正文)宽度拖拽调节、分区栏与段落栏显示/隐藏; + * Markdown 正文 CDN 懒加载渲染、正文图片路径前缀补全、段落目录生成与平滑跳转。 + */ +(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 app = $('.js-art-app'); + var frame = $('.js-art-frame'); + if (!app || !frame) return; + + var BODY_MIN = 22; // 正文列最小占比(%) + var KEY = 'hp-art-layout'; + var MEDIA_SMALL = '(max-width: 860px)'; + + /* ---------- 布局状态(宽度 / 显隐) ---------- */ + function loadState() { + var isList = global.HP_ART_DATA && global.HP_ART_DATA.listMode ? true : false; + var st = { w1: 15, w2: 15, t1: true, t2: !isList }; // 列表页默认收起段落栏 + try { + var saved = JSON.parse(localStorage.getItem(KEY) || 'null'); + if (saved) { + st.w1 = clampPct(saved.w1, 6, 60); + st.w2 = clampPct(saved.w2, 6, 60); + if (typeof saved.t1 === 'boolean') st.t1 = saved.t1; + if (typeof saved.t2 === 'boolean') st.t2 = saved.t2; + } else if (window.matchMedia && window.matchMedia(MEDIA_SMALL).matches) { + // 窄屏首次进入:默认只显示正文,两栏以浮层形式随时唤出 + st.t1 = false; + st.t2 = false; + } + } catch (e) { /* ignore */ } + return st; + } + function saveState(st) { + try { localStorage.setItem(KEY, JSON.stringify(st)); } catch (e) { /* ignore */ } + } + function clampPct(v, min, max) { + v = parseFloat(v); + if (isNaN(v)) return min; + return Math.min(max, Math.max(min, v)); + } + var st = loadState(); + + function isSmall() { + return (window.matchMedia && window.matchMedia(MEDIA_SMALL).matches) ? true : false; + } + + function applyLayout() { + frame.style.setProperty('--w-title', st.w1 + '%'); + frame.style.setProperty('--w-toc', st.w2 + '%'); + var small = isSmall(); + var tTitle = $('.js-col-title'); + var tToc = $('.js-col-toc'); + var gTitle = $('.js-gutter[data-var="--w-title"]'); + var gToc = $('.js-gutter[data-var="--w-toc"]'); + if (!small) { + if (tTitle) { tTitle.classList.toggle('art-col-hidden', !st.t1); } + if (tToc) { tToc.classList.toggle('art-col-hidden', !st.t2); } + if (gTitle) gTitle.style.display = st.t1 ? '' : 'none'; + if (gToc) gToc.style.display = st.t2 ? '' : 'none'; + } else { + // 窄屏:默认隐藏,靠开关以浮层方式显示 + if (tTitle) tTitle.classList.toggle('art-col-show-mobile', !!st.t1); + if (tToc) tToc.classList.toggle('art-col-show-mobile', !!st.t2); + if (gTitle) gTitle.style.display = 'none'; + if (gToc) gToc.style.display = 'none'; + } + updateToggleBtns(); + } + + function setColVisible(col, visible) { + if (col === 'title') st.t1 = visible; + if (col === 'toc') st.t2 = visible; + applyLayout(); + saveState(st); + } + + function updateToggleBtns() { + $$('.js-art-toggle').forEach(function (b) { + var c = b.getAttribute('data-col'); + var visible = (c === 'title') ? st.t1 : st.t2; + b.classList.toggle('active', visible); + var inSmall = isSmall(); + b.title = (inSmall + ? (visible ? '收起' : '展开') + ((c === 'title') ? '文章标题栏' : '段落目录') + : '显示 / 隐藏' + ((c === 'title') ? '文章标题栏' : '段落目录')); + }); + } + + function bindToggles() { + document.addEventListener('click', function (ev) { + var b = ev.target && ev.target.closest ? ev.target.closest('.js-art-toggle') : null; + if (!b) return; + var c = b.getAttribute('data-col'); + if (c !== 'title' && c !== 'toc') return; + var cur = (c === 'title') ? st.t1 : st.t2; + setColVisible(c, !cur); + }); + } + + /* ---------- 分隔条拖拽 ---------- */ + function bindGutters() { + $$('.js-gutter', frame).forEach(function (g) { + g.addEventListener('mousedown', function (ev) { + ev.preventDefault(); + var colVar = g.getAttribute('data-var'); // --w-title / --w-toc + var isTitle = colVar.indexOf('title') !== -1; + var startX = ev.clientX; + var frameW = frame.getBoundingClientRect().width || 1; + var startW = isTitle ? st.w1 : st.w2; + var other = isTitle ? st.w2 : st.w1; + var moved = false; + + function onMove(e) { + var dx = e.clientX - startX; + var pct = startW + (dx / frameW) * 100; + var max = 100 - BODY_MIN - other; // 保证正文列至少 BODY_MIN% + pct = clampPct(pct, 6, Math.max(6, max)); + if (Math.abs(pct - startW) > 0.2) moved = true; + if (isTitle) { st.w1 = pct; } else { st.w2 = pct; } + frame.style.setProperty(colVar, pct + '%'); + g.classList.add('dragging'); + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + g.classList.remove('dragging'); + if (moved) { + if (isTitle) { st.w1 = clampPct(st.w1, 6, Math.max(6, 100 - BODY_MIN - st.w2)); } + else { st.w2 = clampPct(st.w2, 6, Math.max(6, 100 - BODY_MIN - st.w1)); } + saveState(st); + } + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + }); + } + + /* ---------- Markdown 库 CDN 懒加载(marked v4) ---------- */ + var MARKED_URLS = [ + 'https://cdn.jsdelivr.net/npm/marked@4.3.0/marked.min.js', + 'https://unpkg.com/marked@4.3.0/marked.min.js' + ]; + var markedPromise = null; + + function loadMarked() { + if (global.marked) return Promise.resolve(global.marked); + if (markedPromise) return markedPromise; + markedPromise = new Promise(function (resolve) { + var tryLoad = function (i) { + if (global.marked) { resolve(global.marked); return; } + if (i >= MARKED_URLS.length) { resolve(null); return; } + var s = document.createElement('script'); + s.src = MARKED_URLS[i]; + s.onload = function () { resolve(global.marked || null); }; + s.onerror = function () { tryLoad(i + 1); }; + document.head.appendChild(s); + }; + tryLoad(0); + }); + return markedPromise; + } + + /* ---------- 正文渲染与目录 ---------- */ + var rawEl = document.getElementById('artRaw'); + var mdBox = document.getElementById('artMd'); + var tocNav = $('.js-toc-nav'); + var tocCol = $('.js-col-toc'); + + function fixRelativeUrls() { + // 正文图片/链接可能使用 data/articles/img/… 站点相对根路径(相对站点根), + // 页面位于 article/ 子目录,渲染后需补 ../ 前缀才能正确指向 data/articles/img/。 + if (!mdBox) return; + var absPrefixes = ['#', 'http:', 'https:', '//', 'mailto:', 'tel:', 'data:', '/', '../']; + function apply(attr) { + mdBox.querySelectorAll('img, a').forEach(function (el) { + var v = el.getAttribute(attr); + if (!v) return; + var t = v.trim(); + for (var i = 0; i < absPrefixes.length; i++) { + if (t.indexOf(absPrefixes[i]) === 0) return; + } + // 纯相对路径:视为相对站点根 → 相对当前 article/ 目录需 ../ + el.setAttribute(attr, '../' + v); + }); + } + apply('src'); + apply('href'); + } + + function renderError() { + var tpl = document.getElementById('artErrTpl'); + if (mdBox && tpl) mdBox.innerHTML = tpl.textContent; + } + + function buildToc() { + if (!mdBox || !tocNav) return; + var heads = $$('#artMd h1, #artMd h2, #artMd h3, #artMd h4, #artMd h5'); + tocNav.innerHTML = ''; + if (!heads.length) { + tocNav.innerHTML = '

本文暂无段落标题

'; + return; + } + var frag = document.createDocumentFragment(); + heads.forEach(function (h, i) { + var id = 'art-sec-' + i; + h.id = id; + var lv = parseInt(h.tagName.charAt(1), 10) || 1; + var level = Math.min(4, Math.max(1, lv)); + var a = document.createElement('a'); + a.href = '#' + id; + a.className = 'art-toc-link lv' + level; + a.textContent = h.textContent; + a.addEventListener('click', function (e) { + e.preventDefault(); + var target = document.getElementById(id); + if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + frag.appendChild(a); + }); + tocNav.appendChild(frag); + } + + function renderArticle() { + if (!rawEl || !mdBox) { + if (tocNav) tocNav.innerHTML = '

选择左侧任意文章后,此处生成段落目录。

'; + return; + } + loadMarked().then(function (marked) { + if (!marked) { renderError(); return; } + var md = rawEl.value || ''; + var html = marked.parse(md, { gfm: true, breaks: false }); + mdBox.innerHTML = html; + fixRelativeUrls(); + buildToc(); + // 高亮标题栏当前项 + var curId = (global.HP_ART_DATA && global.HP_ART_DATA.curId) ? global.HP_ART_DATA.curId : 0; + if (curId) { + var link = $('.js-art-nav a[href="?id=' + curId + '"]'); + if (link) { + $$('.js-art-nav a').forEach(function (a) { a.classList.remove('active'); }); + link.classList.add('active'); + } + } + }); + } + + /* ---------- 初始化 ---------- */ + function init() { + applyLayout(); + bindToggles(); + bindGutters(); + renderArticle(); + // 窗口尺寸变化(跨断点时重新应用显隐策略) + var resizeTimer = null; + window.addEventListener('resize', function () { + if (resizeTimer) clearTimeout(resizeTimer); + resizeTimer = setTimeout(function () { applyLayout(); }, 120); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(window); diff --git a/data/homepage.db b/data/homepage.db deleted file mode 100644 index 994ee89..0000000 Binary files a/data/homepage.db and /dev/null differ diff --git a/includes/db.php b/includes/db.php index 86cc4fe..79d6ad8 100644 --- a/includes/db.php +++ b/includes/db.php @@ -95,6 +95,18 @@ function db_init(PDO $pdo): void enabled INTEGER NOT NULL DEFAULT 1, sort INTEGER NOT NULL DEFAULT 0 )'); + $pdo->exec('CREATE TABLE IF NOT EXISTS articles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + part TEXT NOT NULL DEFAULT \'未分类\', + title TEXT NOT NULL, + summary TEXT DEFAULT \'\', + markdown TEXT DEFAULT \'\', + pinned INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + sort INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT \'\', + updated_at TEXT DEFAULT \'\' + )'); // 兼容旧库:为已存在的 categories 表补充 icon 列 db_ensure_column($pdo, 'categories', 'icon', "TEXT DEFAULT ''"); @@ -117,6 +129,13 @@ function db_init(PDO $pdo): void } } + // ---------- 文章库板块分类(第五大板块,旧库缺失时追加) ---------- + $hasArticleCat = (int)$pdo->query("SELECT COUNT(*) FROM categories WHERE key = 'article'")->fetchColumn(); + if ($hasArticleCat === 0) { + $stC = $pdo->prepare('INSERT INTO categories (key, name, icon, description, sort) VALUES (?,?,?,?,?)'); + $stC->execute(['article', '文章库', '📚', '站长原创与整理收录的图文与 Markdown 文章合集。', 5]); + } + // ---------- 默认设置种子 ---------- $hasSettings = (int)$pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn(); if ($hasSettings === 0) { @@ -133,6 +152,7 @@ function db_init(PDO $pdo): void 'last_updated' => '', 'hero_bg' => '', // 首页顶部标识栏背景(纯色 / CSS 渐变),空 = 主题默认 'cdn_ranges' => '', // CDN 段库文本;空 = 使用默认内置列表 + 'article_note' => '', // 文章区首页顶部注要(Markdown 风格,纯文本) ]; $st = $pdo->prepare('INSERT INTO settings (key, value) VALUES (?,?)'); foreach ($defaults as $k => $v) { @@ -149,8 +169,7 @@ function db_init(PDO $pdo): void } // ---------- 功能区工具默认种子 ---------- - $toolCount = (int)$pdo->query('SELECT COUNT(*) FROM func_tools')->fetchColumn(); - if ($toolCount === 0) { + $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], @@ -178,6 +197,37 @@ function db_init(PDO $pdo): void $insTool->execute([$t[0], $t[1], $t[2], $t[3], $mx + 1]); } } + + // ---------- 文章库示例文章(首次建库时提供一篇使用说明) ---------- + $artCount = (int)$pdo->query('SELECT COUNT(*) FROM articles')->fetchColumn(); + if ($artCount === 0) { + $sampleMd = <<prepare('INSERT INTO articles (part, title, summary, markdown, pinned, enabled, sort, created_at, updated_at) VALUES (?,?,?,?,1,1,1,?,?)'); + $stA->execute(['使用说明', '文章库使用说明', '了解分区、三栏阅读与 Markdown 图片引用的基本用法。', $sampleMd, $now, $now]); + } } /** 标记内容已更新(首页展示"上次更新时间") */ diff --git a/index.php b/index.php index 22361bb..e2cbef0 100644 --- a/index.php +++ b/index.php @@ -40,18 +40,21 @@ $catLink = [ 'red' => 'nav.php?cat=red', 'blue' => 'nav.php?cat=blue', 'tool' => 'func/index.php', + 'article' => 'article/index.php', ]; $catIcon = [ 'popular' => '📖', 'red' => '⚔️', 'blue' => '🛡️', 'tool' => '🧰', + 'article' => '📚', ]; $catHint = [ 'popular' => '入门·科普·前沿', 'red' => '攻防演练资源', 'blue' => '防御·应急·溯源', 'tool' => '进入功能区', + 'article' => '进入文章库', ]; layout_head(''); diff --git a/nginx.htaccess b/nginx.htaccess deleted file mode 100644 index e69de29..0000000