608 lines
30 KiB
PHP
608 lines
30 KiB
PHP
<?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 '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');
|
||
?>
|
||
<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></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>
|
||
<span style="display:inline-flex;gap:8px;align-items:center">
|
||
<button type="button" class="btn btn-sm" id="artFoldAll" title="折叠 / 展开所有分类">展开全部</button>
|
||
<a class="btn btn-primary btn-sm" href="articles.php?new=1">+ 新增文章</a>
|
||
</span>
|
||
</div>
|
||
<p class="tip" style="margin-top:6px">
|
||
分类即分组:点组头 ▾ 展开 / 收起该分类;按住组头 <strong>⠿</strong> 拖动可调整分类先后顺序(松手即自动保存,前台按此顺序展示)。
|
||
分类内文章使用 ↑/↓ 微调顺序;前台各分类内按文章<strong>最后更新时间</strong>倒序展示。
|
||
</p>
|
||
<?php if (!$list): ?>
|
||
<p class="tip" style="margin-top:10px">还没有文章,点击右上角「新增文章」开始写作。</p>
|
||
<?php else: ?>
|
||
<div id="artGroups" style="margin-top:12px">
|
||
<?php foreach ($artGroups as $gPart => $g): ?>
|
||
<div class="xg is-fold" data-part="<?= he($gPart) ?>">
|
||
<div class="xg-h" title="点击展开 / 收起该分类">
|
||
<button type="button" class="c-arr" aria-hidden="true">▾</button>
|
||
<button type="button" class="drag-h" title="按住拖动,调整该分类先后顺序" draggable="true">⠿</button>
|
||
<span class="xg-name"><?= he($gPart) ?></span>
|
||
<span class="xg-cnt"><?= count($g['rows']) ?> 篇</span>
|
||
</div>
|
||
<div class="xg-b">
|
||
<div style="overflow-x:auto">
|
||
<table class="tbl">
|
||
<thead><tr><th>标题</th><th>更新时间</th><th>状态</th><th>操作</th></tr></thead>
|
||
<tbody>
|
||
<?php foreach ($g['rows'] as $a): ?>
|
||
<tr>
|
||
<td>
|
||
<a href="articles.php?edit=<?= (int)$a['id'] ?>"><?= he((string)$a['title']) ?></a>
|
||
<?php if (trim((string)$a['summary']) !== ''): ?><div class="tip"><?= he((string)$a['summary']) ?></div><?php endif; ?>
|
||
</td>
|
||
<td style="white-space:nowrap"><?= he((string)$a['updated_at']) ?></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 style="white-space:nowrap">
|
||
<form method="post" style="display:inline" data-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>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<p class="tip" style="margin-top:10px">分组顺序与前台文章区分区一致;组头默认收起,便于大量文章时分批展开管理。</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 = '';
|
||
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();
|
||
}
|
||
});
|
||
})();
|
||
(function () {
|
||
'use strict';
|
||
// 文章列表:分类即分组 —— 组头点击折叠/展开、整组拖动排序并即时持久化 part_order
|
||
var gWrap = document.getElementById('artGroups');
|
||
if (!gWrap) return;
|
||
|
||
function groups() {
|
||
return Array.prototype.filter.call(gWrap.children, function (c) { return c.classList.contains('xg'); });
|
||
}
|
||
function tip(text, isErr) {
|
||
var old = document.getElementById('artOrderTip');
|
||
if (old) old.remove();
|
||
var d = document.createElement('div');
|
||
d.id = 'artOrderTip';
|
||
d.className = isErr ? 'err-msg' : 'ok-msg';
|
||
d.style.margin = '8px 0';
|
||
d.textContent = text;
|
||
gWrap.parentNode.insertBefore(d, gWrap);
|
||
setTimeout(function () { if (d.parentNode) d.parentNode.removeChild(d); }, 3200);
|
||
}
|
||
function syncFoldBtn() {
|
||
var b = document.getElementById('artFoldAll');
|
||
if (!b) return;
|
||
var gs = groups();
|
||
var allFolded = gs.length > 0 && gs.every(function (g) { return g.classList.contains('is-fold'); });
|
||
var allOpen = gs.length > 0 && gs.every(function (g) { return !g.classList.contains('is-fold'); });
|
||
b.textContent = allOpen ? '收起全部' : '展开全部';
|
||
b.title = allFolded ? '展开全部分类' : (allOpen ? '收起全部分类' : '折叠 / 展开所有分类');
|
||
}
|
||
gWrap.addEventListener('click', function (ev) {
|
||
if (ev.target.classList.contains('drag-h')) return;
|
||
var xgh = ev.target.closest ? ev.target.closest('.xg-h') : null;
|
||
if (!xgh || !gWrap.contains(xgh)) return;
|
||
xgh.parentNode.classList.toggle('is-fold');
|
||
syncFoldBtn();
|
||
});
|
||
var foldAllBtn = document.getElementById('artFoldAll');
|
||
if (foldAllBtn) {
|
||
foldAllBtn.addEventListener('click', function () {
|
||
var gs = groups();
|
||
var anyOpen = gs.some(function (g) { return !g.classList.contains('is-fold'); });
|
||
for (var i = 0; i < gs.length; i++) { gs[i].classList.toggle('is-fold', anyOpen); }
|
||
syncFoldBtn();
|
||
});
|
||
}
|
||
|
||
// 拖动整个分区组排序(鼠标按住手柄拖动,其他组自适应)
|
||
var dragXg = null;
|
||
function clearDrop() {
|
||
groups().forEach(function (g) { g.classList.remove('drop-target', 'drag-src'); });
|
||
}
|
||
gWrap.addEventListener('dragstart', function (ev) {
|
||
var h = ev.target.closest ? ev.target.closest('.drag-h') : null;
|
||
if (!h || !gWrap.contains(h)) return;
|
||
var xg = h.closest('.xg');
|
||
if (!xg) return;
|
||
dragXg = xg;
|
||
ev.dataTransfer.effectAllowed = 'move';
|
||
try { ev.dataTransfer.setData('text/plain', xg.getAttribute('data-part') || ''); } catch (e) {}
|
||
setTimeout(function () { xg.classList.add('drag-src'); }, 0);
|
||
});
|
||
gWrap.addEventListener('dragover', function (ev) {
|
||
if (!dragXg) return;
|
||
var xg = ev.target.closest ? ev.target.closest('.xg') : null;
|
||
if (!xg || xg === dragXg) {
|
||
clearDrop();
|
||
return;
|
||
}
|
||
ev.preventDefault();
|
||
ev.dataTransfer.dropEffect = 'move';
|
||
clearDrop();
|
||
xg.classList.add('drop-target');
|
||
});
|
||
gWrap.addEventListener('drop', function (ev) {
|
||
if (!dragXg) return;
|
||
ev.preventDefault();
|
||
var xg = ev.target.closest ? ev.target.closest('.xg') : null;
|
||
if (xg && xg !== dragXg) {
|
||
var rect = xg.getBoundingClientRect();
|
||
var after = ev.clientY > rect.top + rect.height / 2;
|
||
gWrap.insertBefore(dragXg, after ? xg.nextSibling : xg);
|
||
savePartOrder();
|
||
}
|
||
});
|
||
gWrap.addEventListener('dragend', function () {
|
||
clearDrop();
|
||
dragXg = null;
|
||
});
|
||
|
||
function savePartOrder() {
|
||
var order = groups().map(function (g) { return g.getAttribute('data-part') || ''; })
|
||
.filter(function (v) { return v !== ''; }).join(',');
|
||
var csrfEl = document.querySelector('input[name="csrf"]');
|
||
if (!csrfEl) { alert('找不到安全令牌,请刷新页面重试。'); return; }
|
||
var fd = new FormData();
|
||
fd.append('act', 'part_order');
|
||
fd.append('csrf', csrfEl.value);
|
||
fd.append('order', order);
|
||
fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' })
|
||
.then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); })
|
||
.then(function (j) {
|
||
if (j && j.ok === true) tip('分类顺序已保存,前台文章区已同步更新。');
|
||
else alert('分类顺序保存失败:' + ((j && j.msg) || '未知错误'));
|
||
})
|
||
.catch(function () { alert('网络错误,分类顺序未保存。'); });
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|