feat: 评论/帖子编辑历史与编辑标记,内容锁定
- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal - 新增 PostEditedMark 编辑标记展示 - 新增内容锁定机制(content_lock)防止并发编辑冲突 - 审核与通知服务适配
This commit is contained in:
@@ -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…"
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user