NewHome/admin/notice.php

372 lines
20 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/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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
// 解析链接http(s) / 协议相对 / 站内相对 / 裸域名(自动补 https://);无法识别则保留原写法
function resolveLink(label, url) {
var raw = url.replace(/&amp;/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>