NewHome/admin/articles.php
2026-09-14 22:06:21 +08:00

655 lines
30 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
{
// 改为右下角 Toast注入 hpToastMsg由 common.js 在页面加载后自动弹出
return '<script>window.hpToastMsg = ' . json_encode(['kind' => $kind === 'ok' ? 'ok' : 'err', 'text' => $text], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) . ';</script>';
}
$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">
<div class="admin-head">
<div class="ah-main">
<h2 class="ah-title">文章管理</h2>
<p class="ah-sub">文章库(第五大板块)的文章维护:正文为标准 Markdown图片统一上传到 data/articles/img 目录。</p>
</div>
</div>
<?php if ($savedFlag): ?>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,文章库前台已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?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">文章列表</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 = '![图片](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();
}
});
})();
(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>