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 ||

View File

@@ -1,173 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { Loader2 } from "lucide-react";
import Modal from "@/components/Modal";
import Avatar from "@/components/Avatar";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
import {
apiAdminCommentHistory,
type CommentEditHistoryItem,
} from "@/lib/api";
const PAGE_SIZE = 10;
/**
* 版主可见:评论历次编辑前的旧正文快照(倒序分页)。
* 宽度对齐详情页评论主栏(约 800px+),长文可滚动;底部「加载更多」。
*/
export default function CommentEditHistoryModal({
open,
commentId,
onClose,
}: {
open: boolean;
commentId: number | null;
onClose: () => void;
}) {
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState("");
const [items, setItems] = useState<CommentEditHistoryItem[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const hasMore = items.length < total;
const loadPage = useCallback(
async (nextPage: number, append: boolean) => {
if (commentId == null) return;
if (append) setLoadingMore(true);
else {
setLoading(true);
setError("");
}
try {
const res = await apiAdminCommentHistory(commentId, nextPage, PAGE_SIZE);
const batch = res.items || [];
setError("");
setTotal(res.total ?? 0);
setPage(res.page ?? nextPage);
setItems((prev) => (append ? [...prev, ...batch] : batch));
} catch (e) {
const msg = e instanceof Error ? e.message : "加载失败";
if (append) {
// 追加失败不清空已有列表
setError(msg);
} else {
setError(msg);
setItems([]);
setTotal(0);
}
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[commentId]
);
useEffect(() => {
if (!open || commentId == null) return;
setItems([]);
setPage(1);
setTotal(0);
setError("");
void loadPage(1, false);
}, [open, commentId, loadPage]);
return (
<Modal
open={open}
title="评论编辑历史"
description={total > 0 ? `共 ${total} 条修订记录` : undefined}
onClose={onClose}
// 对齐详情页中间评论栏可用宽度(1440 三栏布局下约 800px)
maxWidthClass="max-w-[min(840px,calc(100vw-2rem))]"
panelClassName="p-5 sm:p-6 max-h-[85vh] flex flex-col min-h-0 overflow-hidden"
bodyClassName="overflow-y-auto min-h-0 flex-1 -mx-1 px-1"
footer={
<button type="button" className="btn btn-line btn-sm" onClick={onClose}>
关闭
</button>
}
>
{loading ? (
<div className="flex items-center justify-center gap-2 py-10 meta text-[13px]">
<Loader2 size={16} className="animate-spin" />
加载中…
</div>
) : error && items.length === 0 ? (
<p className="text-[13.5px] py-6 text-center" style={{ color: "var(--danger)" }}>
{error}
</p>
) : items.length === 0 ? (
<p className="meta text-[13.5px] py-8 text-center">暂无编辑记录</p>
) : (
<div className="flex flex-col gap-4">
<ul className="flex flex-col gap-4">
{items.map((it) => {
const name = it.editor.nickname || it.editor.username || "用户";
return (
<li
key={it.id}
className="rounded-md p-3 sm:p-4"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<div className="flex items-center gap-2 mb-2.5">
<Link href={`/u/${it.editor.id}`} className="shrink-0">
<Avatar name={name} src={it.editor.avatar || undefined} size={28} />
</Link>
<div className="min-w-0 flex-1">
<Link
href={`/u/${it.editor.id}`}
className="text-[13px] hover:text-[var(--accent)]"
style={{ color: "var(--ink)" }}
>
{name}
</Link>
<p className="meta text-[11.5px] tabular-nums">
{new Date(it.created_at).toLocaleString("zh-CN")} · 编辑前正文
</p>
</div>
</div>
<MarkdownBodyClient
content={it.old_content}
className="prose-content prose-comment"
/>
</li>
);
})}
</ul>
{error && items.length > 0 && (
<p className="text-[12.5px] text-center" style={{ color: "var(--danger)" }}>
{error}
</p>
)}
{hasMore && (
<div className="flex justify-center pt-1 pb-1">
<button
type="button"
className="btn btn-line btn-sm"
disabled={loadingMore}
onClick={() => void loadPage(page + 1, true)}
>
{loadingMore ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 size={14} className="animate-spin" />
加载中…
</span>
) : (
`加载更多(已显示 ${items.length}/${total})`
)}
</button>
</div>
)}
</div>
)}
</Modal>
);
}

View File

@@ -1,4 +1,4 @@
"use client";
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
@@ -7,6 +7,7 @@ import {
MessagesSquare,
ChevronRight,
ChevronDown,
Lock,
} from "lucide-react";
import {
apiCreateComment,
@@ -16,13 +17,14 @@ import {
apiUpdateComment,
apiAcceptBounty,
apiAcceptQuestion,
apiCommentHistory,
apiAdminApproveComment,
apiAdminRejectComment,
postDeleteTypeLabel,
type CommentNode,
type User,
} from "@/lib/api";
import { canModerateBoard, canStaffSoftDelete, canPurgeContent } from "@/lib/roles";
import { canModerateBoard, canStaffSoftDelete, canPurgeContent, isStaff } from "@/lib/roles";
import { toast } from "@/lib/toast";
import { formatCommentTime, formatDateTime } from "@/lib/format";
import { useAllowComments, useCommentsRequireLogin } from "@/components/CommentsPolicyProvider";
@@ -36,7 +38,7 @@ import MarkdownBodyClient from "@/components/MarkdownBodyClient";
import MarkdownEditor from "@/components/MarkdownEditor";
import ConfirmDialog from "@/components/ConfirmDialog";
import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal";
import CommentEditHistoryModal from "@/components/CommentEditHistoryModal";
import EditHistoryModal from "@/components/EditHistoryModal";
interface CommentSectionProps {
postId: string;
@@ -56,6 +58,12 @@ interface CommentSectionProps {
acceptedCommentId?: number;
/** 抽奖已开奖:中奖楼层评论 ID(每名中奖者仅其最早主评一条) */
winnerCommentIds?: number[];
/** 帖子是否被管理员手动锁定(普通用户不可回复/编辑评论,staff 豁免) */
postLocked: boolean;
/** 评论可编辑时长(小时);0=不锁定;staff 豁免 */
commentEditLockHours: number;
/** 旧帖回复确认提示(后端按查看者计算;存在时提交回复前需用户确认,确认后发送并扣分) */
necroReply?: { after_hours: number; penalty: number } | null;
}
// 递归统计子树回复数(彻底删除级联提示用)
@@ -63,6 +71,9 @@ function countReplies(node: CommentNode): number {
return node.replies?.reduce((acc, r) => acc + 1 + countReplies(r), 0) ?? 0;
}
/** 评论正文字符上限(与后端 handler/comment.go 校验一致) */
const COMMENT_MAX = 5000;
function findComment(nodes: CommentNode[], id: number): CommentNode | null {
for (const n of nodes) {
if (n.id === id) return n;
@@ -196,11 +207,22 @@ export default function CommentSection({
acceptMode = null,
acceptedCommentId = 0,
winnerCommentIds = [],
postLocked,
commentEditLockHours,
necroReply = null,
}: CommentSectionProps) {
const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0));
const router = useRouter();
const commentsOpen = useAllowComments();
const commentsRequireLogin = useCommentsRequireLogin();
const staffViewer = !!user && isStaff(user.role);
/** 锁帖后普通用户禁止回复与编辑自己的评论,staff 豁免 */
const replyBlocked = postLocked && !staffViewer;
/** 评论是否已超过可编辑时限(staff 豁免;0=不启用) */
const commentTimeLocked = (createdAt: string) =>
!staffViewer &&
commentEditLockHours > 0 &&
Date.now() - new Date(createdAt).getTime() > commentEditLockHours * 3_600_000;
const [content, setContent] = useState("");
// 内联回复目标:同一时间只有一个回复框;草稿按目标评论隔离
const [replyTarget, setReplyTarget] = useState<{
@@ -221,6 +243,8 @@ export default function CommentSection({
const [replyUploading, setReplyUploading] = useState(false);
const [editUploading, setEditUploading] = useState(false);
const [awayHref, setAwayHref] = useState<string | null>(null);
// 旧帖回复确认:记录待确认的提交入口(主楼层 / 内联回复)
const [necroConfirm, setNecroConfirm] = useState<null | "floor" | "reply">(null);
const [discardReplyOpen, setDiscardReplyOpen] = useState(false);
const [discardEditOpen, setDiscardEditOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
@@ -237,6 +261,7 @@ export default function CommentSection({
const [editContent, setEditContent] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
const [historyCommentId, setHistoryCommentId] = useState<number | null>(null);
const [historyCommentContent, setHistoryCommentContent] = useState("");
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
@@ -304,6 +329,11 @@ export default function CommentSection({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// 旧帖回复:确认后才发送并扣分(后端按查看者下发提示,staff/待审用户不会拿到)
if (necroReply) {
setNecroConfirm("floor");
return;
}
void submitFloor();
};
@@ -347,6 +377,11 @@ export default function CommentSection({
const handleSubmitReply = (e: React.FormEvent) => {
e.preventDefault();
// 旧帖回复:确认后才发送并扣分
if (necroReply) {
setNecroConfirm("reply");
return;
}
void submitReply();
};
@@ -472,9 +507,16 @@ export default function CommentSection({
(user.id === comment.user?.id ||
canStaffSoftDelete(user, boardId, comment.user?.role));
const canEdit = (comment: CommentNode) => canSoftDelete(comment);
const canEdit = (comment: CommentNode) => {
if (!canSoftDelete(comment)) return false;
// 作者本人编辑:锁帖与可编辑时限限制(staff 豁免;版主编辑他人不受此限)
if (user?.id === comment.user?.id && (replyBlocked || commentTimeLocked(comment.created_at))) {
return false;
}
return true;
};
const canViewHistory = !!user && canModerateBoard(user, boardId);
const canViewHistory = !!user;
const canPurge = (comment: CommentNode) =>
!!user && canPurgeContent(user.role) && canModerateBoard(user, boardId);
@@ -615,9 +657,10 @@ export default function CommentSection({
density="comment"
value={editContent}
onChange={(v) => {
setEditContent(v);
setEditContent(v.slice(0, COMMENT_MAX));
if (editError) setEditError("");
}}
maxChars={COMMENT_MAX}
disabled={savingEdit}
autoFocus
allowHide={false}
@@ -671,7 +714,14 @@ export default function CommentSection({
if (!c.edited) return null;
if (canViewHistory) {
return (
<button type="button" className="cmt-edited" onClick={() => setHistoryCommentId(c.id)}>
<button
type="button"
className="cmt-edited"
onClick={() => {
setHistoryCommentContent(c.content);
setHistoryCommentId(c.id);
}}
>
已编辑
</button>
);
@@ -693,11 +743,15 @@ export default function CommentSection({
onSelect: () => startEdit(c),
});
}
if (canViewHistory) {
// 未编辑(无修订快照)的评论不展示历史入口,避免打开空记录
if (canViewHistory && c.edited) {
items.push({
key: "history",
label: "编辑历史",
onSelect: () => setHistoryCommentId(c.id),
onSelect: () => {
setHistoryCommentContent(c.content);
setHistoryCommentId(c.id);
},
});
}
if (canRestore(c)) {
@@ -758,9 +812,10 @@ export default function CommentSection({
density="comment"
value={replyContent}
onChange={(v) => {
setReplyContent(v);
setReplyContent(v.slice(0, COMMENT_MAX));
if (replyError) setReplyError("");
}}
maxChars={COMMENT_MAX}
disabled={submitting}
autoFocus
allowHide={false}
@@ -1015,7 +1070,7 @@ export default function CommentSection({
</div>
{renderContentOrEditor(c)}
<div className="cmt-actions">
{commentsOpen && user && editingId !== c.id && (
{commentsOpen && user && !replyBlocked && editingId !== c.id && (
<button
type="button"
onClick={() => openReply(c, floor)}
@@ -1117,7 +1172,7 @@ export default function CommentSection({
</div>
{renderContentOrEditor(c)}
<div className="cmt-actions">
{commentsOpen && user && editingId !== c.id && (
{commentsOpen && user && !replyBlocked && editingId !== c.id && (
<button
type="button"
onClick={() => openReply(c, floor)}
@@ -1214,7 +1269,18 @@ export default function CommentSection({
) : null}
</div>
{user ? (
{replyBlocked ? (
<div
className="mb-8 px-5 py-4 rounded-2xl flex items-center gap-2.5 border"
style={{ borderColor: "var(--line)", background: "transparent" }}
role="status"
>
<Lock size={15} style={{ color: "var(--ink-3)" }} className="shrink-0" />
<span className="text-sm" style={{ color: "var(--ink-2)" }}>
帖子已被锁定,暂时无法回复
</span>
</div>
) : user ? (
<form onSubmit={handleSubmit} className="mb-6">
<div className="cmt-md-editor cmt-md-editor-floor scroll-mt-28">
<MarkdownEditor
@@ -1222,9 +1288,10 @@ export default function CommentSection({
density="comment"
value={content}
onChange={(v) => {
setContent(v);
setContent(v.slice(0, COMMENT_MAX));
if (floorError) setFloorError("");
}}
maxChars={COMMENT_MAX}
disabled={submitting}
allowHide={false}
ariaLabel="发表评论"
@@ -1355,9 +1422,29 @@ export default function CommentSection({
onCancel={() => setDiscardEditOpen(false)}
/>
<CommentEditHistoryModal
{/* 旧帖回复确认:确认后才发送并扣分(提示由后端按查看者下发) */}
<ConfirmDialog
open={necroConfirm !== null}
title="回复旧帖提醒"
message={`该帖已超过 ${necroReply?.after_hours ?? 0} 小时没有新回复,回复将扣除 ${necroReply?.penalty ?? 0} 积分。确定继续吗?`}
confirmLabel="继续回复"
onConfirm={() => {
const kind = necroConfirm;
setNecroConfirm(null);
if (kind === "floor") void submitFloor();
else if (kind === "reply") void submitReply();
}}
onCancel={() => setNecroConfirm(null)}
/>
<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)}
/>
</section>

View File

@@ -0,0 +1,433 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Loader2 } from "lucide-react";
import Modal from "@/components/Modal";
import Avatar from "@/components/Avatar";
const PAGE_SIZE = 10;
export interface EditHistoryItem {
id: number;
editor: {
id: number;
username: string;
nickname: string;
avatar: string;
};
old_title?: string; // 帖子场景有;评论场景无
old_content: string;
created_at: string;
}
export type FetchHistoryPage = (
page: number,
size: number
) => Promise<{
items: EditHistoryItem[];
total: number;
page: number;
size: number;
}>;
/* ---------------- 行级 diff(LCS),git diff 风格渲染 ---------------- */
type DiffOp = "del" | "add" | "ctx";
interface DiffLine {
op: DiffOp;
text: string;
}
interface Seg {
chg: boolean;
text: string;
}
type Row =
| { kind: "line"; op: DiffOp; text: string; pair: string | null }
| { kind: "fold"; count: number };
// 超大文本退化为整段替换,避免 O(n·m) 内存膨胀
const MAX_DIFF_CELLS = 400_000;
// 单行超过该长度不做行内变更高亮
const MAX_INLINE_DIFF_LEN = 2000;
// 相同内容行超过该数量时折叠中段
const CONTEXT_FOLD_LIMIT = 8;
function splitLines(text: string): string[] {
if (!text) return [];
return text.replace(/\r\n/g, "\n").split("\n");
}
function diffLines(oldText: string, newText: string): DiffLine[] {
const a = splitLines(oldText);
const b = splitLines(newText);
const n = a.length;
const m = b.length;
if (n * m > MAX_DIFF_CELLS) {
return [
...a.map((text) => ({ op: "del" as const, text })),
...b.map((text) => ({ op: "add" as const, text })),
];
}
const width = m + 1;
const dp = new Uint32Array((n + 1) * width);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i * width + j] =
a[i] === b[j]
? dp[(i + 1) * width + (j + 1)] + 1
: Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)]);
}
}
const out: DiffLine[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
out.push({ op: "ctx", text: a[i] });
i += 1;
j += 1;
} else if (dp[(i + 1) * width + j] >= dp[i * width + (j + 1)]) {
out.push({ op: "del", text: a[i] });
i += 1;
} else {
out.push({ op: "add", text: b[j] });
j += 1;
}
}
while (i < n) {
out.push({ op: "del", text: a[i] });
i += 1;
}
while (j < m) {
out.push({ op: "add", text: b[j] });
j += 1;
}
return normalizeBlocks(out);
}
// 每个连续变更块内统一「先删后增」,与 git diff 呈现一致
function normalizeBlocks(lines: DiffLine[]): DiffLine[] {
const out: DiffLine[] = [];
let k = 0;
while (k < lines.length) {
if (lines[k].op === "ctx") {
out.push(lines[k]);
k += 1;
continue;
}
let end = k;
while (end < lines.length && lines[end].op !== "ctx") end += 1;
for (let t = k; t < end; t++) if (lines[t].op === "del") out.push(lines[t]);
for (let t = k; t < end; t++) if (lines[t].op === "add") out.push(lines[t]);
k = end;
}
return out;
}
// 行内变更高亮:取公共前/后缀,中间差异段标记 chg
function segmentLine(line: string, other: string | null): Seg[] {
if (
!other ||
line === other ||
line.length + other.length > MAX_INLINE_DIFF_LEN * 2
) {
return [{ chg: false, text: line }];
}
const min = Math.min(line.length, other.length);
let p = 0;
while (p < min && line[p] === other[p]) p += 1;
let s = 0;
while (
s < min - p &&
line[line.length - 1 - s] === other[other.length - 1 - s]
) {
s += 1;
}
const segs: Seg[] = [];
if (p > 0) segs.push({ chg: false, text: line.slice(0, p) });
const mid = line.slice(p, line.length - s);
if (mid) segs.push({ chg: true, text: mid });
if (s > 0) segs.push({ chg: false, text: line.slice(line.length - s) });
return segs.length > 0 ? segs : [{ chg: false, text: line }];
}
// 折叠长段相同内容;同块内删除/新增按序号配对做行内高亮
function buildRows(lines: DiffLine[]): Row[] {
const rows: Row[] = [];
let k = 0;
while (k < lines.length) {
if (lines[k].op === "ctx") {
let end = k;
while (end < lines.length && lines[end].op === "ctx") end += 1;
const run = end - k;
if (run > CONTEXT_FOLD_LIMIT) {
for (let t = k; t < k + 3; t++) {
rows.push({ kind: "line", op: lines[t].op, text: lines[t].text, pair: null });
}
rows.push({ kind: "fold", count: run - 6 });
for (let t = end - 3; t < end; t++) {
rows.push({ kind: "line", op: lines[t].op, text: lines[t].text, pair: null });
}
} else {
for (let t = k; t < end; t++) {
rows.push({ kind: "line", op: lines[t].op, text: lines[t].text, pair: null });
}
}
k = end;
} else {
let end = k;
while (end < lines.length && lines[end].op !== "ctx") end += 1;
const dels = lines.slice(k, end).filter((l) => l.op === "del");
const adds = lines.slice(k, end).filter((l) => l.op === "add");
const pairFor = new Map<DiffLine, string | null>();
const cnt = Math.min(dels.length, adds.length);
for (let t = 0; t < cnt; t++) {
pairFor.set(dels[t], adds[t].text);
pairFor.set(adds[t], dels[t].text);
}
for (let t = k; t < end; t++) {
rows.push({
kind: "line",
op: lines[t].op,
text: lines[t].text,
pair: pairFor.get(lines[t]) ?? null,
});
}
k = end;
}
}
return rows;
}
function DiffBody({ before, after }: { before: string; after: string }) {
const rows = useMemo(() => buildRows(diffLines(before, after)), [before, after]);
const delCount = rows.filter((r) => r.kind === "line" && r.op === "del").length;
const addCount = rows.filter((r) => r.kind === "line" && r.op === "add").length;
return (
<div className="cmt-diff">
<div className="cmt-diff-stat">
<span className="cmt-diff-stat-add">+{addCount}</span>
<span className="cmt-diff-stat-del">−{delCount}</span>
</div>
<div className="cmt-diff-body">
{rows.map((r, idx) =>
r.kind === "fold" ? (
<div key={idx} className="cmt-diff-fold">
⋯ 已省略 {r.count} 行相同内容 ⋯
</div>
) : (
<div key={idx} className={`cmt-diff-line is-${r.op}`}>
<span className="cmt-diff-sign" aria-hidden>
{r.op === "del" ? "-" : r.op === "add" ? "+" : " "}
</span>
<span className="cmt-diff-text">
{segmentLine(r.text, r.pair).map((s, si) =>
s.chg ? (
<mark key={si} className="cmt-diff-chg">
{s.text}
</mark>
) : (
<span key={si}>{s.text}</span>
)
)}
</span>
</div>
)
)}
</div>
</div>
);
}
/* ---------------- 弹窗 ---------------- */
/**
* 登录可见:历次编辑的 git diff 风格修订视图(倒序分页)。
* 后端只存每次编辑前的旧快照:第 i 条的「改后内容」= 第 i-1 条(更新的一条)的快照,
* 最新一条的「改后内容」= 当前正文(由调用方传入)。帖子场景另传 currentTitle 渲染标题 diff。
*/
export default function EditHistoryModal({
open,
title,
currentTitle,
currentContent,
fetchPage,
onClose,
}: {
open: boolean;
title: string;
currentTitle?: string;
currentContent: string;
fetchPage: FetchHistoryPage;
onClose: () => void;
}) {
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState("");
const [items, setItems] = useState<EditHistoryItem[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const hasMore = items.length < total;
const loadPage = useCallback(
async (nextPage: number, append: boolean) => {
if (!open) return;
if (append) setLoadingMore(true);
else {
setLoading(true);
setError("");
}
try {
const res = await fetchPage(nextPage, PAGE_SIZE);
const batch = res.items || [];
setError("");
setTotal(res.total ?? 0);
setPage(res.page ?? nextPage);
setItems((prev) => (append ? [...prev, ...batch] : batch));
} catch (e) {
const msg = e instanceof Error ? e.message : "加载失败";
if (append) {
// 追加失败不清空已有列表
setError(msg);
} else {
setError(msg);
setItems([]);
setTotal(0);
}
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[open, fetchPage]
);
useEffect(() => {
if (!open) return;
setItems([]);
setPage(1);
setTotal(0);
setError("");
void loadPage(1, false);
}, [open, loadPage]);
const hasTitle = typeof currentTitle === "string";
return (
<Modal
open={open}
title={title}
description={total > 0 ? `共 ${total} 条修订记录 · - 移除内容 / + 新增内容` : undefined}
onClose={onClose}
// 对齐详情页中间评论栏可用宽度(1440 三栏布局下约 800px)
maxWidthClass="max-w-[min(840px,calc(100vw-2rem))]"
panelClassName="p-5 sm:p-6 max-h-[85vh] flex flex-col min-h-0 overflow-hidden"
bodyClassName="overflow-y-auto min-h-0 flex-1 -mx-1 px-1"
footer={
<button type="button" className="btn btn-line btn-sm" onClick={onClose}>
关闭
</button>
}
>
{loading ? (
<div className="flex items-center justify-center gap-2 py-10 meta text-[13px]">
<Loader2 size={16} className="animate-spin" />
加载中…
</div>
) : error && items.length === 0 ? (
<p className="text-[13.5px] py-6 text-center" style={{ color: "var(--danger)" }}>
{error}
</p>
) : items.length === 0 ? (
<p className="meta text-[13.5px] py-8 text-center">暂无编辑记录</p>
) : (
<div className="flex flex-col gap-4">
<ul className="flex flex-col gap-4">
{items.map((it, idx) => {
const name = it.editor.nickname || it.editor.username || "用户";
// 本次编辑的改后内容:上一条(更新的)快照;最新一条为当前正文
const afterContent = idx === 0 ? currentContent : items[idx - 1].old_content;
const afterTitle = hasTitle
? idx === 0
? (currentTitle ?? "")
: (items[idx - 1].old_title ?? "")
: undefined;
const titleChanged = hasTitle && it.old_title !== afterTitle;
const contentChanged = it.old_content !== afterContent;
return (
<li
key={it.id}
className="rounded-md p-3 sm:p-4"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<div className="flex items-center gap-2 mb-2.5">
<Link href={`/u/${it.editor.id}`} className="shrink-0">
<Avatar name={name} src={it.editor.avatar || undefined} size={28} />
</Link>
<div className="min-w-0 flex-1">
<Link
href={`/u/${it.editor.id}`}
className="text-[13px] hover:text-[var(--accent)]"
style={{ color: "var(--ink)" }}
>
{name}
</Link>
<p className="meta text-[11.5px] tabular-nums">
{new Date(it.created_at).toLocaleString("zh-CN")} · 第{" "}
{total - idx} 次编辑
</p>
</div>
</div>
{titleChanged && (
<div className="mb-3">
<p className="meta text-[11.5px] mb-1">标题</p>
<DiffBody before={it.old_title ?? ""} after={afterTitle ?? ""} />
</div>
)}
{contentChanged ? (
<DiffBody before={it.old_content} after={afterContent} />
) : (
!titleChanged && (
<p className="meta text-[12.5px]">本次仅变更了标签或其他元数据,未修改标题/正文。</p>
)
)}
</li>
);
})}
</ul>
{error && items.length > 0 && (
<p className="text-[12.5px] text-center" style={{ color: "var(--danger)" }}>
{error}
</p>
)}
{hasMore && (
<div className="flex justify-center pt-1 pb-1">
<button
type="button"
className="btn btn-line btn-sm"
disabled={loadingMore}
onClick={() => void loadPage(page + 1, true)}
>
{loadingMore ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 size={14} className="animate-spin" />
加载中…
</span>
) : (
`加载更多(已显示 ${items.length}/${total})`
)}
</button>
</div>
)}
</div>
)}
</Modal>
);
}

View File

@@ -71,6 +71,8 @@ type MarkdownEditorProps = {
shortcutName?: string;
/** 底栏右侧操作(发表 / 保存),纳入编辑器边框内 */
footer?: ReactNode;
/** 正文字符数上限;设置后计数器显示「已输入 / 上限」并限制输入 */
maxChars?: number;
/** 提交失败等原因,显示在编辑器内 */
error?: string;
onUploadingChange?: (uploading: boolean) => void;
@@ -143,6 +145,7 @@ export default function MarkdownEditor({
hint,
shortcutName,
footer,
maxChars,
error,
onUploadingChange,
}: MarkdownEditorProps) {
@@ -1247,6 +1250,7 @@ export default function MarkdownEditor({
value={value}
disabled={disabled}
rows={isComment ? 3 : rows}
maxLength={maxChars}
placeholder={placeholder}
aria-label={ariaLabel || placeholder || "正文"}
className="j13-md-textarea"
@@ -1278,7 +1282,9 @@ export default function MarkdownEditor({
: "支持粘贴/拖拽图片 · Markdown · Ctrl/⌘ + Enter 提交")}
</span>
<span className="j13-md-foot-actions">
<span className="j13-md-count tabular-nums">{value.length} 字</span>
<span className="j13-md-count tabular-nums">
{maxChars != null ? `${value.length} / ${maxChars}` : `${value.length} 字`}
</span>
{footer}
</span>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, type PointerEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
export type ModalProps = {
@@ -102,7 +103,7 @@ export default function Modal({
overlayPressedRef.current = false;
};
return (
return createPortal(
<div
className={`fixed inset-0 ${zIndexClass} flex items-center justify-center p-4`}
style={{ background: "rgba(0,0,0,0.55)" }}
@@ -150,7 +151,8 @@ export default function Modal({
<div className="flex items-center justify-end gap-2 mt-5">{footer}</div>
) : null}
</div>
</div>
</div>,
document.body
);
}

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Bell, CheckCheck, Heart, MessageCircle, CheckCircle2, XCircle, AtSign, ClipboardList, Award } from "lucide-react";
import { Bell, CheckCheck, Heart, MessageCircle, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle } from "lucide-react";
import {
apiFetchNotifications,
apiUnreadCount,
@@ -137,17 +137,22 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
if (n.type === "pending_review") return "有新内容待审核";
if (n.type === "deleted") return "删除了你的帖子";
if (n.type === "badge") return "向你颁布了徽章";
if (n.type === "necro_reply") return "旧帖回复提醒";
return "点赞了你的帖子";
};
// 审核结果类通知:无互动对象语义,使用专用图标与语义色
const isApproval = (n: NotificationItem) =>
n.type === "approved" || n.type === "rejected" || n.type === "deleted";
// 系统提醒(ActorID=0):同样不渲染操作者链接
const isSystemNotice = (n: NotificationItem) => n.type === "necro_reply";
const iconStyle = (n: NotificationItem): { color: string; bg: string; node: React.ReactNode } => {
if (n.type === "approved")
return { color: "var(--ok)", bg: "var(--ok-soft)", node: <CheckCircle2 size={15} /> };
if (n.type === "rejected" || n.type === "deleted")
return { color: "var(--danger)", bg: "var(--danger-soft)", node: <XCircle size={15} /> };
if (n.type === "necro_reply")
return { color: "var(--danger)", bg: "var(--danger-soft)", node: <AlertTriangle size={15} /> };
if (n.type === "like")
return { color: "var(--clay)", bg: "var(--clay-soft)", node: <Heart size={15} /> };
if (n.type === "mention")
@@ -223,7 +228,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
) : (
notifications.map((n) => {
const ic = iconStyle(n);
const approval = isApproval(n);
const approval = isApproval(n) || isSystemNotice(n);
return (
<button
key={n.id}

View File

@@ -3,9 +3,9 @@
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check, BadgeCheck } from "lucide-react";
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, apiAdminApprovePost, type User } from "@/lib/api";
import { canStaffSoftDelete, canModerateBoard, isAdminOrAbove } from "@/lib/roles";
import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check, BadgeCheck, Lock, LockOpen } from "lucide-react";
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, apiTogglePostLock, apiAdminApprovePost, type User } from "@/lib/api";
import { canStaffSoftDelete, canModerateBoard, isAdminOrAbove, isStaff } from "@/lib/roles";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
@@ -19,6 +19,12 @@ interface PostActionsProps {
recommended: boolean;
/** 帖子审核状态:pending 时对可审者显示「通过评审」 */
status: string;
/** 帖子创建时间:自动编辑锁计时基准(staff 豁免) */
createdAt: string;
/** 帖子是否被管理员手动锁定(staff 豁免) */
locked: boolean;
/** 帖子可编辑时长(小时);0=不锁定;staff 豁免 */
postEditLockHours: number;
/** 供删除确认展示 */
title?: string;
}
@@ -31,6 +37,9 @@ export default function PostActions({
pinned,
recommended,
status,
createdAt,
locked,
postEditLockHours,
title,
}: PostActionsProps) {
const router = useRouter();
@@ -39,6 +48,7 @@ export default function PostActions({
const [deleting, setDeleting] = useState(false);
const [isPinned, setIsPinned] = useState(pinned > 0);
const [isRecommended, setIsRecommended] = useState(recommended);
const [isLocked, setIsLocked] = useState(locked);
const [acting, setActing] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [staffDeleteOpen, setStaffDeleteOpen] = useState(false);
@@ -70,6 +80,13 @@ export default function PostActions({
!!user &&
(user.id === authorId || canStaffSoftDelete(user, boardId, authorRole));
const isAdmin = !!user && isAdminOrAbove(user.role);
const staffUser = !!user && isStaff(user.role);
// 编辑入口:手动锁帖或超过可编辑时限时对普通用户隐藏,staff 豁免
const editTimeLocked =
!staffUser &&
postEditLockHours > 0 &&
Date.now() - new Date(createdAt).getTime() > postEditLockHours * 3_600_000;
const showEdit = canManage && !(isLocked && !staffUser) && !editTimeLocked;
const canApprove =
!!user && status === "pending" && canModerateBoard(user, boardId);
// staff 删他人帖:走类型+理由;作者自删:简单确认
@@ -139,6 +156,21 @@ export default function PostActions({
}
};
const handleLock = async () => {
setActing(true);
setOpen(false);
try {
const res = await apiTogglePostLock(postId);
setIsLocked(res.locked);
toast(res.locked ? "已锁定,普通用户不可编辑与回复" : "已解除锁定", "ok");
router.refresh();
} catch {
toast("操作失败,请稍后重试");
} finally {
setActing(false);
}
};
if (!canManage) return null;
const itemCls =
@@ -212,18 +244,35 @@ export default function PostActions({
<span className="flex-1 text-left">{isRecommended ? "取消推荐" : "推荐"}</span>
{isRecommended && <Check size={14} className="shrink-0" />}
</button>
<button
type="button"
role="menuitem"
onClick={handleLock}
className={itemCls}
style={isLocked ? { color: "var(--danger)" } : { color: "var(--ink)" }}
>
{isLocked ? (
<LockOpen size={14} className="shrink-0" />
) : (
<Lock size={14} className="shrink-0" />
)}
<span className="flex-1 text-left">{isLocked ? "解除锁定" : "锁定"}</span>
{isLocked && <Check size={14} className="shrink-0" />}
</button>
<div style={{ height: 1, background: "var(--line)" }} />
</>
)}
<Link
href={`/post/${postId}/edit`}
role="menuitem"
className={`${itemCls} items-center`}
style={{ color: "var(--ink)" }}
>
<Pencil size={14} className="shrink-0" />
<span className="flex-1 text-left">编辑帖子</span>
</Link>
{showEdit && (
<Link
href={`/post/${postId}/edit`}
role="menuitem"
className={`${itemCls} items-center`}
style={{ color: "var(--ink)" }}
>
<Pencil size={14} className="shrink-0" />
<span className="flex-1 text-left">编辑帖子</span>
</Link>
)}
<button
type="button"
role="menuitem"

View File

@@ -37,6 +37,8 @@ import Modal from "@/components/Modal";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
const TITLE_MAX = 256;
/** 帖子正文字符上限(与后端 handler/post.go 校验一致) */
const CONTENT_MAX = 20000;
const DRAFT_KEY = "j13-compose-draft";
const DRAFT_DEBOUNCE_MS = 600;
/** 与 globals.css 工作台分栏断点一致 */
@@ -1640,7 +1642,8 @@ export default function PostComposer(props: PostComposerProps) {
<MarkdownEditor
id="compose-content"
value={content}
onChange={setContent}
onChange={(v) => setContent(v.slice(0, CONTENT_MAX))}
maxChars={CONTENT_MAX}
allowHide
disabled={loading}
rows={18}

View File

@@ -0,0 +1,37 @@
"use client";
import { useState } from "react";
import EditHistoryModal from "@/components/EditHistoryModal";
import { apiPostHistory } from "@/lib/api";
/** 帖子详情「更新于」旁的编辑历史入口(登录用户可见) */
export default function PostEditedMark({
postId,
currentTitle,
currentContent,
}: {
postId: number;
currentTitle: string;
currentContent: string;
}) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
className="cmt-edited"
onClick={() => setOpen(true)}
>
编辑历史
</button>
<EditHistoryModal
open={open}
title="帖子编辑历史"
currentTitle={currentTitle}
currentContent={currentContent}
fetchPage={(p, s) => apiPostHistory(postId, p, s)}
onClose={() => setOpen(false)}
/>
</>
);
}

View File

@@ -381,11 +381,19 @@ export interface Post {
type_status?: string;
pinned: number;
recommended: boolean;
/** 管理员手动锁定:普通用户不可编辑/回复(staff 豁免) */
locked: boolean;
/** 最后一条已发布评论时间;null=无回复(旧帖判定回落 created_at) */
last_reply_at?: string | null;
/** 旧帖回复确认提示(详情接口按查看者计算;存在时提交回复前需用户确认) */
necro_reply?: { after_hours: number; penalty: number } | null;
status: string;
like_count: number;
view_count: number;
comment_count: number;
liked: boolean;
/** 详情接口返回:是否存在编辑历史快照(列表接口无此字段) */
edited?: boolean;
created_at: string;
updated_at: string;
board: Board;
@@ -608,7 +616,7 @@ export interface NotificationItem {
id: number;
user_id: number;
actor_id: number;
type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted" | "badge";
type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted" | "badge" | "necro_reply";
post_id: number;
comment_id: number;
room_id?: number;
@@ -918,6 +926,14 @@ export interface PublicSettings {
points_reply_daily_cap: number;
/** 帖子被推荐奖励(每帖仅一次);0=关闭;缺省 0 */
points_recommend_reward: number;
/** 帖子可编辑时长(小时);0=不锁定;缺省 0;上限 8760 */
post_edit_lock_hours: number;
/** 评论可编辑时长(小时);0=不锁定;缺省 0;上限 8760 */
comment_edit_lock_hours: number;
/** 旧帖回复判定时长(小时);0=关闭;缺省 0 */
necro_reply_after_hours: number;
/** 旧帖回复扣除积分;0=仅提醒不扣分;缺省 0;上限 100 */
necro_reply_penalty: number;
/** 用户等级体系(数量/每级阈值/每级色板 key);等级数值由后端计算 */
levels: LevelDef[];
/** 等级特效动画总开关(流光动画);缺省开 */
@@ -980,6 +996,10 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
points_reply_reward: 0,
points_reply_daily_cap: 0,
points_recommend_reward: 0,
post_edit_lock_hours: 0,
comment_edit_lock_hours: 0,
necro_reply_after_hours: 0,
necro_reply_penalty: 0,
levels: [...DEFAULT_LEVEL_DEFS],
levels_fx: true,
};
@@ -1068,11 +1088,40 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
bg_admin_url: typeof data?.bg_admin_url === "string" ? data.bg_admin_url : "",
bg_admin_mode: typeof data?.bg_admin_mode === "string" ? data.bg_admin_mode : "cover",
...normalizePointsSettings(data),
...normalizeLockSettings(data),
levels: normalizeLevels(data?.levels),
levels_fx: data?.levels_fx !== false,
};
}
/** 内容锁定 4 键:小时 0–8760 / 积分 0–100 有效整数,越界/异常回落默认值 */
function normalizeLockSettings(
data: Partial<PublicSettings> | null | undefined,
): Pick<
PublicSettings,
| "post_edit_lock_hours"
| "comment_edit_lock_hours"
| "necro_reply_after_hours"
| "necro_reply_penalty"
> {
const hours = (key: keyof PublicSettings): number | undefined => {
const v = data?.[key];
return typeof v === "number" && v >= 0 && v <= 8760 ? Math.round(v) : undefined;
};
const points = (key: keyof PublicSettings): number | undefined => {
const v = data?.[key];
return typeof v === "number" && v >= 0 && v <= 100 ? Math.round(v) : undefined;
};
return {
post_edit_lock_hours: hours("post_edit_lock_hours") ?? DEFAULT_PUBLIC_SETTINGS.post_edit_lock_hours,
comment_edit_lock_hours:
hours("comment_edit_lock_hours") ?? DEFAULT_PUBLIC_SETTINGS.comment_edit_lock_hours,
necro_reply_after_hours:
hours("necro_reply_after_hours") ?? DEFAULT_PUBLIC_SETTINGS.necro_reply_after_hours,
necro_reply_penalty: points("necro_reply_penalty") ?? DEFAULT_PUBLIC_SETTINGS.necro_reply_penalty,
};
}
/** 积分规则 8 键:0–100 有效整数,越界/异常回落默认值 */
function normalizePointsSettings(
data: Partial<PublicSettings> | null | undefined,
@@ -1977,6 +2026,15 @@ export async function apiToggleRecommend(postId: string): Promise<{ recommended:
return res.json();
}
/** 切换帖子手动锁定(管理员及以上);锁定后普通用户不可编辑/回复 */
export async function apiTogglePostLock(postId: string): Promise<{ locked: boolean }> {
const res = await fetchWithRefresh(`/api/posts/${postId}/lock`, {
method: "PUT",
headers: clientHeaders(),
});
return res.json();
}
export async function apiChangePassword(oldPassword: string, newPassword: string) {
const res = await fetchWithRefresh("/api/change-password", {
method: "POST",
@@ -3092,6 +3150,10 @@ export type UpdateSiteSettingsBody = {
points_reply_reward?: number;
points_reply_daily_cap?: number;
points_recommend_reward?: number;
post_edit_lock_hours?: number;
comment_edit_lock_hours?: number;
necro_reply_after_hours?: number;
necro_reply_penalty?: number;
/** 用户等级体系(数量/每级阈值/每级色板 key);整体替换保存 */
levels?: LevelDef[];
levels_fx?: boolean;
@@ -3264,6 +3326,8 @@ export interface AdminContentComment {
board_id: number;
content: string;
status: string;
/** 相对创建已编辑(服务端判定);无编辑时隐藏历史入口 */
edited: boolean;
deleted: boolean;
deleted_at?: string;
created_at: string;
@@ -3352,15 +3416,44 @@ export interface CommentEditHistoryItem {
created_at: string;
}
/** 管理:评论编辑历史(分页;默认每页 10 条) */
export async function apiAdminCommentHistory(
/** 评论编辑历史(登录可见;分页;默认每页 10 条) */
export async function apiCommentHistory(
commentId: number,
page = 1,
size = 10
): Promise<{ items: CommentEditHistoryItem[]; total: number; page: number; size: number }> {
const params = new URLSearchParams({ page: String(page), size: String(size) });
const res = await fetchWithRefresh(
`/api/admin/content/comments/${commentId}/history?${params}`,
`/api/comments/${commentId}/history?${params}`,
{ headers: clientHeaders() }
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取编辑历史失败");
return data;
}
export interface PostEditHistoryItem {
id: number;
editor: {
id: number;
username: string;
nickname: string;
avatar: string;
};
old_title: string;
old_content: string;
created_at: string;
}
/** 帖子编辑历史(登录可见;分页;默认每页 10 条) */
export async function apiPostHistory(
postId: number,
page = 1,
size = 10
): Promise<{ items: PostEditHistoryItem[]; total: number; page: number; size: number }> {
const params = new URLSearchParams({ page: String(page), size: String(size) });
const res = await fetchWithRefresh(
`/api/posts/${postId}/history?${params}`,
{ headers: clientHeaders() }
);
const data = await res.json().catch(() => ({}));

View File

@@ -0,0 +1,214 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { smartDiff, toFoldedRows } from "./smartDiff.ts";
/** 把结果压成便于断言的短格式:每行 "op:text" */
function flat(text: string, other: string): string[] {
const r = smartDiff(text, other);
return r.blocks.flatMap((b) => b.lines.map((l) => `${l.op}:${l.text}`));
}
describe("smartDiff", () => {
it("场景1:整段中文说明 → 整段 R 代码,判定 block-replace 而非碎片对齐", () => {
// 两段各留一个空行作为「诱饵公共行」——朴素 LCS 会围绕它做对齐
const oldText = [
"本函数用于处理用户上传的数据。",
"",
"首先校验输入的合法性,",
"然后将数据逐行写入临时表,",
"最后清理缓存并返回结果。",
].join("\n");
const newText = [
"process_data <- function(df) {",
"",
" stopifnot(!is.null(df))",
" out <- transform(df, total = sum(x))",
' write.csv(out, "out.csv")',
" invisible(out)",
"}",
].join("\n");
const r = smartDiff(oldText, newText);
assert.equal(r.blocks.length, 1);
assert.equal(r.blocks[0].type, "block-replace");
// 大删除块在前、大新增块在后,不出现 -/+ 交替碎片
const ops = r.blocks[0].lines.map((l) => l.op);
assert.ok(ops.slice(0, 5).every((o) => o === "del"));
assert.ok(ops.slice(5).every((o) => o === "ins"));
assert.equal(r.stats.dels, 5);
assert.equal(r.stats.adds, 7);
// 整段替换不做行内细分
assert.ok(r.blocks[0].lines.every((l) => l.segments === undefined));
});
it("场景2:单行内改一个词,输出词级 segments 而非整行标红标绿", () => {
const oldText = "const total = countItems(list);";
const newText = "const total = countUsers(list);";
const r = smartDiff(oldText, newText);
assert.equal(r.blocks.length, 1);
assert.equal(r.blocks[0].type, "replace");
const del = r.blocks[0].lines[0];
const ins = r.blocks[0].lines[1];
assert.ok(del.segments && del.segments.length > 0, "del 行应有 segments");
assert.ok(ins.segments && ins.segments.length > 0, "ins 行应有 segments");
// 删除行上被标记的正是 countItems,新增行上是 countUsers
const delMarked = del.segments!.map((s) => del.text.slice(s.start, s.end)).join("");
const insMarked = ins.segments!.map((s) => ins.text.slice(s.start, s.end)).join("");
assert.equal(delMarked, "countItems");
assert.equal(insMarked, "countUsers");
// 片段不应包含未变化的部分
for (const s of del.segments!) {
assert.ok(!del.text.slice(s.start, s.end).includes("const"));
}
});
it("场景3:仅行尾空白变化,不产生假改动", () => {
const oldText = "第一行 \n第二行\t";
const newText = "第一行\n第二行";
const r = smartDiff(oldText, newText);
assert.equal(r.blocks.length, 1);
assert.equal(r.blocks[0].type, "equal");
assert.deepEqual(r.stats, { adds: 0, dels: 0 });
// 展示保留原文(含行尾空白),但比较视为相同
assert.equal(r.blocks[0].lines[0].text, "第一行 ");
});
it("场景4:大量重复空行/花括号的代码,对齐不错位、变更块不碎", () => {
const oldText = [
"function a() {",
"",
" if (x) {",
"",
" return 1;",
"",
" }",
"",
"}",
].join("\n");
const newText = [
"function a() {",
"",
" if (y) {",
"",
" return 2;",
"",
" }",
"",
"}",
].join("\n");
const r = smartDiff(oldText, newText);
// 期望:equal → replace(if 行) → equal → replace(return 行) → equal
assert.deepEqual(
r.blocks.map((b) => b.type),
["equal", "replace", "equal", "replace", "equal"]
);
// 两个 replace 块均有行内细分,且只标记真正变化的字符
const [b1, b2] = [r.blocks[1], r.blocks[3]];
assert.equal(b1.lines[0].text, " if (x) {");
assert.equal(
b1.lines[0].segments!.map((s) => b1.lines[0].text.slice(s.start, s.end)).join(""),
"x"
);
assert.equal(
b1.lines[1].segments!.map((s) => b1.lines[1].text.slice(s.start, s.end)).join(""),
"y"
);
assert.equal(
b2.lines[0].segments!.map((s) => b2.lines[0].text.slice(s.start, s.end)).join(""),
"1"
);
// 空行不进入任何变更块
assert.ok(r.blocks.every((b) => b.type === "equal" || !b.lines.some((l) => l.text === "")));
});
it("场景5:完全相同的文本,只有一个 equal 块", () => {
const text = "第一行\n第二行\n第三行";
const r = smartDiff(text, text);
assert.equal(r.blocks.length, 1);
assert.equal(r.blocks[0].type, "equal");
assert.equal(r.blocks[0].lines.length, 3);
assert.deepEqual(r.stats, { adds: 0, dels: 0 });
});
it("CRLF / CR 行尾符差异不影响比较", () => {
const r = smartDiff("a\r\nb\rc", "a\nb\nc");
assert.equal(r.blocks.length, 1);
assert.equal(r.blocks[0].type, "equal");
assert.deepEqual(r.stats, { adds: 0, dels: 0 });
});
it("单侧为空:整体删除 / 整体新增", () => {
const ins = smartDiff("", "新的一行\n再来一行");
assert.deepEqual(ins.blocks.map((b) => b.type), ["insert"]);
assert.equal(ins.stats.adds, 2);
const del = smartDiff("旧行一\n旧行二", "");
assert.deepEqual(del.blocks.map((b) => b.type), ["delete"]);
assert.equal(del.stats.dels, 2);
assert.deepEqual(smartDiff("", "").blocks, []);
});
it("后处理不变式:相邻同向块必合并,replace 块内 del 全部在 ins 之前", () => {
const oldText = Array.from({ length: 30 }, (_, i) => `common ${i}`).join("\n");
const newText = oldText
.replace("common 5", "changed 5")
.replace("common 6", "changed 6")
.replace("common 20", "changed 20");
const r = smartDiff(oldText, newText);
// 相邻块不允许同为变更块(equal 必然隔开两个变更区域)
for (let i = 1; i < r.blocks.length; i++) {
const prev = r.blocks[i - 1];
const cur = r.blocks[i];
assert.ok(
!(prev.type !== "equal" && cur.type !== "equal"),
"相邻变更块未被合并"
);
}
for (const b of r.blocks) {
if (b.type !== "replace") continue;
const firstIns = b.lines.findIndex((l) => l.op === "ins");
const lastDel = b.lines.map((l) => l.op).lastIndexOf("del");
assert.ok(lastDel < firstIns, "replace 块内应先删后增");
}
});
it("上下文折叠:长 equal 块折叠为上下文 + 省略标记,头/尾单侧保留", () => {
const lines = Array.from({ length: 40 }, (_, i) => `L${i}`);
const oldText = lines.join("\n");
const newText = [...lines.slice(0, 20), "changed", ...lines.slice(21)].join("\n");
const rows = toFoldedRows(smartDiff(oldText, newText).blocks);
// 头部 equal 块(L0..L19,isHead)只保留尾部 3 行上下文 L17/L18/L19 + 折叠 17 行;
// 尾部 equal 块(L21..L39,isTail)只保留头部 3 行上下文 L21/L22/L23 + 折叠 16 行
const folds = rows.filter((r) => r.kind === "fold") as Array<{ kind: "fold"; count: number }>;
assert.deepEqual(
folds.map((f) => f.count),
[17, 16]
);
// 头部 equal 块(L0..L19,isHead):省略前 17 行 → 保留尾部上下文 L17/L18/L19;
// 尾部 equal 块(L21..L39,isTail):保留头部上下文 L21/L22/L23 → 省略后 16 行
assert.deepEqual(rows.slice(0, 4).map((r) => (r.kind === "fold" ? "fold" : r.text)), [
"fold",
"L17",
"L18",
"L19",
]);
});
it("10 万行大文件在 2 秒内完成", () => {
const n = 100_000;
const oldLines = Array.from({ length: n }, (_, i) => `line-${i}`);
const newLines = [...oldLines];
const changes = 20;
for (let k = 0; k < changes; k++) {
newLines[k * 4_000 + 7] = `line-${k * 4_000 + 7}-changed`;
}
const start = performance.now();
const r = smartDiff(oldLines.join("\n"), newLines.join("\n"));
const elapsed = performance.now() - start;
assert.equal(r.stats.dels, changes);
assert.equal(r.stats.adds, changes);
assert.ok(elapsed < 2_000, `应在 2 秒内完成,实际 ${elapsed.toFixed(0)}ms`);
});
});

527
frontend/lib/smartDiff.ts Normal file
View File

@@ -0,0 +1,527 @@
/**
* smartDiff —— 面向可读性的结构化行级 diff(自研,无第三方依赖)。
*
* 四层处理:
* 1. 预处理:CRLF/CR → LF(仅用于比较与展示),行尾空白在比较时忽略(展示保留原文)。
* 2. 算法选择:行级相似度(difflib ratio 思路:2M/(a+b),M 为多重集交集)低于阈值时
* 判定「整段替换」,输出一个 block-replace,跳过逐行对齐——避免把毫无对应关系的
* 两段文本切成几十对 -/+ 碎片;否则用 Patience diff(锚定双方唯一行,LIS 选链),
* 无锚点/超大规模/超深递归的子区间回退为 LCS 或整块替换。
* 3. 后处理:相邻同向变更合并为一个块(del 在前 ins 在后),消除 LCS 的交叉配对;
* 上下文折叠(默认 3 行,头/尾块只保留单侧上下文)。
* 4. 行内细分:replace 块内按下标配对的行对,先分词(CJK 单字、西文单词、空白、符号),
* 词级 LCS 相似度 > 阈值时输出变更片段的字符偏移(segments),否则整行视为变更。
*
* 输出为结构化块数组,供 diff 视图组件渲染;segments 直接挂在对应行上
* (start/end 为该行文本的 UTF-16 偏移),UI 只高亮变更部分。
*/
export type LineOp = "equal" | "del" | "ins";
export type BlockType = "equal" | "insert" | "delete" | "replace" | "block-replace";
/** 行内变更片段:start/end 为行文本的 UTF-16 偏移,type 为该行视角下的变更方向 */
export interface InlineSegment {
start: number;
end: number;
type: "del" | "ins";
}
export interface DiffLine {
op: LineOp;
/** 原始行文本(保留行尾空白,供展示) */
text: string;
/** 仅 replace 块中被判定为「修改」的行对附带;缺省表示整行变更 */
segments?: InlineSegment[];
}
export interface DiffBlock {
type: BlockType;
lines: DiffLine[];
}
export interface DiffStats {
adds: number;
dels: number;
}
export interface DiffResult {
blocks: DiffBlock[];
stats: DiffStats;
}
export interface SmartDiffOptions {
/** 行级相似度低于该值判定整段替换(默认 0.2;仅任一侧 ≥2 行时生效) */
replaceThreshold?: number;
/** 行对词级相似度高于该值才做行内细分(默认 0.6) */
inlineThreshold?: number;
}
// 行级 LCS 回退的规模上限(超出则该区间整块替换,避免 O(n·m) 爆炸)
const MAX_REGION_CELLS = 400_000;
// 行内词级 LCS 的 token 数上限
const MAX_INLINE_CELLS = 20_000;
// 行对合计字符数超过则跳过行内细分
const MAX_INLINE_CHARS = 4_000;
// Patience 递归深度上限(超过直接整块替换,防御构造性深递归)
const MAX_PATIENCE_DEPTH = 32;
/* ---------------- 第一层:预处理 ---------------- */
interface SplitText {
raw: string[];
/** 归一化后的行(比较用):行尾空白已去除 */
cmp: string[];
}
function splitLines(text: string): SplitText {
const norm = text.replace(/\r\n?/g, "\n");
const raw = norm === "" ? [] : norm.split("\n");
return { raw, cmp: raw.map((l) => l.replace(/\s+$/, "")) };
}
/* ---------------- 第二层:相似度与算法选择 ---------------- */
/** 行级相似度:difflib ratio 思路,2M/(a+b),M 为行多重集交集大小 */
function lineSimilarity(a: string[], b: string[]): number {
if (a.length === 0 && b.length === 0) return 1;
const freq = new Map<string, number>();
for (const l of a) freq.set(l, (freq.get(l) ?? 0) + 1);
let m = 0;
for (const l of b) {
const c = freq.get(l) ?? 0;
if (c > 0) {
m += 1;
freq.set(l, c - 1);
}
}
return (2 * m) / (a.length + b.length);
}
type RawOp = { op: LineOp; ai: number; bi: number };
/**
* Patience diff:在区间内找到「双方都恰好出现一次」的公共行作锚点,
* 对锚点按 i 升序求 j 的最长递增子序列(patience sorting,O(k log k))作骨架,
* 骨架之间的间隙递归;无锚点/超深的区间回退 fallbackRegion。
*/
function patienceDiff(
cmpA: string[],
cmpB: string[],
loA: number,
hiA: number,
loB: number,
hiB: number,
out: RawOp[],
depth: number
): void {
if (loA >= hiA && loB >= hiB) return;
const countA = new Map<string, number>();
for (let i = loA; i < hiA; i++) countA.set(cmpA[i], (countA.get(cmpA[i]) ?? 0) + 1);
const countB = new Map<string, number>();
for (let j = loB; j < hiB; j++) countB.set(cmpB[j], (countB.get(cmpB[j]) ?? 0) + 1);
const posA = new Map<string, number>();
for (let i = loA; i < hiA; i++) {
if (countA.get(cmpA[i]) === 1) posA.set(cmpA[i], i);
}
const anchors: Array<{ i: number; j: number }> = [];
for (let j = loB; j < hiB; j++) {
if (countB.get(cmpB[j]) === 1) {
const i = posA.get(cmpB[j]);
if (i !== undefined) anchors.push({ i, j });
}
}
if (anchors.length === 0 || depth >= MAX_PATIENCE_DEPTH) {
fallbackRegion(cmpA, cmpB, loA, hiA, loB, hiB, out);
return;
}
anchors.sort((x, y) => x.i - y.i);
const tails: number[] = [];
const prev = new Array<number>(anchors.length).fill(-1);
for (let k = 0; k < anchors.length; k++) {
const j = anchors[k].j;
let lo = 0;
let hi = tails.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (anchors[tails[mid]].j < j) lo = mid + 1;
else hi = mid;
}
if (lo > 0) prev[k] = tails[lo - 1];
if (lo === tails.length) tails.push(k);
else tails[lo] = k;
}
const chain: number[] = [];
for (let k = tails[tails.length - 1]; k !== -1; k = prev[k]) chain.push(k);
chain.reverse();
let ai = loA;
let bi = loB;
for (const k of chain) {
const { i, j } = anchors[k];
patienceDiff(cmpA, cmpB, ai, i, bi, j, out, depth + 1);
out.push({ op: "equal", ai: i, bi: j });
ai = i + 1;
bi = j + 1;
}
patienceDiff(cmpA, cmpB, ai, hiA, bi, hiB, out, depth + 1);
}
/** 无锚点区间:无公共行或规模超限时整块替换;否则 LCS 细对齐 */
function fallbackRegion(
cmpA: string[],
cmpB: string[],
loA: number,
hiA: number,
loB: number,
hiB: number,
out: RawOp[]
): void {
const n = hiA - loA;
const m = hiB - loB;
if (n === 0 && m === 0) return;
if (n === 0) {
for (let j = loB; j < hiB; j++) out.push({ op: "ins", ai: loA, bi: j });
return;
}
if (m === 0) {
for (let i = loA; i < hiA; i++) out.push({ op: "del", ai: i, bi: loB });
return;
}
if (n * m > MAX_REGION_CELLS || !hasCommonLine(cmpA, cmpB, loA, hiA, loB, hiB)) {
for (let i = loA; i < hiA; i++) out.push({ op: "del", ai: i, bi: loB });
for (let j = loB; j < hiB; j++) out.push({ op: "ins", ai: hiA, bi: j });
return;
}
lcsRegion(cmpA, cmpB, loA, hiA, loB, hiB, out);
}
function hasCommonLine(
cmpA: string[],
cmpB: string[],
loA: number,
hiA: number,
loB: number,
hiB: number
): boolean {
const set = new Set<string>();
for (let i = loA; i < hiA; i++) set.add(cmpA[i]);
for (let j = loB; j < hiB; j++) {
if (set.has(cmpB[j])) return true;
}
return false;
}
/** 行级 LCS(规模已由调用方限制),供无锚点但有公共行的区间使用 */
function lcsRegion(
cmpA: string[],
cmpB: string[],
loA: number,
hiA: number,
loB: number,
hiB: number,
out: RawOp[]
): void {
const n = hiA - loA;
const m = hiB - loB;
const width = m + 1;
const dp = new Uint32Array((n + 1) * width);
for (let i = n - 1; i >= 0; i--) {
const aLine = cmpA[loA + i];
for (let j = m - 1; j >= 0; j--) {
dp[i * width + j] =
aLine === cmpB[loB + j]
? dp[(i + 1) * width + (j + 1)] + 1
: Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)]);
}
}
let i = 0;
let j = 0;
while (i < n && j < m) {
if (cmpA[loA + i] === cmpB[loB + j]) {
out.push({ op: "equal", ai: loA + i, bi: loB + j });
i += 1;
j += 1;
} else if (dp[(i + 1) * width + j] >= dp[i * width + (j + 1)]) {
out.push({ op: "del", ai: loA + i, bi: loB + j });
i += 1;
} else {
out.push({ op: "ins", ai: loA + i, bi: loB + j });
j += 1;
}
}
while (i < n) {
out.push({ op: "del", ai: loA + i, bi: loB + j });
i += 1;
}
while (j < m) {
out.push({ op: "ins", ai: loA + i, bi: loB + j });
j += 1;
}
}
/* ---------------- 第三层:后处理(块构建 + 上下文折叠) ---------------- */
/**
* 把扁平 op 序列整理成块:
* - 连续 equal → equal 块(展示用旧文本原文)
* - 连续非 equal 合并为一个变更区域:del 按原顺序在前、ins 按新顺序在后,
* 两侧都有 → replace(后续做行内细分),单侧 → delete / insert。
* 这一步消除了 LCS 可能出现的 -/+ 交叉碎片(del,ins,del,ins → 一个 replace 块)。
*/
function buildBlocks(ops: RawOp[], rawA: string[], rawB: string[]): DiffBlock[] {
const blocks: DiffBlock[] = [];
let k = 0;
while (k < ops.length) {
if (ops[k].op === "equal") {
const lines: DiffLine[] = [];
while (k < ops.length && ops[k].op === "equal") {
lines.push({ op: "equal", text: rawA[ops[k].ai] });
k += 1;
}
blocks.push({ type: "equal", lines });
continue;
}
const dels: DiffLine[] = [];
const inss: DiffLine[] = [];
while (k < ops.length && ops[k].op !== "equal") {
const o = ops[k];
if (o.op === "del") dels.push({ op: "del", text: rawA[o.ai] });
else inss.push({ op: "ins", text: rawB[o.bi] });
k += 1;
}
const type: BlockType =
dels.length > 0 && inss.length > 0 ? "replace" : dels.length > 0 ? "delete" : "insert";
blocks.push({ type, lines: [...dels, ...inss] });
}
return blocks;
}
export type FoldedRow =
| { kind: "line"; op: LineOp; text: string; segments?: InlineSegment[] }
| { kind: "fold"; count: number };
/**
* 上下文折叠:超过 context*2+2 行的 equal 块只保留变更两侧各 context 行,
* 头/尾 equal 块只保留单侧;返回渲染用的行序列(fold 为折叠标记)。
*/
export function toFoldedRows(blocks: DiffBlock[], context = 3): FoldedRow[] {
const rows: FoldedRow[] = [];
const pushLine = (l: DiffLine) =>
rows.push({ kind: "line", op: l.op, text: l.text, segments: l.segments });
blocks.forEach((b, bi) => {
if (!(b.type === "equal" && b.lines.length > context * 2 + 2)) {
b.lines.forEach(pushLine);
return;
}
const isHead = bi === 0;
const isTail = bi === blocks.length - 1;
if (isHead && isTail) {
// 整个 diff 都是相同内容:不折叠
b.lines.forEach(pushLine);
return;
}
const head = isHead ? 0 : context;
const tail = isTail ? 0 : context;
for (let i = 0; i < head; i++) pushLine(b.lines[i]);
rows.push({ kind: "fold", count: b.lines.length - head - tail });
for (let i = b.lines.length - tail; i < b.lines.length; i++) pushLine(b.lines[i]);
});
return rows;
}
/* ---------------- 第四层:行内细分(词级 diff) ---------------- */
// CJK 逐字成 token(中文无词边界),西文按单词,空白连续,其余单字符
const TOKEN_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]|[A-Za-z0-9_]+|\s+|[^\s]/gu;
interface Tokens {
tokens: string[];
starts: number[];
ends: number[];
}
function tokenize(line: string): Tokens {
const tokens: string[] = [];
const starts: number[] = [];
const ends: number[] = [];
for (const m of line.matchAll(TOKEN_RE)) {
const start = m.index;
if (start === undefined) continue;
tokens.push(m[0]);
starts.push(start);
ends.push(start + m[0].length);
}
return { tokens, starts, ends };
}
function lcsTokenLen(a: string[], b: string[]): number {
const n = a.length;
const m = b.length;
const width = m + 1;
const dp = new Uint32Array((n + 1) * width);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i * width + j] =
a[i] === b[j]
? dp[(i + 1) * width + (j + 1)] + 1
: Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)]);
}
}
return dp[0];
}
/** X 视角下的变更片段:词级 LCS 中未被匹配的连续 token 合并为一个字符区间 */
function changedSegments(
t: Tokens,
ops: Array<{ op: "equal" | "chg"; idx: number }>,
type: "del" | "ins"
): InlineSegment[] {
const segs: InlineSegment[] = [];
let cur: InlineSegment | null = null;
for (const o of ops) {
if (o.op === "equal") {
cur = null;
continue;
}
const start = t.starts[o.idx];
const end = t.ends[o.idx];
if (cur && cur.end === start) {
cur.end = end;
} else {
cur = { start, end, type };
segs.push(cur);
}
}
return segs;
}
/** 词级 LCS 回溯,产出 X/Y 各自的 equal/chg 序列(idx 为各自 token 下标) */
function tokenOps(x: Tokens, y: Tokens): {
xOps: Array<{ op: "equal" | "chg"; idx: number }>;
yOps: Array<{ op: "equal" | "chg"; idx: number }>;
} {
const n = x.tokens.length;
const m = y.tokens.length;
const width = m + 1;
const dp = new Uint32Array((n + 1) * width);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i * width + j] =
x.tokens[i] === y.tokens[j]
? dp[(i + 1) * width + (j + 1)] + 1
: Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)]);
}
}
const xOps: Array<{ op: "equal" | "chg"; idx: number }> = [];
const yOps: Array<{ op: "equal" | "chg"; idx: number }> = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (x.tokens[i] === y.tokens[j]) {
xOps.push({ op: "equal", idx: i });
yOps.push({ op: "equal", idx: j });
i += 1;
j += 1;
} else if (dp[(i + 1) * width + j] >= dp[i * width + (j + 1)]) {
xOps.push({ op: "chg", idx: i });
i += 1;
} else {
yOps.push({ op: "chg", idx: j });
j += 1;
}
}
while (i < n) {
xOps.push({ op: "chg", idx: i });
i += 1;
}
while (j < m) {
yOps.push({ op: "chg", idx: j });
j += 1;
}
return { xOps, yOps };
}
/** 对 replace 块内按下标配对的行对做词级细分;相似度不足则整行变更、不产出 segments */
function annotateInline(blocks: DiffBlock[], inlineThreshold: number): void {
for (const b of blocks) {
if (b.type !== "replace") continue;
const dels = b.lines.filter((l) => l.op === "del");
const inss = b.lines.filter((l) => l.op === "ins");
const n = Math.min(dels.length, inss.length);
for (let i = 0; i < n; i++) {
const delLine = dels[i];
const insLine = inss[i];
if (delLine.text.length + insLine.text.length > MAX_INLINE_CHARS) continue;
const x = tokenize(delLine.text);
const y = tokenize(insLine.text);
if (x.tokens.length === 0 || y.tokens.length === 0) continue;
if (x.tokens.length * y.tokens.length > MAX_INLINE_CELLS) continue;
const ratio = (2 * lcsTokenLen(x.tokens, y.tokens)) / (x.tokens.length + y.tokens.length);
if (!(ratio > inlineThreshold)) continue;
const { xOps, yOps } = tokenOps(x, y);
const delSegs = changedSegments(x, xOps, "del");
const insSegs = changedSegments(y, yOps, "ins");
if (delSegs.length > 0) delLine.segments = delSegs;
if (insSegs.length > 0) insLine.segments = insSegs;
}
}
}
/* ---------------- 入口 ---------------- */
export function smartDiff(
oldText: string,
newText: string,
options: SmartDiffOptions = {}
): DiffResult {
const replaceThreshold = options.replaceThreshold ?? 0.2;
const inlineThreshold = options.inlineThreshold ?? 0.6;
const a = splitLines(oldText);
const b = splitLines(newText);
const ops: RawOp[] = [];
let wholeReplace = false;
if (a.cmp.length === 0 && b.cmp.length === 0) {
// 两侧皆空:无块
} else if (a.cmp.length === 0 || b.cmp.length === 0) {
// 单侧为空:整体删除/新增
if (a.cmp.length > 0) {
for (let i = 0; i < a.cmp.length; i++) ops.push({ op: "del", ai: i, bi: 0 });
} else {
for (let j = 0; j < b.cmp.length; j++) ops.push({ op: "ins", ai: 0, bi: j });
}
} else if (
a.cmp.length >= 2 &&
b.cmp.length >= 2 &&
lineSimilarity(a.cmp, b.cmp) < replaceThreshold
) {
// 整段替换:跳过逐行对齐,输出一个大删除块 + 一个大新增块
wholeReplace = true;
for (let i = 0; i < a.cmp.length; i++) ops.push({ op: "del", ai: i, bi: 0 });
for (let j = 0; j < b.cmp.length; j++) ops.push({ op: "ins", ai: 0, bi: j });
} else {
patienceDiff(a.cmp, b.cmp, 0, a.cmp.length, 0, b.cmp.length, ops, 0);
}
const blocks = buildBlocks(ops, a.raw, b.raw);
if (wholeReplace) {
// 相似度低于阈值判定为整段替换:唯一变更块标记为 block-replace,
// 不做行内细分(两侧内容无对应关系,细对齐只会产出噪音)
const blk = blocks.find((x) => x.type === "replace");
if (blk) blk.type = "block-replace";
}
annotateInline(blocks, inlineThreshold);
let dels = 0;
let adds = 0;
for (const blk of blocks) {
for (const l of blk.lines) {
if (l.op === "del") dels += 1;
else if (l.op === "ins") adds += 1;
}
}
return { blocks, stats: { adds, dels } };
}

View File

@@ -7,7 +7,7 @@
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint",
"test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/widgetCode.test.ts"
"test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/widgetCode.test.ts lib/smartDiff.test.ts"
},
"dependencies": {
"hast-util-to-jsx-runtime": "^2.3.6",