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

@@ -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(() => ({}));