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 { // 改为右下角 Toast:注入 hpToastMsg,由 common.js 在页面加载后自动弹出 return ''; } $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; // 分发到库首页(文章区):board_cats 逗号分隔;未勾选=不展示到库页 $bcKept = []; foreach ((array)($_POST['board_cats'] ?? []) as $bk) { $bk = trim((string)$bk); if (in_array($bk, ['popular', 'red', 'blue'], true) && !in_array($bk, $bcKept, true)) { $bcKept[] = $bk; } } $boardCats = implode(',', $bcKept); if ($part === '' || $part === '__new__') { $part = '未分类'; } if ($title === '') { $msgKind = 'err'; $msgText = '文章标题不能为空。'; } elseif ($id > 0) { $st = $pdo->prepare('UPDATE articles SET part=?, title=?, summary=?, markdown=?, pinned=?, enabled=?, sort=?, board_cats=?, updated_at=? WHERE id=?'); $st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, 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, board_cats, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)'); $now = date('Y-m-d H:i:s'); $st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, $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 'part_order': // 分区(分类)拖动排序后持久化到设置:CSV 顺序 header('Content-Type: application/json; charset=utf-8'); $parts = []; foreach (explode(',', (string)($_POST['order'] ?? '')) as $po) { $po = trim((string)$po); if ($po !== '' && !in_array($po, $parts, true)) { $parts[] = $po; } } settings_set('article_part_order', implode(',', $parts)); touch_last_updated(); echo json_encode(['ok' => true], JSON_UNESCAPED_UNICODE); exit; 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(); // —— 文章按分区(分类)分组;分区顺序:后台拖动保存的 article_part_order 优先,其余按各组最新更新时间降序 —— $artGroups = []; foreach ($list as $a) { $p = trim((string)$a['part']); if ($p === '') { $p = '未分类'; } if (!isset($artGroups[$p])) { $artGroups[$p] = ['ts' => (string)$a['updated_at'], 'rows' => []]; } $artGroups[$p]['rows'][] = $a; if (strcmp((string)$a['updated_at'], $artGroups[$p]['ts']) > 0) { $artGroups[$p]['ts'] = (string)$a['updated_at']; } } $poIdx = []; $poCfg = trim((string)setting_get('article_part_order', '')); if ($poCfg !== '') { foreach (explode(',', $poCfg) as $po) { $po = trim($po); if ($po !== '' && isset($artGroups[$po])) { $poIdx[$po] = count($poIdx); } } } uksort($artGroups, static function ($pa, $pb) use ($poIdx, $artGroups) { $ia = $poIdx[$pa] ?? PHP_INT_MAX; $ib = $poIdx[$pb] ?? PHP_INT_MAX; if ($ia !== $ib) return $ia <=> $ib; $d = strcmp($artGroups[$pb]['ts'], $artGroups[$pa]['ts']); return $d !== 0 ? $d : strcmp($pa, $pb); }); $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'], 'board_cats' => trim((string)($row['board_cats'] ?? '')), ] : ['id' => 0, 'part' => '未分类', 'title' => '', 'summary' => '', 'md' => '', 'sort' => 0, 'pinned' => 0, 'enabled' => 1, 'board_cats' => '']; // 分区下拉选项:未分类 + 全部已有分区;编辑中的自定义分区自动补充,保证能回显选中 $curPart = trim((string)$cur['part']); if ($curPart === '') { $curPart = '未分类'; } $partOptions = ['未分类']; foreach ($list as $a2) { $p2 = trim((string)$a2['part']); if ($p2 === '' || $p2 === '未分类') continue; if (!in_array($p2, $partOptions, true)) { $partOptions[] = $p2; } } if (!in_array($curPart, $partOptions, true)) { array_unshift($partOptions, $curPart); } ?>
可直接下拉选择已有分区;需要新栏目时选「+ 新建分区…」并输入名称后回车。
上传后自动在正文末尾插入 ![说明](data/articles/img/文件.png) 形式引用;引用路径相对站点根目录,渲染时自动补全前缀。
取消 / 返回列表
文章区首页注要(顶部展示给读者的说明文字)
文章列表(共 篇,按分类分组) + 新增文章

分类即分组:点组头 ▾ 展开 / 收起该分类;按住组头 拖动可调整分类先后顺序(松手即自动保存,前台按此顺序展示)。 分类内文章使用 ↑/↓ 微调顺序;前台各分类内按文章最后更新时间倒序展示。

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

$g): ?>
标题更新时间状态操作
置顶 可见隐藏

分组顺序与前台文章区分区一致;组头默认收起,便于大量文章时分批展开管理。