优化评论区展示:弱化用户名样式、调整回复布局与编辑时限。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 00:33:06 +08:00
parent e8b28a37e4
commit 322ac14055
7 changed files with 107 additions and 41 deletions

View File

@@ -147,7 +147,7 @@ export interface AdminDashboard {
export interface ForumLimits { export interface ForumLimits {
post_edit_window_hours: number; post_edit_window_hours: number;
comment_edit_window_hours: number; comment_edit_window_minutes: number;
rate_limit_post: number; rate_limit_post: number;
rate_limit_comment: number; rate_limit_comment: number;
rate_limit_register: number; rate_limit_register: number;
@@ -176,7 +176,7 @@ export interface ForumLimitsPublic {
post_tags_max: number; post_tags_max: number;
post_content_max: number; post_content_max: number;
comment_max: number; comment_max: number;
comment_edit_window_hours: number; comment_edit_window_minutes: number;
search_keyword_min: number; search_keyword_min: number;
search_keyword_max: number; search_keyword_max: number;
page_size_default: number; page_size_default: number;

View File

@@ -25,20 +25,21 @@ import {
} from '../utils/comment'; } from '../utils/comment';
import { isTimeDiffSignificant } from '../utils/content'; import { isTimeDiffSignificant } from '../utils/content';
import { useForumLimits } from '../hooks/useForumLimits'; import { useForumLimits } from '../hooks/useForumLimits';
import { Tooltip } from './ui/Tooltip';
import UserLink from './UserLink'; import UserLink from './UserLink';
function isCommentAuthor(c: Comment, user?: User | null): boolean { function isCommentAuthor(c: Comment, user?: User | null): boolean {
return !!user && c.user_id > 0 && c.user_id === user.id; return !!user && c.user_id > 0 && c.user_id === user.id;
} }
function canEditComment(c: Comment, user: User | null | undefined, windowHours: number): boolean { function canEditComment(c: Comment, user: User | null | undefined, windowMinutes: number): boolean {
if (!user) return false; if (!user) return false;
if (user.role === 'admin') return true; if (user.role === 'admin') return true;
if (!isCommentAuthor(c, user)) return false; if (!isCommentAuthor(c, user)) return false;
if (windowHours <= 0) return true; if (windowMinutes <= 0) return true;
const created = new Date(c.created_at).getTime(); const created = new Date(c.created_at).getTime();
if (Number.isNaN(created)) return false; if (Number.isNaN(created)) return false;
return Date.now() - created <= windowHours * 3600_000; return Date.now() - created <= windowMinutes * 60_000;
} }
interface ItemProps { interface ItemProps {
@@ -84,7 +85,21 @@ function CommentItem({
const isReplying = replyToId === c.id; const isReplying = replyToId === c.id;
const isEditing = editingId === c.id; const isEditing = editingId === c.id;
const isAdmin = currentUser?.role === 'admin'; const isAdmin = currentUser?.role === 'admin';
const canEdit = canEditComment(c, currentUser, limits.comment_edit_window_hours ?? 24); const editWindowMinutes = limits.comment_edit_window_minutes ?? 3;
// 到期后强制重渲染,使「编辑」按钮自动消失
const [, setEditExpireTick] = useState(0);
useEffect(() => {
if (!currentUser || currentUser.role === 'admin') return;
if (!isCommentAuthor(c, currentUser)) return;
if (editWindowMinutes <= 0) return;
const created = new Date(c.created_at).getTime();
if (Number.isNaN(created)) return;
const remaining = created + editWindowMinutes * 60_000 - Date.now();
if (remaining <= 0) return;
const timer = window.setTimeout(() => setEditExpireTick(n => n + 1), remaining + 30);
return () => window.clearTimeout(timer);
}, [c.created_at, c.id, c.user_id, currentUser, editWindowMinutes]);
const canEdit = canEditComment(c, currentUser, editWindowMinutes);
const canDelete = isAdmin; const canDelete = isAdmin;
const canApprove = isAdmin const canApprove = isAdmin
&& (c.status === 'pending' || c.status === 'rejected') && (c.status === 'pending' || c.status === 'rejected')
@@ -153,6 +168,11 @@ function CommentItem({
) : ( ) : (
<span className="waline-comment-author">{nick}</span> <span className="waline-comment-author">{nick}</span>
)} )}
{!c.reply_to && (
<span className="waline-comment-floor" aria-label={`${c.floor}`}>
#{c.floor}
</span>
)}
</div> </div>
{hidden ? ( {hidden ? (
@@ -184,9 +204,6 @@ function CommentItem({
</div> </div>
) : ( ) : (
<div className="waline-comment-bubble"> <div className="waline-comment-bubble">
{c.reply_target && (
<span className="waline-reply-at">@{commentNick(c.reply_target)}</span>
)}
<CommentContent content={c.content} /> <CommentContent content={c.content} />
</div> </div>
)} )}
@@ -195,10 +212,13 @@ function CommentItem({
<span className="waline-comment-date"> <span className="waline-comment-date">
<Clock size={14} /> <Clock size={14} />
{formatCommentDate(c.created_at)} {formatCommentDate(c.created_at)}
{showEdited && <span className="waline-comment-edited"> · </span>} {isAdmin && showEdited && <span className="waline-comment-edited"> · </span>}
{c.status === 'pending' && <span className="waline-comment-status waline-comment-status--pending"> · </span>} {c.status === 'pending' && <span className="waline-comment-status waline-comment-status--pending"> · </span>}
{c.status === 'rejected' && <span className="waline-comment-status waline-comment-status--rejected"> · </span>} {c.status === 'rejected' && <span className="waline-comment-status waline-comment-status--rejected"> · </span>}
</span> </span>
{c.reply_target && (
<span className="waline-reply-at">@{commentNick(c.reply_target)}</span>
)}
{!hidden && !isEditing && canApprove && ( {!hidden && !isEditing && canApprove && (
<button <button
type="button" type="button"
@@ -224,10 +244,12 @@ function CommentItem({
</button> </button>
) : ( ) : (
<button type="button" className="waline-comment-reply-btn" onClick={() => onReply(c)}> <Tooltip content={`回复给 ${nick}`} side="top">
<MessageSquare size={14} /> <button type="button" className="waline-comment-reply-btn" onClick={() => onReply(c)}>
<MessageSquare size={14} />
</button>
</button>
</Tooltip>
) )
)} )}
{!hidden && !isEditing && canEdit && ( {!hidden && !isEditing && canEdit && (

View File

@@ -7,7 +7,7 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
post_tags_max: 256, post_tags_max: 256,
post_content_max: 50000, post_content_max: 50000,
comment_max: 5000, comment_max: 5000,
comment_edit_window_hours: 24, comment_edit_window_minutes: 3,
search_keyword_min: 1, search_keyword_min: 1,
search_keyword_max: 50, search_keyword_max: 50,
page_size_default: 30, page_size_default: 30,

View File

@@ -40,7 +40,7 @@ const SETTING_SECTIONS: SettingSection[] = [
summary: '控制普通用户修改自己帖子 / 评论的时限0 = 不限)', summary: '控制普通用户修改自己帖子 / 评论的时限0 = 不限)',
rows: [ rows: [
{ key: 'post_edit_window_hours', label: '帖子可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 }, { key: 'post_edit_window_hours', label: '帖子可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 },
{ key: 'comment_edit_window_hours', label: '评论可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 }, { key: 'comment_edit_window_minutes', label: '评论可编辑时限', unit: '分钟', hint: '0 = 不限', min: 0 },
], ],
}, },
{ {

View File

@@ -3160,7 +3160,7 @@ a.post-title:visited {
.post-detail-author-name { .post-detail-author-name {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 400;
color: var(--color-text-1); color: var(--color-text-1);
} }
@@ -5230,7 +5230,12 @@ a.post-title:visited {
border-bottom: 1px solid var(--j13-border-light); border-bottom: 1px solid var(--j13-border-light);
} }
/* 回复:头像与昵称同行,正文从头像下方起铺满,避免逐级挤窄 */
.waline-comment.nested { .waline-comment.nested {
display: grid;
grid-template-columns: 32px 1fr;
gap: 6px 10px;
align-items: start;
padding: 12px 0 0; padding: 12px 0 0;
border-bottom: none; border-bottom: none;
} }
@@ -5249,11 +5254,40 @@ a.post-title:visited {
} }
.waline-comment.nested .waline-comment-avatar { .waline-comment.nested .waline-comment-avatar {
grid-column: 1;
grid-row: 1;
width: 32px; width: 32px;
height: 32px; height: 32px;
font-size: 13px; font-size: 13px;
} }
/* 让子元素直接参与 grid正文/回复可跨列铺满 */
.waline-comment.nested .waline-comment-main {
display: contents;
}
.waline-comment.nested .waline-comment-head {
grid-column: 2;
grid-row: 1;
margin-bottom: 0;
align-self: center;
min-width: 0;
}
.waline-comment.nested .waline-comment-bubble,
.waline-comment.nested .waline-comment-private-mask,
.waline-comment.nested .waline-comment-edit,
.waline-comment.nested .waline-comment-meta,
.waline-comment.nested .comment-box-wrap.inline,
.waline-comment.nested .waline-replies {
grid-column: 1 / -1;
}
/* 更深层级与第一层回复左对齐,不再继续右移 */
.waline-comment.nested .waline-replies {
margin-top: 4px;
}
.waline-comment-avatar { .waline-comment-avatar {
flex-shrink: 0; flex-shrink: 0;
width: 40px; width: 40px;
@@ -5291,12 +5325,23 @@ a.post-title:visited {
.waline-comment-author { .waline-comment-author {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 400;
color: var(--color-text-1); color: var(--color-text-1);
text-decoration: none; text-decoration: none;
} }
a.waline-comment-author:hover { color: var(--j13-green); } a.waline-comment-author:hover {
color: var(--j13-green);
text-decoration: none;
}
.waline-comment-floor {
margin-left: auto;
font-size: 12px;
font-weight: 500;
color: var(--color-text-3);
flex-shrink: 0;
}
.waline-comment-bubble { .waline-comment-bubble {
padding: 10px 14px; padding: 10px 14px;
@@ -5309,10 +5354,8 @@ a.waline-comment-author:hover { color: var(--j13-green); }
} }
.waline-reply-at { .waline-reply-at {
display: block; color: inherit;
color: #27c; font-weight: 400;
font-weight: 600;
margin-bottom: 4px;
line-height: 1.5; line-height: 1.5;
} }
@@ -5429,7 +5472,7 @@ a.waline-comment-author:hover { color: var(--j13-green); }
.waline-comment { padding: 12px 14px; } .waline-comment { padding: 12px 14px; }
} }
.mention { color: var(--j13-green); font-weight: 600; } .mention { color: var(--j13-green); font-weight: 400; }
.quote-block { .quote-block {
border-left: 3px solid var(--j13-green); border-left: 3px solid var(--j13-green);
@@ -5928,6 +5971,7 @@ a.waline-comment-author:hover { color: var(--j13-green); }
.user-link:hover, .user-link:hover,
.user-link:focus-visible { .user-link:focus-visible {
color: var(--j13-green); color: var(--j13-green);
text-decoration: none;
} }
.user-link--static { .user-link--static {
@@ -5950,11 +5994,11 @@ a.user-link--avatar-only:focus-visible {
.post-detail-author-name.user-link { .post-detail-author-name.user-link {
font-size: inherit; font-size: inherit;
font-weight: 600; font-weight: 400;
} }
.waline-comment-author.user-link { .waline-comment-author.user-link {
font-weight: 600; font-weight: 400;
} }
.widget-item--comment { .widget-item--comment {

View File

@@ -282,8 +282,8 @@ func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content st
return "", ErrPermissionDenied return "", ErrPermissionDenied
} }
if !isAdmin { if !isAdmin {
window := s.settings.CommentEditWindowHours() window := s.settings.CommentEditWindowMinutes()
if window > 0 && time.Since(comment.CreatedAt) > time.Duration(window)*time.Hour { if window > 0 && time.Since(comment.CreatedAt) > time.Duration(window)*time.Minute {
return "", errors.New("已超过可编辑时限") return "", errors.New("已超过可编辑时限")
} }
} }

View File

@@ -13,8 +13,8 @@ import (
// 论坛设置键名 // 论坛设置键名
const ( const (
SettingPostEditWindowHours = "post_edit_window_hours" SettingPostEditWindowHours = "post_edit_window_hours"
SettingCommentEditWindowHours = "comment_edit_window_hours" SettingCommentEditWindowMinutes = "comment_edit_window_minutes"
SettingRateLimitPost = "rate_limit_post" SettingRateLimitPost = "rate_limit_post"
SettingRateLimitComment = "rate_limit_comment" SettingRateLimitComment = "rate_limit_comment"
@@ -91,8 +91,8 @@ const (
// ForumLimits 论坛可配置限制API 传输结构) // ForumLimits 论坛可配置限制API 传输结构)
type ForumLimits struct { type ForumLimits struct {
PostEditWindowHours int `json:"post_edit_window_hours"` PostEditWindowHours int `json:"post_edit_window_hours"`
CommentEditWindowHours int `json:"comment_edit_window_hours"` CommentEditWindowMinutes int `json:"comment_edit_window_minutes"`
RateLimitPost int `json:"rate_limit_post"` RateLimitPost int `json:"rate_limit_post"`
RateLimitComment int `json:"rate_limit_comment"` RateLimitComment int `json:"rate_limit_comment"`
@@ -135,7 +135,7 @@ type ForumLimitsPublic struct {
AvatarMaxMB int `json:"avatar_max_mb"` AvatarMaxMB int `json:"avatar_max_mb"`
SignatureMax int `json:"signature_max"` SignatureMax int `json:"signature_max"`
CommentEditWindowHours int `json:"comment_edit_window_hours"` CommentEditWindowMinutes int `json:"comment_edit_window_minutes"`
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"` OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"` OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
@@ -153,7 +153,7 @@ type settingDef struct {
var forumSettingDefs = []settingDef{ var forumSettingDefs = []settingDef{
{SettingPostEditWindowHours, "24", 0, 0}, {SettingPostEditWindowHours, "24", 0, 0},
{SettingCommentEditWindowHours, "24", 0, 0}, {SettingCommentEditWindowMinutes, "3", 0, 0},
{SettingRateLimitPost, "10", 1, 1000}, {SettingRateLimitPost, "10", 1, 1000},
{SettingRateLimitComment, "10", 1, 1000}, {SettingRateLimitComment, "10", 1, 1000},
@@ -427,8 +427,8 @@ func (s *ForumSettingsService) setInt(key string, value int) error {
func (s *ForumSettingsService) Limits() ForumLimits { func (s *ForumSettingsService) Limits() ForumLimits {
permalink := s.Permalink() permalink := s.Permalink()
return ForumLimits{ return ForumLimits{
PostEditWindowHours: s.PostEditWindowHours(), PostEditWindowHours: s.PostEditWindowHours(),
CommentEditWindowHours: s.CommentEditWindowHours(), CommentEditWindowMinutes: s.CommentEditWindowMinutes(),
RateLimitPost: s.RateLimitFor("post"), RateLimitPost: s.RateLimitFor("post"),
RateLimitComment: s.RateLimitFor("comment"), RateLimitComment: s.RateLimitFor("comment"),
@@ -473,7 +473,7 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
AvatarMaxMB: limits.AvatarMaxMB, AvatarMaxMB: limits.AvatarMaxMB,
SignatureMax: limits.SignatureMax, SignatureMax: limits.SignatureMax,
CommentEditWindowHours: limits.CommentEditWindowHours, CommentEditWindowMinutes: limits.CommentEditWindowMinutes,
OpenPostsInNewTab: limits.OpenPostsInNewTab, OpenPostsInNewTab: limits.OpenPostsInNewTab,
OpenContentLinksInNewTab: limits.OpenContentLinksInNewTab, OpenContentLinksInNewTab: limits.OpenContentLinksInNewTab,
@@ -485,8 +485,8 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error { func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
updates := map[string]int{ updates := map[string]int{
SettingPostEditWindowHours: in.PostEditWindowHours, SettingPostEditWindowHours: in.PostEditWindowHours,
SettingCommentEditWindowHours: in.CommentEditWindowHours, SettingCommentEditWindowMinutes: in.CommentEditWindowMinutes,
SettingRateLimitPost: in.RateLimitPost, SettingRateLimitPost: in.RateLimitPost,
SettingRateLimitComment: in.RateLimitComment, SettingRateLimitComment: in.RateLimitComment,
SettingRateLimitRegister: in.RateLimitRegister, SettingRateLimitRegister: in.RateLimitRegister,
@@ -539,8 +539,8 @@ func (s *ForumSettingsService) PostEditWindowHours() int {
return s.getInt(SettingPostEditWindowHours, 24) return s.getInt(SettingPostEditWindowHours, 24)
} }
func (s *ForumSettingsService) CommentEditWindowHours() int { func (s *ForumSettingsService) CommentEditWindowMinutes() int {
return s.getInt(SettingCommentEditWindowHours, 24) return s.getInt(SettingCommentEditWindowMinutes, 3)
} }
func (s *ForumSettingsService) RateLimitFor(action string) int { func (s *ForumSettingsService) RateLimitFor(action string) int {