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