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()}
>