feat: 评论/帖子编辑历史与编辑标记,内容锁定

- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal
- 新增 PostEditedMark 编辑标记展示
- 新增内容锁定机制(content_lock)防止并发编辑冲突
- 审核与通知服务适配
This commit is contained in:
2026-09-25 23:41:45 +08:00
parent cab8230803
commit 5270fee2b7
40 changed files with 2521 additions and 350 deletions

View File

@@ -21,6 +21,9 @@ import { ANNOUNCEMENT_COLORS, announcementPublicPath } from "./announcementColor
type PublishKey = "published" | "draft";
type StatusFilter = "all" | "published" | "draft";
/** 公告正文字符上限(与后端 service/announcement.go 校验一致) */
const CONTENT_MAX = 20000;
type FormState = {
title: string;
content: string;
@@ -328,8 +331,9 @@ export default function AnnouncementComposeClient({
value={form.content}
onChange={(content) => {
setErrors((prev) => ({ ...prev, content: undefined }));
patchForm({ content });
patchForm({ content: content.slice(0, CONTENT_MAX) });
}}
maxChars={CONTENT_MAX}
allowHide
allowReplyHide={false}
placeholder="请输入公告正文,支持 Markdown…"

View File

@@ -200,7 +200,7 @@ export default function BadgesClient() {
管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。
</p>
</div>
<button type="button" className="btn btn-solid btn-sm" onClick={openCreate}>
<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
<Plus size={14} /> 新建徽章
</button>
</div>
@@ -290,7 +290,7 @@ export default function BadgesClient() {
) : (
<button
type="button"
className="btn btn-solid btn-sm"
className="btn btn-primary btn-sm"
disabled={grantingId === u.id}
onClick={() => void grant(u.id)}
>
@@ -416,7 +416,7 @@ export default function BadgesClient() {
</button>
<button
type="button"
className="btn btn-solid btn-sm"
className="btn btn-primary btn-sm"
disabled={saving || runeCount(form.name.trim()) === 0}
onClick={() => void saveForm()}
>

View File

@@ -25,6 +25,7 @@ import {
apiAdminContentPosts,
apiAdminContentComments,
apiAdminSoftDeletePost,
apiCommentHistory,
apiAdminRestorePost,
apiAdminPurgePost,
apiAdminSoftDeleteComment,
@@ -48,7 +49,7 @@ import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog";
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
import DeleteCommentModal from "@/components/DeleteCommentModal";
import CommentEditHistoryModal from "@/components/CommentEditHistoryModal";
import EditHistoryModal from "@/components/EditHistoryModal";
import {
AdminEmpty,
AdminPageHeader,
@@ -320,6 +321,7 @@ export default function ContentAdmin({
const [staffDeleteComment, setStaffDeleteComment] = useState<AdminContentComment | null>(null);
const [staffDeleteCommentBusy, setStaffDeleteCommentBusy] = useState(false);
const [historyCommentId, setHistoryCommentId] = useState<number | null>(null);
const [historyCommentContent, setHistoryCommentContent] = useState("");
const refreshCounts = useCallback(() => {
apiAdminContentCounts().then(setCounts).catch(() => {});
@@ -710,11 +712,17 @@ export default function ContentAdmin({
onReject={() => askReject("comments", c.id)}
/>
) : null}
<ContentAction
label="历史"
icon={<History size={13} />}
onClick={() => setHistoryCommentId(c.id)}
/>
{/* 未编辑(无修订快照)的评论不展示历史入口,避免打开空记录 */}
{c.edited ? (
<ContentAction
label="历史"
icon={<History size={13} />}
onClick={() => {
setHistoryCommentContent(c.content);
setHistoryCommentId(c.id);
}}
/>
) : null}
{c.deleted ? (
<>
{canModerateAuthor({ role: viewerRole }, c.user?.role) ? (
@@ -930,9 +938,14 @@ export default function ContentAdmin({
}}
/>
<CommentEditHistoryModal
<EditHistoryModal
open={historyCommentId != null}
commentId={historyCommentId}
title="评论编辑历史"
currentContent={historyCommentContent}
fetchPage={(p, s) => {
if (historyCommentId == null) return Promise.reject(new Error("未选择评论"));
return apiCommentHistory(historyCommentId, p, s);
}}
onClose={() => setHistoryCommentId(null)}
/>
</div>

View File

@@ -20,6 +20,9 @@ import useUnsavedGuard from "@/hooks/useUnsavedGuard";
type PublishKey = "published" | "draft";
type StatusFilter = "all" | "published" | "draft";
/** 单页正文字符上限(与后端 service/site_page.go 校验一致) */
const CONTENT_MAX = 20000;
type FormState = {
slug: string;
title: string;
@@ -365,8 +368,9 @@ export default function PageComposeClient({
value={form.content}
onChange={(content) => {
setErrors((prev) => ({ ...prev, content: undefined }));
patchForm({ content });
patchForm({ content: content.slice(0, CONTENT_MAX) });
}}
maxChars={CONTENT_MAX}
allowHide={false}
placeholder="请输入页面正文,支持 Markdown…"
rows={16}

View File

@@ -56,7 +56,7 @@ export default function PointsTabs({
: TAB_OPTIONS.filter((o) => o.key === "overview");
return (
<div>
<div className={tab === "users" ? "admin-points-users-tab" : undefined}>
{canManage ? (
<div className="mb-4">
<AdminSegmented

View File

@@ -24,6 +24,15 @@ const fmt = (n?: number) => (Number(n) || 0).toLocaleString("zh-CN");
type PointsSortKey = "id" | "points" | "total_points";
/**
* 表头吸顶样式:sticky 相对 .admin-canvas 滚动口生效(lg 起外层不再是滚动容器,见下方包装层)。
* 分隔线用 inset 阴影而非 border——border-collapse: collapse 下吸顶 th 的边线会丢失。
*/
const thStickStyle = {
background: "var(--panel)",
boxShadow: "inset 0 -1px 0 var(--line)",
} as const;
/** 表头排序按钮:未激活显示双向箭头,激活显示单向箭头并高亮 */
function SortHeader({
label,
@@ -158,24 +167,40 @@ export default function UserPointsTab() {
/>
</div>
<div className="panel p-0 overflow-hidden">
<div className="overflow-x-auto">
<div className="panel p-0">
{/* lg 起放开横向滚动容器:sticky th 才能相对 admin-canvas 吸顶;窄屏保留横向滚动(不吸顶) */}
<div className="overflow-x-auto lg:overflow-x-visible">
<table className="w-full text-[13px]" style={{ borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--line)" }}>
<th className="px-4 py-2.5 text-left font-medium" style={{ color: "var(--ink-3)" }}>
<tr>
<th
className="sticky top-0 z-10 px-4 py-2.5 text-left font-medium"
style={{ color: "var(--ink-3)", ...thStickStyle }}
>
用户
</th>
<th className="px-4 py-2.5 text-right font-medium" style={{ color: "var(--ink-3)" }}>
<th
className="sticky top-0 z-10 px-4 py-2.5 text-right font-medium"
style={{ color: "var(--ink-3)", ...thStickStyle }}
>
<SortHeader label="余额" active={sort === "points"} dir={dir} onClick={() => toggleSort("points")} />
</th>
<th className="px-4 py-2.5 text-right font-medium" style={{ color: "var(--ink-3)" }}>
<th
className="sticky top-0 z-10 px-4 py-2.5 text-right font-medium"
style={{ color: "var(--ink-3)", ...thStickStyle }}
>
<SortHeader label="累计获得" active={sort === "total_points"} dir={dir} onClick={() => toggleSort("total_points")} />
</th>
<th className="px-4 py-2.5 text-left font-medium" style={{ color: "var(--ink-3)" }}>
<th
className="sticky top-0 z-10 px-4 py-2.5 text-left font-medium"
style={{ color: "var(--ink-3)", ...thStickStyle }}
>
等级
</th>
<th className="px-4 py-2.5 text-right font-medium" style={{ color: "var(--ink-3)" }}>
<th
className="sticky top-0 z-10 px-4 py-2.5 text-right font-medium"
style={{ color: "var(--ink-3)", ...thStickStyle }}
>
操作
</th>
</tr>

View File

@@ -1,6 +1,6 @@
"use client";
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, ShieldOff, Timer } from "lucide-react";
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, ShieldOff, Timer, Lock, Hourglass, Coins, History } from "lucide-react";
import { useState } from "react";
import {
AdminSettingsGroup,
@@ -18,6 +18,59 @@ function InstantNote({ saving }: { saving?: boolean }) {
return <span className="admin-settings-instant">{saving ? "正在保存…" : "修改后立即生效"}</span>;
}
/** 内容锁定 4 键(小时上限 8760 / 积分上限 100,后端裁剪) */
type LockKeys = Pick<
PublicSettings,
"post_edit_lock_hours" | "comment_edit_lock_hours" | "necro_reply_after_hours" | "necro_reply_penalty"
>;
type LockKey = keyof LockKeys;
const LOCK_META: { key: LockKey; icon: React.ReactNode; label: string; hint: string; max: number; unit: string; toast: (v: number) => string }[] = [
{
key: "post_edit_lock_hours",
icon: <Lock size={15} />,
label: "帖子可编辑时长",
hint: "发帖后仅在该时长内可编辑(0=不限制,上限 8760 小时),到期后编辑入口隐藏;管理团队不受限。",
max: 8760,
unit: "小时",
toast: (v) => (v === 0 ? "已关闭帖子编辑时限" : `帖子可编辑时长已设为 ${v} 小时`),
},
{
key: "comment_edit_lock_hours",
icon: <Hourglass size={15} />,
label: "评论可编辑时长",
hint: "评论发布后仅在该时长内可编辑(0=不限制,上限 8760 小时);管理团队不受限。",
max: 8760,
unit: "小时",
toast: (v) => (v === 0 ? "已关闭评论编辑时限" : `评论可编辑时长已设为 ${v} 小时`),
},
{
key: "necro_reply_after_hours",
icon: <History size={15} />,
label: "旧帖回复判定时长",
hint: "回复超过该时长的帖子将提醒作者并按下方分值扣分(0=关闭,上限 8760 小时);管理团队不受限。",
max: 8760,
unit: "小时",
toast: (v) => (v === 0 ? "已关闭旧帖回复提醒" : `旧帖判定时长已设为 ${v} 小时`),
},
{
key: "necro_reply_penalty",
icon: <Coins size={15} />,
label: "旧帖回复扣除积分",
hint: "触发旧帖回复时扣除的积分(0=仅提醒不扣分,上限 100);积分不足将无法回复。",
max: 100,
unit: "分",
toast: (v) => (v === 0 ? "旧帖回复仅提醒不扣分" : `旧帖回复将扣除 ${v} 积分`),
},
];
const pickLock = (s: PublicSettings): LockKeys => ({
post_edit_lock_hours: s.post_edit_lock_hours,
comment_edit_lock_hours: s.comment_edit_lock_hours,
necro_reply_after_hours: s.necro_reply_after_hours,
necro_reply_penalty: s.necro_reply_penalty,
});
/** 内容互动开关 + 访问安全模块 */
export default function AccessSettings({ initial }: { initial: PublicSettings }) {
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
@@ -37,8 +90,12 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
const [togglingTrust, setTogglingTrust] = useState(false);
const [togglingWithoutReview, setTogglingWithoutReview] = useState(false);
const [savingCooldown, setSavingCooldown] = useState(false);
const [lockForm, setLockForm] = useState<LockKeys>(() => pickLock(initial));
const [savedLock, setSavedLock] = useState<LockKeys>(() => pickLock(initial));
const [savingLockKey, setSavingLockKey] = useState<LockKey | null>(null);
const [moduleDirty, setModuleDirty] = useState(false);
const leave = useUnsavedGuard(moduleDirty);
const lockDirty = (Object.keys(savedLock) as LockKey[]).some((k) => lockForm[k] !== savedLock[k]);
const leave = useUnsavedGuard(moduleDirty || lockDirty);
const toggleComments = async () => {
if (togglingComments) return;
@@ -164,6 +221,23 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
}
};
const saveLock = async (key: LockKey) => {
if (savingLockKey) return;
const n = Math.max(0, Math.min(8760, Math.round(Number(lockForm[key])) || 0));
setSavingLockKey(key);
try {
const res = await apiUpdateSiteSettings({ [key]: n });
const saved = pickLock(res);
setLockForm((f) => ({ ...f, [key]: saved[key] }));
setSavedLock((f) => ({ ...f, [key]: saved[key] }));
toast(LOCK_META.find((m) => m.key === key)?.toast(saved[key]) || "已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingLockKey(null);
}
};
return (
<div className="admin-settings-stack">
<AdminSettingsGroup title="内容与互动">
@@ -308,6 +382,31 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
</div>
</AdminSettingsRow>
</AdminSettingsGroup>
<AdminSettingsGroup title="内容锁定与旧帖回复">
{LOCK_META.map((m) => (
<AdminSettingsRow key={m.key} label={<span className="inline-flex items-center gap-1.5">{m.icon} {m.label}</span>} hintBelow={m.hint}>
<div className="flex items-center gap-2 flex-wrap">
<AdminStepper
value={lockForm[m.key]}
onChange={(v) => setLockForm((f) => ({ ...f, [m.key]: v }))}
min={0}
max={m.max}
unit={m.unit}
ariaLabel={`${m.label}${m.unit}`}
disabled={savingLockKey !== null}
/>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingLockKey !== null || lockForm[m.key] === savedLock[m.key]}
onClick={() => void saveLock(m.key)}
>
{savingLockKey === m.key ? "保存中…" : "保存"}
</button>
</div>
</AdminSettingsRow>
))}
</AdminSettingsGroup>
<OperationsPanel name="security" onDirtyChange={setModuleDirty} />
<ConfirmDialog
open={leave.leaveOpen}

View File

@@ -212,7 +212,7 @@ export default function EconomySettings({ initial }: { initial: PublicSettings }
<div className="flex items-center gap-3">
<button
type="button"
className="btn btn-solid btn-sm"
className="btn btn-primary btn-sm"
disabled={saving || !dirty}
onClick={() => void save()}
>

View File

@@ -3335,6 +3335,12 @@ body:has([data-admin-viewport]) {
padding-top: 0;
}
/* 用户积分表头吸顶:同 admin-users-stick,画布顶内边距必须归零 sticky 才能贴紧顶栏
(内边距挪到 .admin-points-users-tab;标记类仅在用户积分标签激活时挂载,不影响其他标签) */
.admin-canvas:has(.admin-points-users-tab) {
padding-top: 0;
}
/* 移动端导航抽屉 */
.admin-drawer {
position: fixed;
@@ -5692,6 +5698,21 @@ a.j13-mention-token:hover {
box-shadow: var(--shadow-sm);
}
/* 用户积分标签:画布顶内边距挪到这里(见未分层区 canvas:has 规则),吸顶 th 才能贴齐 admin-topbar */
.admin-points-users-tab {
padding-top: 1.25rem;
}
@media (min-width: 640px) {
.admin-points-users-tab {
padding-top: 1.5rem;
}
}
@media (min-width: 1024px) {
.admin-points-users-tab {
padding-top: 1.75rem;
}
}
/* 内容管理页:窄屏可整页滚;桌面高度由上方未分层的 canvas:has 规则锁定 */
.admin-content-page {
display: flex;
@@ -8409,6 +8430,78 @@ button.cmt-edited {
text-decoration: underline;
text-underline-offset: 2px;
}
/* 评论编辑历史:git diff 风格修订视图 */
.cmt-diff {
display: flex;
flex-direction: column;
gap: 4px;
}
.cmt-diff-stat {
display: flex;
gap: 8px;
font-size: 11.5px;
font-weight: 600;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.cmt-diff-stat-add {
color: var(--ok);
}
.cmt-diff-stat-del {
color: var(--danger);
}
.cmt-diff-body {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 12.5px;
line-height: 1.55;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 6px;
padding: 4px 0;
}
.cmt-diff-line {
display: flex;
gap: 8px;
padding: 0 10px;
white-space: pre-wrap;
word-break: break-word;
min-height: 1.55em;
}
.cmt-diff-sign {
flex-shrink: 0;
width: 1ch;
user-select: none;
opacity: 0.65;
}
.cmt-diff-text {
min-width: 0;
flex: 1;
}
.cmt-diff-line.is-del {
background: color-mix(in srgb, var(--danger-soft) 60%, transparent);
}
.cmt-diff-line.is-add {
background: color-mix(in srgb, var(--ok-soft) 60%, transparent);
}
.cmt-diff-line.is-ctx {
color: var(--ink-2);
}
.cmt-diff-chg {
border-radius: 3px;
padding: 0 1px;
background: color-mix(in srgb, var(--danger) 24%, transparent);
color: inherit;
}
.cmt-diff-line.is-add .cmt-diff-chg {
background: color-mix(in srgb, var(--ok) 24%, transparent);
}
.cmt-diff-fold {
padding: 2px 10px;
font-size: 11.5px;
text-align: center;
color: var(--ink-2);
background: color-mix(in srgb, var(--ink) 3%, transparent);
}
.cmt-badge {
display: inline-flex;
align-items: center;

View File

@@ -3,7 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award } from "lucide-react";
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle } from "lucide-react";
import { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api";
/**
@@ -76,22 +76,26 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
<div className="panel overflow-hidden p-0">
{notifications.map((n, i) => {
const approval = n.type === "approved" || n.type === "rejected";
// 系统提醒(ActorID=0):不渲染操作者链接,仅展示标题与内容
const system = n.type === "necro_reply";
const iconNode = n.type === "approved"
? <CheckCircle2 size={16} />
: n.type === "rejected" || n.type === "deleted"
? <XCircle size={16} />
: n.type === "like"
? <Heart size={16} />
: n.type === "mention"
? <AtSign size={16} />
: n.type === "pending_review"
? <ClipboardList size={16} />
: n.type === "badge"
? <Award size={16} />
: <MessageCircle size={16} />;
: n.type === "necro_reply"
? <AlertTriangle size={16} />
: n.type === "like"
? <Heart size={16} />
: n.type === "mention"
? <AtSign size={16} />
: n.type === "pending_review"
? <ClipboardList size={16} />
: n.type === "badge"
? <Award size={16} />
: <MessageCircle size={16} />;
const iconColor = n.type === "approved"
? "var(--ok)"
: n.type === "rejected" || n.type === "deleted"
: n.type === "rejected" || n.type === "deleted" || n.type === "necro_reply"
? "var(--danger)"
: n.type === "like"
? "var(--clay)"
@@ -100,7 +104,7 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
: "var(--accent)";
const iconBg = n.type === "approved"
? "var(--ok-soft)"
: n.type === "rejected" || n.type === "deleted"
: n.type === "rejected" || n.type === "deleted" || n.type === "necro_reply"
? "var(--danger-soft)"
: n.type === "like"
? "var(--clay-soft)"
@@ -133,6 +137,10 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
>
{n.type === "approved" ? "审核通过" : "未通过审核"}
</span>
) : system ? (
<span className="font-bold" style={{ color: "var(--danger)" }}>
旧帖回复提醒
</span>
) : (
<>
<Link

View File

@@ -37,6 +37,7 @@ import BadgeChip from "@/components/BadgeChip";
import MarkdownBody from "@/components/MarkdownBody";
import { publicMarkdownForSeo } from "@/lib/hideBlocks";
import PostAttachments from "@/components/PostAttachments";
import PostEditedMark from "@/components/PostEditedMark";
import PostInteractPanels, { QuestionStatusCard } from "@/components/PostInteractPanels";
import AcceptedAnswerCard from "@/components/AcceptedAnswerCard";
import { canModerateBoard } from "@/lib/roles";
@@ -323,8 +324,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
const toc = extractToc(publicBody);
const headingToc = markdownHeadingToc(toc);
const readingMinutes = estimateReadingMinutes(publicBody, post.title.length);
const edited =
new Date(post.updated_at).getTime() - new Date(post.created_at).getTime() > 60_000;
const edited = post.edited ?? false;
// 侧栏布局:post 范围左右两列(toc 需在下方已就绪)
const sidebarLists = sidebarScopeLists(sidebar.config, "post");
@@ -560,6 +560,9 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
pinned={post.pinned}
recommended={post.recommended}
status={post.status}
createdAt={post.created_at}
locked={post.locked}
postEditLockHours={publicSettings.post_edit_lock_hours}
title={post.title}
/>
</div>
@@ -586,6 +589,16 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
<>
{" "}
· 更新于 <time dateTime={post.updated_at}>{formatDateTime(post.updated_at)}</time>
{me.user && (
<>
{" "}·{" "}
<PostEditedMark
postId={post.id}
currentTitle={post.title}
currentContent={post.content || ""}
/>
</>
)}
</>
)}
</p>
@@ -670,6 +683,9 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
page={commentsData.page}
size={commentsData.size}
user={me.user}
postLocked={post.locked}
commentEditLockHours={publicSettings.comment_edit_lock_hours}
necroReply={post.necro_reply}
acceptMode={acceptMode}
acceptedCommentId={
post.question?.accepted_comment_id ||