新增文章库

This commit is contained in:
MasonLiu 2026-09-06 15:20:48 +08:00
parent 90bd40ffde
commit 4e6d6e5307
12 changed files with 1119 additions and 4 deletions

View File

View File

@ -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) {

370
admin/articles.php Normal file
View File

@ -0,0 +1,370 @@
<?php
/**
* admin/articles.php —— 文章管理
* 分区 + 标题 + 摘要 + Markdown 正文的管理SQLite 存储);
* 文章正文图片上传到 data/articles/img/,正文用站点相对路径引用。
*/
$P = '../';
require_once __DIR__ . '/_guard.php';
$pdo = db();
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]);
}
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 '<div class="' . ($kind === 'ok' ? 'ok-msg' : 'err-msg') . '">' . he($text) . '</div>';
}
$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');
?>
<main class="page-main">
<div class="wrap page-body">
<h2 style="margin-bottom:6px">文章管理</h2>
<p class="tip">文章库(第五大板块)的文章维护:正文为标准 Markdown图片统一上传到 data/articles/img 目录。</p>
<?php if ($savedFlag): ?>
<div class="ok-msg">保存成功,文章库前台已同步更新。</div>
<?php endif; ?>
<?php if ($msgText !== ''): echo msg_block($msgKind, $msgText); endif; ?>
<?php if ($editing || isset($_GET['new'])): ?>
<?php
$cur = $row ? [
'id' => (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;
}
}
?>
<div class="fieldset-card">
<div class="fs-title"><?= $cur['id'] ? '编辑文章 #' . $cur['id'] : '新增文章' ?></div>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="act" value="save">
<input type="hidden" name="id" value="<?= (int)$cur['id'] ?>">
<div class="field-row" style="flex-wrap:wrap">
<div style="flex:1 1 200px;min-width:160px">
<label class="fl">分区 / 栏目</label>
<input type="text" name="part" value="<?= he($cur['part']) ?>" placeholder="如:安全研究 / 随笔" list="artPartList">
<datalist id="artPartList">
<?php foreach ($partSuggest as $ps): ?>
<option value="<?= he($ps) ?>">
<?php endforeach; ?>
</datalist>
</div>
<div style="flex:2 1 260px;min-width:200px">
<label class="fl">文章标题</label>
<input type="text" name="title" value="<?= he($cur['title']) ?>" required>
</div>
<div style="flex:0 1 120px;min-width:90px">
<label class="fl">排序(组内)</label>
<input type="number" name="sort" value="<?= (int)$cur['sort'] ?>" min="0" max="999">
</div>
</div>
<label class="fl">摘要 / 一句话简介(文章区列表展示)</label>
<input type="text" name="summary" value="<?= he($cur['summary']) ?>" placeholder="可选">
<div class="field-row" style="gap:18px;margin:6px 0">
<label class="seg-check"><input type="checkbox" name="pinned" value="1"<?= $cur['pinned'] ? ' checked' : '' ?>> 置顶(首页文章区默认最前)</label>
<label class="seg-check"><input type="checkbox" name="enabled" value="1"<?= $cur['enabled'] ? ' checked' : '' ?>> 前台可见</label>
</div>
<label class="fl">正文Markdown</label>
<textarea name="markdown" rows="16" style="font-family:Consolas,Menlo,monospace;font-size:13px"><?= he($cur['md']) ?></textarea>
<div class="field-row" style="margin-top:8px;align-items:center">
<input type="file" id="artImg" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" style="flex:0 1 260px">
<button type="button" class="btn btn-sm" id="artImgUpload">上传图片并插入引用</button>
<span class="tip">上传后自动在正文末尾插入 <code>![说明](data/articles/img/文件.png)</code> 形式引用;引用路径相对站点根目录,渲染时自动补全前缀。</span>
</div>
<div id="artUpMsg" style="margin-top:6px"></div>
<div class="field-row" style="margin-top:14px">
<button type="submit" class="btn btn-primary">保存文章</button>
<a class="btn" href="articles.php">取消 / 返回列表</a>
</div>
</form>
</div>
<?php endif; ?>
<!-- 文章区首页注要 -->
<div class="fieldset-card">
<div class="fs-title">文章区首页注要(顶部展示给读者的说明文字)</div>
<form method="post">
<?= csrf_field() ?><input type="hidden" name="act" value="note">
<textarea name="note" rows="3" placeholder="例如:这里收录本人在安全与开发方向的原创文章,欢迎阅读。"><?= he($noteText) ?></textarea>
<div style="margin-top:10px"><button type="submit" class="btn btn-primary">保存注要</button></div>
</form>
</div>
<!-- 文章列表 -->
<div class="fieldset-card">
<div class="field-row" style="align-items:center;justify-content:space-between">
<span class="fs-title" style="margin:0;border:none;padding:0">文章列表(共 <?= count($list) ?> 篇)</span>
<a class="btn btn-primary btn-sm" href="articles.php?new=1">+ 新增文章</a>
</div>
<?php if (!$list): ?>
<p class="tip" style="margin-top:10px">还没有文章,点击右上角「新增文章」开始写作。</p>
<?php else: ?>
<div style="overflow-x:auto">
<table class="tbl">
<thead><tr><th>分区</th><th>标题</th><th>状态</th><th>排序</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($list as $a): ?>
<tr>
<td><?= he((string)$a['part']) ?></td>
<td>
<a href="articles.php?edit=<?= (int)$a['id'] ?>"><?= he((string)$a['title']) ?></a>
<div class="tip"><?= he((string)$a['summary']) ?></div>
</td>
<td style="white-space:nowrap">
<?php if ((int)$a['pinned'] === 1): ?><span class="badge badge-ok">置顶</span><?php endif; ?>
<?php if ((int)$a['enabled'] === 1): ?><span class="badge badge-ok">可见</span><?php else: ?><span class="badge badge-no">隐藏</span><?php endif; ?>
</td>
<td><?= (int)$a['sort'] ?></td>
<td style="white-space:nowrap">
<form method="post" style="display:inline" onsubmit="return confirm('确认删除该文章?')">
<?= csrf_field() ?><input type="hidden" name="act" value="del"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger" title="删除"></button>
</form>
<form method="post" style="display:inline">
<?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input type="hidden" name="field" value="pinned"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
<button type="submit" class="btn btn-sm" title="<?= (int)$a['pinned'] === 1 ? '取消置顶' : '置顶' ?>"><?= (int)$a['pinned'] === 1 ? '取消置顶' : '置顶' ?></button>
</form>
<form method="post" style="display:inline">
<?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input type="hidden" name="field" value="enabled"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
<button type="submit" class="btn btn-sm"><?= (int)$a['enabled'] === 1 ? '隐藏' : '显示' ?></button>
</form>
<form method="post" style="display:inline">
<?= csrf_field() ?><input type="hidden" name="act" value="move"><input type="hidden" name="dir" value="up"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
<button type="submit" class="btn btn-sm" title="上移"></button>
</form>
<form method="post" style="display:inline">
<?= csrf_field() ?><input type="hidden" name="act" value="move"><input type="hidden" name="dir" value="down"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
<button type="submit" class="btn btn-sm" title="下移"></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<p class="tip">同分区内使用 / 调整顺序,置顶文章在列表中始终靠前。</p>
<?php endif; ?>
</div>
</div>
</main>
<?php layout_theme_fab(); ?>
<script src="../assets/js/common.js"></script>
<script>
(function () {
'use strict';
var btn = document.getElementById('artImgUpload');
var file = document.getElementById('artImg');
var msg = document.getElementById('artUpMsg');
if (!btn || !file) return;
btn.addEventListener('click', function () {
if (!file.files || !file.files[0]) { alert('请先选择一张图片。'); return; }
var fd = new FormData();
fd.append('act', 'upload');
var csrf = document.querySelector('input[name="csrf"]');
if (csrf) fd.append(csrf.name, csrf.value);
fd.append('file', file.files[0]);
var xhr = new XMLHttpRequest();
xhr.open('POST', window.location.href);
xhr.onload = function () {
try {
var r = JSON.parse(xhr.responseText || '{}');
if (!r.ok) { msg.innerHTML = '<div class="err-msg">上传失败:' + (r.msg || '未知错误') + '</div>'; return; }
var ta = document.querySelector('textarea[name="markdown"]');
var name = r.file.replace('data/articles/img/', '');
var ins = '![图片](data/articles/img/' + name + ')';
if (ta) {
if (ta.value && ta.value.charAt(ta.value.length - 1) !== '\n') ta.value += '\n';
ta.value += '\n' + ins;
}
msg.innerHTML = '<div class="ok-msg">图片已上传data/articles/img/' + name + ',并已插入引用。</div>';
file.value = '';
} catch (e) {
msg.innerHTML = '<div class="err-msg">上传返回异常:' + e + '</div>';
}
};
xhr.onerror = function () { msg.innerHTML = '<div class="err-msg">网络错误,上传失败。</div>'; };
xhr.send(fd);
});
})();
</script>
</body>
</html>

View File

@ -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', '');

70
article/data.php Normal file
View File

@ -0,0 +1,70 @@
<?php
/**
* article/data.php —— 文章库 JSON 数据接口
* data.php?mode=list 文章元数据列表(按分区分组,含分区名)
* data.php?mode=content&id=N 单篇文章完整数据(含 markdown 正文)
* 说明仅返回前台可见enabled=1)文章;隐文不对外。
*/
require_once dirname(__DIR__) . '/includes/db.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$pdo = db();
function json_out(array $data): void
{
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$mode = (string)($_GET['mode'] ?? 'list');
if ($mode === 'content') {
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) {
json_out(['ok' => 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]);

186
article/index.php Normal file
View File

@ -0,0 +1,186 @@
<?php
/**
* article/index.php —— 文章区(文章库)
* 结构:顶部栏(返回首页 + 当页名 + 分区/段落开关)
* 内容三栏:左 15% 文章标题栏(按分区) / 15% 当前文章段落目录 / 70% 正文Markdown 渲染)
* 文章正文存 SQLite图片上传到 data/articles/img/,正文以 data/articles/img/ 站点相对路径引用,
* 渲染时自动补全前缀。三栏宽度可拖拽调节,标题栏 / 段落栏可分别隐藏。
* 打开页面为「先列表后阅读」:默认右栏展示全部分区文章列表,点击文章进入阅读。
*/
$P = '../';
require_once dirname(__DIR__) . '/includes/layout.php';
$pdo = db();
$noteText = setting_get('article_note', '');
// 全部可见文章(按 id 稳定取回,分区顺序取各分区首篇 id 序;分区内置顶 → sort → id
$all = $pdo->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'] : '文章库');
?>
<style>
/* 文章区专用:将外层正文区铺满视口 */
html, body { height: 100%; }
</style>
<div class="art-app js-art-app">
<!-- 顶部栏:返回首页 / 当页名(列表为文章库、阅读时显示文章标题)/ 分区·段落开关 -->
<header class="art-topbar">
<div class="art-topbar-nav">
<a class="btn btn-sm" href="../index.php" title="返回站点首页"> 首页</a>
<?php if ($cur): ?>
<a class="btn btn-sm" href="index.php" title="返回文章区首页(文章列表)"> 文章区首页</a>
<?php endif; ?>
</div>
<div class="art-topbar-title"><?= $cur ? he((string)$cur['title']) : '📚 文章库' ?></div>
<div class="art-topbar-acts">
<button type="button" class="btn btn-sm js-art-toggle" data-col="title" title="显示 / 隐藏左侧文章标题栏"><span class="art-tg-txt">标题栏</span></button>
<button type="button" class="btn btn-sm js-art-toggle" data-col="toc" title="显示 / 隐藏中间段落目录"><span class="art-tg-txt">段落</span></button>
</div>
</header>
<?php if ($noteText !== ''): ?>
<div class="art-note"><?= he($noteText) ?></div>
<?php endif; ?>
<div class="art-frame js-art-frame">
<!-- 左:文章标题栏(按分区) -->
<aside class="art-col art-col-title js-col-title">
<div class="art-col-head">
<span>文章目录</span>
<button type="button" class="art-mini js-art-toggle" data-col="title" title="隐藏标题栏"></button>
</div>
<nav class="art-nav js-art-nav">
<?php if (!$rows): ?>
<p class="tip" style="padding:10px">暂无文章</p>
<?php endif; ?>
<?php foreach ($rows as $g): ?>
<div class="art-nav-part">
<div class="art-nav-part-name"><?= he($g['part']) ?></div>
<?php foreach ($g['articles'] as $a): ?>
<a class="art-nav-item<?= ($cur && (int)$cur['id'] === (int)$a['id']) ? ' active' : '' ?>"
href="?id=<?= (int)$a['id'] ?>" title="<?= he((string)$a['summary']) ?>"><?= he((string)$a['title']) ?></a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</nav>
</aside>
<div class="art-gutter js-gutter" data-var="--w-title"></div>
<!-- 中:段落目录 -->
<aside class="art-col art-col-toc js-col-toc">
<div class="art-col-head">
<span>段落目录</span>
<button type="button" class="art-mini js-art-toggle" data-col="toc" title="隐藏段落栏"></button>
</div>
<nav class="art-toc js-toc-nav"></nav>
</aside>
<div class="art-gutter js-gutter" data-var="--w-toc"></div>
<!-- 右:正文 / 列表 -->
<section class="art-body js-art-body" id="artBody">
<?php if ($cur): ?>
<?php
$md = (string)$cur['markdown'];
$updated = (string)($cur['updated_at'] ?? '');
$part = (string)$cur['part'];
?>
<div class="art-article js-article" data-id="<?= (int)$cur['id'] ?>">
<div class="art-article-head">
<div class="art-article-part"><?= he($part) ?></div>
<h1 class="art-article-title js-art-title"><?= he((string)$cur['title']) ?></h1>
<?php if (trim((string)($cur['summary'] ?? '')) !== ''): ?>
<p class="art-article-sum"><?= he(trim((string)$cur['summary'])) ?></p>
<?php endif; ?>
<?php if ($updated !== ''): ?>
<div class="art-article-meta">更新于 <?= he($updated) ?></div>
<?php endif; ?>
</div>
<div class="art-md js-art-md" id="artMd"></div>
<textarea id="artRaw" hidden><?= he($md) ?></textarea>
<script type="text/plain" id="artErrTpl"><div class="err-msg">正文渲染失败:无法加载 Markdown 解析库,请检查网络后刷新。</div></script>
</div>
<?php else: ?>
<div class="art-list">
<div class="art-list-head">
<h1>全部文章 <span class="tip">(共 <?= $total ?> 篇)</span></h1>
<p class="tip">点击任意文章开始阅读;左侧「文章目录」可随时切换文章,中间「段落目录」可快速跳转。</p>
</div>
<?php if (!$rows): ?>
<div class="panel-card"><p class="tip">文章库还没有内容,站长可在后台「文章管理」中发布。</p></div>
<?php endif; ?>
<?php foreach ($rows as $g): ?>
<div class="art-part-block">
<h2 class="art-part-title"><?= he($g['part']) ?></h2>
<div class="art-cards">
<?php foreach ($g['articles'] as $a): ?>
<a class="art-card" href="?id=<?= (int)$a['id'] ?>">
<span class="art-card-title"><?= he((string)$a['title']) ?></span>
<?php if (trim((string)($a['summary'] ?? '')) !== ''): ?>
<span class="art-card-sum"><?= he(trim((string)$a['summary'])) ?></span>
<?php endif; ?>
<span class="art-card-date"><?= he((string)($a['updated_at'] ?? '')) ?></span>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
</div>
</div>
<?php layout_theme_fab(); ?>
<script>
window.HP_ART_DATA = {
curId: <?= $cur ? (int)$cur['id'] : 0 ?>,
listMode: <?= $cur ? 0 : 1 ?>
};
</script>
<script src="../assets/js/common.js"></script>
<script src="../assets/js/article.js"></script>
</body>
</html>

View File

@ -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; }
}

272
assets/js/article.js Normal file
View File

@ -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 = '<p class="tip" style="padding:10px">本文暂无段落标题</p>';
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 = '<p class="tip" style="padding:10px">选择左侧任意文章后,此处生成段落目录。</p>';
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);

Binary file not shown.

View File

@ -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 = <<<MD
## 分区与文章
后台「文章管理」页可新增、编辑、删除文章,正文使用标准 Markdown 书写,支持分区与置顶。
## 三栏阅读
- 左列:全站文章标题栏,按分区展示,可折叠;
- 中列:当前文章的段落目录,点击可快速定位;
- 右列文章正文Markdown 渲染)。
### 图片引用
图片文件上传到 data/articles/img 目录,正文中写作:
![图片说明](data/articles/img/文件名.png)
渲染时会自动补全站内前缀,无需手工填写完整网址。
## Markdown 支持
支持**加粗***斜体*`行内代码`、列表、表格与引用等常规语法。
MD;
$now = date('Y-m-d H:i:s');
$stA = $pdo->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]);
}
}
/** 标记内容已更新(首页展示"上次更新时间" */

View File

@ -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('');

View File