NewHome/admin/articles.php

445 lines
22 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/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;
// 分发到库首页文章区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 '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'],
'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); }
?>
<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>
<select name="part" id="artPartSel">
<?php foreach ($partOptions as $po): ?>
<option value="<?= he($po) ?>"<?= $curPart === $po ? ' selected' : '' ?>><?= he($po) ?></option>
<?php endforeach; ?>
<option value="__new__"> 新建分区…</option>
</select>
<input type="text" id="artPartNew" placeholder="输入新分区名称,回车确认" style="display:none">
<span class="tip" style="display:block;margin-top:4px">可直接下拉选择已有分区;需要新栏目时选「+ 新建分区…」并输入名称后回车。</span>
</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>
<?php $artBc = array_map('trim', explode(',', (string)$cur['board_cats'])); ?>
<label class="fl">分发到库首页「文章区」(不勾选=不在科普 / 红队 / 蓝队库首页展示)</label>
<div class="field-row" style="gap:18px;margin:6px 0">
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="popular"<?= in_array('popular', $artBc, true) ? ' checked' : '' ?>>科普库首页</label>
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="red"<?= in_array('red', $artBc, true) ? ' checked' : '' ?>>红队库首页</label>
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="blue"<?= in_array('blue', $artBc, true) ? ' 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);
});
})();
(function () {
'use strict';
// 分区下拉:选择「+ 新建分区…」时切换为新分区名输入框,回车/失焦确认后回落
var sel = document.getElementById('artPartSel');
var inp = document.getElementById('artPartNew');
var form = (sel && inp) ? sel.closest('form') : null;
if (!sel || !inp || !form) return;
function showSel() {
sel.style.display = '';
inp.style.display = 'none';
}
function acceptNew() {
var v = inp.value.replace(/^\s+|\s+$/g, '');
if (v === '') return false;
var i, found = false;
for (i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === v) { sel.selectedIndex = i; found = true; break; }
}
if (!found) {
var opt = document.createElement('option');
opt.value = v;
opt.text = v;
sel.appendChild(opt);
sel.value = v;
}
inp.value = '';
showSel();
return true;
}
sel.addEventListener('change', function () {
if (sel.value === '__new__') {
sel.style.display = 'none';
inp.style.display = '';
inp.focus();
}
});
inp.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
if (!acceptNew()) showSel();
}
});
inp.addEventListener('blur', function () {
if (!acceptNew()) showSel();
});
form.addEventListener('submit', function (ev) {
if (sel.value === '__new__' && !acceptNew()) {
ev.preventDefault();
showSel();
sel.focus();
}
});
})();
</script>
</body>
</html>