372 lines
20 KiB
PHP
372 lines
20 KiB
PHP
<?php
|
||
/**
|
||
* admin/notice.php —— 更新公告管理
|
||
* 多条公告的增删改:保留历史、任意一条均可再次编辑、可置顶 / 隐藏;
|
||
* 内容支持超链接([文字](链接) 或裸 http(s) 网址)。发布日期与修改时间均由系统记录。
|
||
*/
|
||
$P = '../';
|
||
require_once __DIR__ . '/_guard.php';
|
||
|
||
$pdo = db();
|
||
|
||
function msg_block(string $kind, string $text): string
|
||
{
|
||
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>';
|
||
}
|
||
|
||
/** 公告内容摘要(用于列表,压缩空白并截断) */
|
||
function notice_excerpt(string $text, int $len = 60): string
|
||
{
|
||
$t = trim(preg_replace('/\s+/u', ' ', $text));
|
||
if (function_exists('mb_substr') && function_exists('mb_strlen')) {
|
||
return mb_strlen($t, 'UTF-8') > $len ? mb_substr($t, 0, $len, 'UTF-8') . '…' : $t;
|
||
}
|
||
return strlen($t) > $len * 2 ? substr($t, 0, $len * 2) . '…' : $t;
|
||
}
|
||
|
||
$msgKind = '';
|
||
$msgText = '';
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
if (!csrf_verify()) {
|
||
$msgKind = 'err';
|
||
$msgText = '安全校验失败,请刷新页面重试。';
|
||
} else {
|
||
$act = (string) ($_POST['act'] ?? '');
|
||
switch ($act) {
|
||
case 'save':
|
||
$id = max(0, (int) ($_POST['id'] ?? 0));
|
||
$title = trim((string) ($_POST['title'] ?? ''));
|
||
$content = trim((string) ($_POST['content'] ?? ''));
|
||
$pinned = (isset($_POST['pinned']) && $_POST['pinned'] === '1') ? 1 : 0;
|
||
$enabled = (isset($_POST['enabled']) && $_POST['enabled'] === '1') ? 1 : 0;
|
||
if ($title === '' && $content === '') {
|
||
$msgKind = 'err';
|
||
$msgText = '公告标题与内容不能同时为空。';
|
||
} elseif ($id > 0) {
|
||
$st = $pdo->prepare('UPDATE notices SET title=?, content=?, pinned=?, enabled=?, updated_at=? WHERE id=?');
|
||
$st->execute([$title, $content, $pinned, $enabled, date('Y-m-d H:i:s'), $id]);
|
||
touch_last_updated();
|
||
header('Location: notice.php?edit=' . $id . '&ok=1');
|
||
exit;
|
||
} else {
|
||
$now = date('Y-m-d H:i:s');
|
||
$st = $pdo->prepare('INSERT INTO notices (title, content, pinned, enabled, created_at, updated_at) VALUES (?,?,?,?,?,?)');
|
||
$st->execute([$title, $content, $pinned, $enabled, $now, $now]);
|
||
$id = (int) $pdo->lastInsertId();
|
||
touch_last_updated();
|
||
header('Location: notice.php?edit=' . $id . '&ok=1');
|
||
exit;
|
||
}
|
||
break;
|
||
case 'del':
|
||
$id = (int) ($_POST['id'] ?? 0);
|
||
if ($id > 0) {
|
||
$pdo->prepare('DELETE FROM notices WHERE id = ?')->execute([$id]);
|
||
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 notices WHERE id = ' . $id)->fetchColumn();
|
||
$pdo->prepare('UPDATE notices SET ' . $field . ' = ? WHERE id = ?')->execute([$cur ? 0 : 1, $id]);
|
||
touch_last_updated();
|
||
$msgKind = 'ok';
|
||
$msgText = $field === 'pinned' ? '置顶状态已更新。' : '可见状态已更新。';
|
||
}
|
||
break;
|
||
default:
|
||
$msgKind = 'err';
|
||
$msgText = '未知操作。';
|
||
}
|
||
}
|
||
}
|
||
|
||
$savedFlag = isset($_GET['ok']);
|
||
$list = $pdo->query('SELECT * FROM notices ORDER BY pinned DESC, created_at DESC, id DESC')->fetchAll();
|
||
|
||
$editing = false;
|
||
$row = null;
|
||
$editId = (int) ($_GET['edit'] ?? 0);
|
||
if ($editId > 0) {
|
||
$st = $pdo->prepare('SELECT * FROM notices WHERE id = ?');
|
||
$st->execute([$editId]);
|
||
$row = $st->fetch();
|
||
if ($row) {
|
||
$editing = true;
|
||
}
|
||
}
|
||
|
||
layout_head('更新公告管理');
|
||
admin_topbar('notice');
|
||
?>
|
||
<main class="page-main">
|
||
<div class="wrap page-body">
|
||
<h2 style="margin-bottom:6px">更新公告管理</h2>
|
||
<p class="tip">
|
||
前台页脚「更新公告」页展示内容:可发布多条公告并<strong>保留历史</strong>,任意一条均可再次编辑。
|
||
内容支持超链接,写法 <code>[文字](链接)</code>;站内用相对路径(如 <code>/index.php</code>、<code>/func/index.php</code>),
|
||
外链用 <code>https://…</code>,也可直接粘贴网址自动识别。换行会被保留。
|
||
</p>
|
||
|
||
<?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'],
|
||
'title' => (string) $row['title'],
|
||
'content' => (string) $row['content'],
|
||
'pinned' => (int) $row['pinned'],
|
||
'enabled' => (int) $row['enabled'],
|
||
'created_at' => (string) $row['created_at'],
|
||
'updated_at' => (string) $row['updated_at'],
|
||
] : ['id' => 0, 'title' => '', 'content' => '', 'pinned' => 0, 'enabled' => 1, 'created_at' => '', 'updated_at' => ''];
|
||
?>
|
||
<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'] ?>">
|
||
|
||
<label class="fl">公告标题(可选)</label>
|
||
<input type="text" name="title" id="ntTitle" value="<?= he($cur['title']) ?>"
|
||
placeholder="例如:2026-09-12 新增 Navicat 密码解密工具">
|
||
|
||
<label class="fl" style="margin-top:12px">公告内容</label>
|
||
<textarea name="content" id="ntContent" rows="10"
|
||
style="font-size:14px;line-height:1.7"><?= he($cur['content']) ?></textarea>
|
||
|
||
<div class="field-row" style="margin-top:8px;align-items:center;flex-wrap:wrap">
|
||
<button type="button" class="btn btn-sm" id="ntLinkBtn">插入链接</button>
|
||
<button type="button" class="btn btn-sm" id="ntPreviewBtn">预览渲染</button>
|
||
<span class="tip">链接写法:<code>[文字](链接)</code>;也能直接粘贴 http(s) 网址。发布日期自动记录,保存时自动更新「修改时间」。</span>
|
||
</div>
|
||
<div class="notice-live" id="ntPreview" style="display:none"></div>
|
||
|
||
<div class="field-row" style="gap:18px;margin:12px 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>
|
||
|
||
<div class="field-row" style="margin-top:8px;align-items:center">
|
||
<button type="submit" class="btn btn-primary">保存公告</button>
|
||
<a class="btn" href="notice.php">取消 / 返回列表</a>
|
||
<?php if ($cur['created_at'] !== ''): ?>
|
||
<span class="tip" style="align-self:center">发布:<?= he($cur['created_at']) ?> ·
|
||
上次修改:<?= he($cur['updated_at']) ?></span>
|
||
<?php endif; ?>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<!-- 公告列表:全部保留,可重新编辑 -->
|
||
<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">
|
||
<a class="btn btn-sm" href="../notice.php" target="_blank" rel="noopener noreferrer">预览前台页面</a>
|
||
<a class="btn btn-primary btn-sm" href="notice.php?new=1">+ 发布公告</a>
|
||
</span>
|
||
</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 $n): ?>
|
||
<tr>
|
||
<td>
|
||
<a
|
||
href="notice.php?edit=<?= (int) $n['id'] ?>"><?= $n['title'] !== '' ? he((string) $n['title']) : '(无标题公告 #' . (int) $n['id'] . ')' ?></a>
|
||
<div class="tip"><?= he(notice_excerpt((string) $n['content'])) ?></div>
|
||
</td>
|
||
<td style="white-space:nowrap"><?= he((string) $n['created_at']) ?></td>
|
||
<td style="white-space:nowrap"><?= he((string) $n['updated_at']) ?></td>
|
||
<td style="white-space:nowrap">
|
||
<?php if ((int) $n['pinned'] === 1): ?><span
|
||
class="badge badge-ok">置顶</span><?php endif; ?>
|
||
<?php if ((int) $n['enabled'] === 1): ?><span
|
||
class="badge badge-ok">可见</span><?php else: ?><span
|
||
class="badge badge-no">隐藏</span><?php endif; ?>
|
||
</td>
|
||
<td style="white-space:nowrap">
|
||
<a class="btn btn-sm" href="notice.php?edit=<?= (int) $n['id'] ?>">编辑</a>
|
||
<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) $n['id'] ?>">
|
||
<button type="submit"
|
||
class="btn btn-sm"><?= (int) $n['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) $n['id'] ?>">
|
||
<button type="submit"
|
||
class="btn btn-sm"><?= (int) $n['enabled'] === 1 ? '隐藏' : '显示' ?></button>
|
||
</form>
|
||
<form method="post" style="display:inline" data-confirm="确认删除该公告?删除后不可恢复。">
|
||
<?= csrf_field() ?><input type="hidden" name="act" value="del"><input type="hidden"
|
||
name="id" value="<?= (int) $n['id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-danger" title="删除">✕</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<?php layout_theme_fab(); ?>
|
||
<script src="../assets/js/common.js"></script>
|
||
<script>
|
||
// 插入链接(站内风格弹窗)+ 本地预览渲染(与前台 notice_html 逻辑一致)
|
||
(function () {
|
||
var ta = document.getElementById('ntContent');
|
||
if (!ta) return;
|
||
|
||
function esc(s) {
|
||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
// 解析链接:http(s) / 协议相对 / 站内相对 / 裸域名(自动补 https://);无法识别则保留原写法
|
||
function resolveLink(label, url) {
|
||
var raw = url.replace(/&/g, '&').trim();
|
||
if (/^(?:https?:)?\/\//i.test(raw)) return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
|
||
if (raw.charAt(0) === '/') return '<a href="' + url + '">' + label + '</a>';
|
||
if (/^[A-Za-z]:/.test(raw)) return '[' + label + '](' + url + ')';
|
||
if (/^[^\s]+\.[A-Za-z]{2,}(?:[/?#].*)?$/.test(raw)) return '<a href="https://' + url + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
|
||
if (/^[\w\-.]+[/][\w\-./?%#=&]*$/.test(raw)) return '<a href="' + url + '">' + label + '</a>';
|
||
return '[' + label + '](' + url + ')';
|
||
}
|
||
|
||
function render(text) {
|
||
var out = esc(text);
|
||
// 1) [文字](链接)
|
||
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, function (m, label, url) {
|
||
return resolveLink(label, url);
|
||
});
|
||
// 2) 裸链接(用回调判断前一个字符,避免使用后行断言)
|
||
out = out.replace(/https?:\/\/[^\s<"')]+/g, function (m, offset) {
|
||
var prev = offset > 0 ? out.charAt(offset - 1) : '';
|
||
if (prev === '"' || prev === '=' || prev === "'" || prev === '/' || prev === '>') return m;
|
||
var url = m.replace(/[.,;:!?]+$/, '');
|
||
var tail = m.slice(url.length);
|
||
return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + url + '</a>' + tail;
|
||
});
|
||
return out.replace(/\n/g, '<br>');
|
||
}
|
||
|
||
// 站内风格「插入链接」弹窗(替代原生 prompt)
|
||
function insertLinkDialog(defaultText) {
|
||
return new Promise(function (resolve) {
|
||
var overlay = document.createElement('div');
|
||
overlay.className = 'sc-overlay';
|
||
overlay.innerHTML =
|
||
'<div class="sc-card nt-dlg" role="dialog" aria-modal="true">' +
|
||
'<div class="sc-head">插入链接</div>' +
|
||
'<div class="sc-body">' +
|
||
'<label class="fl" for="ntLiText">链接文字</label>' +
|
||
'<input type="text" id="ntLiText" class="nt-li-text" placeholder="显示给访客的文字,例如:点击查看">' +
|
||
'<label class="fl" for="ntLiUrl">链接地址</label>' +
|
||
'<input type="text" id="ntLiUrl" class="nt-li-url" placeholder="如 /index.php 或 https://example.com">' +
|
||
'<div class="tip" style="margin-top:8px">站内用 <code>/index.php</code> 这类相对路径;外部填 <code>https://…</code>;只填 <code>www.example.com</code> 会自动补上 https://。</div>' +
|
||
'</div>' +
|
||
'<div class="sc-foot">' +
|
||
'<button type="button" class="btn btn-sm nt-li-cancel">取消</button>' +
|
||
'<button type="button" class="btn btn-sm btn-primary nt-li-ok">插入</button>' +
|
||
'</div></div>';
|
||
var textIn = overlay.querySelector('.nt-li-text');
|
||
var urlIn = overlay.querySelector('.nt-li-url');
|
||
textIn.value = defaultText || '';
|
||
var finished = false;
|
||
function close(val) {
|
||
if (finished) return;
|
||
finished = true;
|
||
document.removeEventListener('keydown', onKey, true);
|
||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
resolve(val);
|
||
}
|
||
function submit() {
|
||
var url = urlIn.value.trim();
|
||
if (url === '') { urlIn.focus(); return; }
|
||
var text = textIn.value.trim();
|
||
if (text === '') text = url;
|
||
close({ text: text, url: url });
|
||
}
|
||
function onKey(ev) {
|
||
if (ev.key === 'Escape') { ev.preventDefault(); close(null); }
|
||
else if (ev.key === 'Enter') { ev.preventDefault(); submit(); }
|
||
}
|
||
overlay.querySelector('.nt-li-ok').addEventListener('click', submit);
|
||
overlay.querySelector('.nt-li-cancel').addEventListener('click', function () { close(null); });
|
||
overlay.addEventListener('mousedown', function (ev) { if (ev.target === overlay) close(null); });
|
||
document.addEventListener('keydown', onKey, true);
|
||
document.body.appendChild(overlay);
|
||
(defaultText ? urlIn : textIn).focus();
|
||
});
|
||
}
|
||
|
||
var linkBtn = document.getElementById('ntLinkBtn');
|
||
if (linkBtn) {
|
||
linkBtn.addEventListener('click', function () {
|
||
var s = ta.selectionStart, e = ta.selectionEnd;
|
||
var sel = ta.value.slice(s, e).trim();
|
||
insertLinkDialog(sel).then(function (r) {
|
||
if (!r) return;
|
||
var snippet = '[' + r.text + '](' + r.url + ')';
|
||
ta.value = ta.value.slice(0, s) + snippet + ta.value.slice(e);
|
||
ta.focus();
|
||
ta.selectionStart = ta.selectionEnd = s + snippet.length;
|
||
});
|
||
});
|
||
}
|
||
|
||
var pvBtn = document.getElementById('ntPreviewBtn');
|
||
var pv = document.getElementById('ntPreview');
|
||
if (pvBtn && pv) {
|
||
pvBtn.addEventListener('click', function () {
|
||
if (pv.style.display === 'none') {
|
||
pv.innerHTML = '<div class="notice-body">' + render(ta.value) + '</div>';
|
||
pv.style.display = '';
|
||
pvBtn.textContent = '收起预览';
|
||
} else {
|
||
pv.style.display = 'none';
|
||
pvBtn.textContent = '预览渲染';
|
||
}
|
||
});
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|