feat: 就地通过评审,并为已过审用户提供默认可关的免审发布
帖/评管理入口可直接通过;站点默认信任已有 published 内容的用户后续直发。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -71,11 +71,8 @@ func (h *Handlers) CreateComment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 管理团队成员评论免审;普通用户评论进入待审核队列,审核通过时才发业务通知
|
// staff 直发;已过审用户在站点开启免审时可直发;其余进待审队列(通过时才发业务通知)
|
||||||
status := model.ContentStatusPending
|
status := h.resolvePublishStatus(claims.ID, claims.Role)
|
||||||
if model.IsStaff(model.Role(claims.Role)) {
|
|
||||||
status = model.ContentStatusPublished
|
|
||||||
}
|
|
||||||
comment, parent, err := h.Comment.Create(claims.ID, uint(id), req.Content, req.ParentID, status)
|
comment, parent, err := h.Comment.Create(claims.ID, uint(id), req.Content, req.ParentID, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -30,6 +30,23 @@ type Handlers struct {
|
|||||||
Chat *service.ChatService
|
Chat *service.ChatService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||||
|
// staff 直发;若开启「已过审免审」且用户已有 published 内容则直发;否则进 pending。
|
||||||
|
func (h *Handlers) resolvePublishStatus(userID uint, role string) string {
|
||||||
|
if model.IsStaff(model.Role(role)) {
|
||||||
|
return model.ContentStatusPublished
|
||||||
|
}
|
||||||
|
trust, err := h.Setting.TrustReviewedPublish()
|
||||||
|
if err != nil || !trust {
|
||||||
|
return model.ContentStatusPending
|
||||||
|
}
|
||||||
|
ok, err := h.Moderation.UserHasApprovedContent(userID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return model.ContentStatusPending
|
||||||
|
}
|
||||||
|
return model.ContentStatusPublished
|
||||||
|
}
|
||||||
|
|
||||||
// broadcastFeedChanged 帖子流有公开发布内容时全员广播(三期)
|
// broadcastFeedChanged 帖子流有公开发布内容时全员广播(三期)
|
||||||
func (h *Handlers) broadcastFeedChanged(kind string, postID, boardID, actorID uint) {
|
func (h *Handlers) broadcastFeedChanged(kind string, postID, boardID, actorID uint) {
|
||||||
if h.Hub == nil {
|
if h.Hub == nil {
|
||||||
|
|||||||
@@ -110,10 +110,7 @@ func (h *Handlers) CreatePost(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
status := model.ContentStatusPending
|
status := h.resolvePublishStatus(claims.ID, claims.Role)
|
||||||
if model.IsStaff(model.Role(claims.Role)) {
|
|
||||||
status = model.ContentStatusPublished
|
|
||||||
}
|
|
||||||
post, err := h.Post.Create(service.CreatePostInput{
|
post, err := h.Post.Create(service.CreatePostInput{
|
||||||
UserID: claims.ID,
|
UserID: claims.ID,
|
||||||
BoardID: req.BoardID,
|
BoardID: req.BoardID,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PublicSettings 公开站点设置(无需登录):前端 SSR 据此注入主题 CSS 变量
|
// PublicSettings 公开站点设置(无需登录):前端 SSR 据此注入主题 CSS 变量等
|
||||||
// GET /api/settings
|
// GET /api/settings
|
||||||
func (h *Handlers) PublicSettings(c *gin.Context) {
|
func (h *Handlers) PublicSettings(c *gin.Context) {
|
||||||
accent, err := h.Setting.AccentColor()
|
accent, err := h.Setting.AccentColor()
|
||||||
@@ -18,12 +18,23 @@ func (h *Handlers) PublicSettings(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"accent": accent})
|
trust, err := h.Setting.TrustReviewedPublish()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"accent": accent,
|
||||||
|
"trust_reviewed_publish": trust,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateSettingsRequest 字段均为可选指针:只更新请求里出现的项,避免外观页覆盖审核策略
|
||||||
type updateSettingsRequest struct {
|
type updateSettingsRequest struct {
|
||||||
// Accent 主题色 #RRGGBB;空串恢复默认
|
// Accent 主题色 #RRGGBB;空串恢复默认
|
||||||
Accent string `json:"accent"`
|
Accent *string `json:"accent"`
|
||||||
|
// TrustReviewedPublish 已过审用户后续内容免审
|
||||||
|
TrustReviewedPublish *bool `json:"trust_reviewed_publish"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSettings 更新站点设置(管理员)
|
// UpdateSettings 更新站点设置(管理员)
|
||||||
@@ -34,7 +45,14 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.Setting.SetAccent(req.Accent); err != nil {
|
if req.Accent == nil && req.TrustReviewedPublish == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "未提供任何可更新的设置项"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
savedAccent := ""
|
||||||
|
if req.Accent != nil {
|
||||||
|
if err := h.Setting.SetAccent(*req.Accent); err != nil {
|
||||||
if errors.Is(err, service.ErrInvalidAccent) {
|
if errors.Is(err, service.ErrInvalidAccent) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -42,11 +60,43 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 归一化后的值实时广播:所有在线客户端立即热换肤,无需 F5 或等轮询
|
savedAccent = strings.ToLower(strings.TrimSpace(*req.Accent))
|
||||||
saved := strings.ToLower(strings.TrimSpace(req.Accent))
|
} else {
|
||||||
|
var err error
|
||||||
|
savedAccent, err = h.Setting.AccentColor()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trustOn := true
|
||||||
|
if req.TrustReviewedPublish != nil {
|
||||||
|
if err := h.Setting.SetTrustReviewedPublish(*req.TrustReviewedPublish); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trustOn = *req.TrustReviewedPublish
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
trustOn, err = h.Setting.TrustReviewedPublish()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 归一化后广播:主题色客户端热换肤;免审开关由下次发帖/发评读库生效
|
||||||
h.Hub.BroadcastAll(realtime.Envelope{
|
h.Hub.BroadcastAll(realtime.Envelope{
|
||||||
Type: realtime.EventSettingsChanged,
|
Type: realtime.EventSettingsChanged,
|
||||||
Data: map[string]string{"accent": saved},
|
Data: gin.H{
|
||||||
|
"accent": savedAccent,
|
||||||
|
"trust_reviewed_publish": trustOn,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"accent": savedAccent,
|
||||||
|
"trust_reviewed_publish": trustOn,
|
||||||
})
|
})
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "accent": saved})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,6 +280,26 @@ func (s *ModerationService) RejectComment(actor *Actor, id uint) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserHasApprovedContent 用户是否已有至少一篇已发布帖子或一条已发布评论(即曾通过审核或免审直发)
|
||||||
|
func (s *ModerationService) UserHasApprovedContent(userID uint) (bool, error) {
|
||||||
|
var n int64
|
||||||
|
if err := s.db.Model(&model.Post{}).
|
||||||
|
Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).
|
||||||
|
Limit(1).Count(&n).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
n = 0
|
||||||
|
if err := s.db.Model(&model.Comment{}).
|
||||||
|
Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).
|
||||||
|
Limit(1).Count(&n).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func keys(m map[uint]bool) []uint {
|
func keys(m map[uint]bool) []uint {
|
||||||
out := make([]uint, 0, len(m))
|
out := make([]uint, 0, len(m))
|
||||||
for k := range m {
|
for k := range m {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
// SettingKeyAccent 站点主题色(浅色基准,#rrggbb);为空表示使用内置默认色
|
// SettingKeyAccent 站点主题色(浅色基准,#rrggbb);为空表示使用内置默认色
|
||||||
SettingKeyAccent = "theme_accent"
|
SettingKeyAccent = "theme_accent"
|
||||||
|
// SettingKeyTrustReviewedPublish 已过审用户后续发帖/评论免审;缺行视为开启(默认开)
|
||||||
|
SettingKeyTrustReviewedPublish = "trust_reviewed_publish"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidAccent 主题色格式非法
|
// ErrInvalidAccent 主题色格式非法
|
||||||
@@ -55,3 +57,33 @@ func (s *SettingService) SetAccent(hex string) error {
|
|||||||
st := model.SiteSetting{Key: SettingKeyAccent, Value: hex}
|
st := model.SiteSetting{Key: SettingKeyAccent, Value: hex}
|
||||||
return s.db.Save(&st).Error
|
return s.db.Save(&st).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrustReviewedPublish 是否对「已有过审内容」的用户免审。
|
||||||
|
// KV 缺行 / 空值视为开启(默认开);仅显式写入 false/0/off/no 为关闭。
|
||||||
|
// 不用 GORM bool default:true,避免零值省略陷阱。
|
||||||
|
func (s *SettingService) TrustReviewedPublish() (bool, error) {
|
||||||
|
var st model.SiteSetting
|
||||||
|
err := s.db.Where("key = ?", SettingKeyTrustReviewedPublish).First(&st).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
v := strings.TrimSpace(strings.ToLower(st.Value))
|
||||||
|
switch v {
|
||||||
|
case "false", "0", "off", "no":
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTrustReviewedPublish 持久化免审开关;开启时删除键以保持「缺行=默认开」语义
|
||||||
|
func (s *SettingService) SetTrustReviewedPublish(on bool) error {
|
||||||
|
if on {
|
||||||
|
return s.db.Where("key = ?", SettingKeyTrustReviewedPublish).Delete(&model.SiteSetting{}).Error
|
||||||
|
}
|
||||||
|
st := model.SiteSetting{Key: SettingKeyTrustReviewedPublish, Value: "false"}
|
||||||
|
return s.db.Save(&st).Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function AppearanceAdmin({ initialAccent }: { initialAccent: stri
|
|||||||
if (!normalized || saving) return;
|
if (!normalized || saving) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiUpdateSiteSettings(submitHex);
|
const res = await apiUpdateSiteSettings({ accent: submitHex });
|
||||||
const next = (res.accent || "").toLowerCase();
|
const next = (res.accent || "").toLowerCase();
|
||||||
setSavedHex(next);
|
setSavedHex(next);
|
||||||
applyAccentTheme(next || null);
|
applyAccentTheme(next || null);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
apiAdminRejectPost,
|
apiAdminRejectPost,
|
||||||
apiAdminApproveComment,
|
apiAdminApproveComment,
|
||||||
apiAdminRejectComment,
|
apiAdminRejectComment,
|
||||||
|
apiUpdateSiteSettings,
|
||||||
type PendingCommentItem,
|
type PendingCommentItem,
|
||||||
type PendingCounts,
|
type PendingCounts,
|
||||||
type Post,
|
type Post,
|
||||||
@@ -47,10 +48,15 @@ export default function ModerationAdmin({
|
|||||||
initialPosts,
|
initialPosts,
|
||||||
initialPostsTotal,
|
initialPostsTotal,
|
||||||
initError,
|
initError,
|
||||||
|
canConfigureSettings,
|
||||||
|
initialTrustReviewedPublish,
|
||||||
}: {
|
}: {
|
||||||
initialPosts: Post[];
|
initialPosts: Post[];
|
||||||
initialPostsTotal: number;
|
initialPostsTotal: number;
|
||||||
initError: string;
|
initError: string;
|
||||||
|
/** 超管/站长可改站点免审策略(后端 PermSettings) */
|
||||||
|
canConfigureSettings: boolean;
|
||||||
|
initialTrustReviewedPublish: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<Tab>("posts");
|
const [tab, setTab] = useState<Tab>("posts");
|
||||||
const [counts, setCounts] = useState<PendingCounts>({ posts: initialPostsTotal, comments: 0 });
|
const [counts, setCounts] = useState<PendingCounts>({ posts: initialPostsTotal, comments: 0 });
|
||||||
@@ -73,12 +79,30 @@ export default function ModerationAdmin({
|
|||||||
const [confirmKey, setConfirmKey] = useState<string | null>(null);
|
const [confirmKey, setConfirmKey] = useState<string | null>(null);
|
||||||
const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const [trustReviewedPublish, setTrustReviewedPublish] = useState(initialTrustReviewedPublish);
|
||||||
|
const [savingTrust, setSavingTrust] = useState(false);
|
||||||
|
|
||||||
const refreshCounts = useCallback(() => {
|
const refreshCounts = useCallback(() => {
|
||||||
apiAdminPendingCounts()
|
apiAdminPendingCounts()
|
||||||
.then(setCounts)
|
.then(setCounts)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const toggleTrustReviewedPublish = async () => {
|
||||||
|
if (!canConfigureSettings || savingTrust) return;
|
||||||
|
const next = !trustReviewedPublish;
|
||||||
|
setSavingTrust(true);
|
||||||
|
try {
|
||||||
|
const res = await apiUpdateSiteSettings({ trust_reviewed_publish: next });
|
||||||
|
setTrustReviewedPublish(res.trust_reviewed_publish);
|
||||||
|
toast(res.trust_reviewed_publish ? "已开启:过审用户后续免审" : "已关闭:所有普通用户均需审核", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSavingTrust(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshCounts();
|
refreshCounts();
|
||||||
}, [refreshCounts]);
|
}, [refreshCounts]);
|
||||||
@@ -215,6 +239,40 @@ export default function ModerationAdmin({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canConfigureSettings && (
|
||||||
|
<section
|
||||||
|
className="panel p-4 sm:p-5 mb-5 flex items-start justify-between gap-4 flex-wrap"
|
||||||
|
aria-label="审核策略"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||||
|
已过审用户后续免审
|
||||||
|
</h2>
|
||||||
|
<p className="meta mt-1 text-[12.5px] leading-relaxed">
|
||||||
|
开启后,若用户已有通过审核的帖子或评论,后续发帖与评论将直接公开,无需再次进入待审队列。默认开启。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={trustReviewedPublish}
|
||||||
|
disabled={savingTrust}
|
||||||
|
onClick={() => void toggleTrustReviewedPublish()}
|
||||||
|
className="shrink-0 relative h-7 w-12 rounded-full transition-colors disabled:opacity-60"
|
||||||
|
style={{
|
||||||
|
background: trustReviewedPublish ? "var(--accent)" : "var(--line-2)",
|
||||||
|
}}
|
||||||
|
data-tip={trustReviewedPublish ? "点击关闭" : "点击开启"}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-[var(--panel)] shadow transition-transform"
|
||||||
|
style={{ transform: trustReviewedPublish ? "translateX(1.25rem)" : "translateX(0)" }}
|
||||||
|
/>
|
||||||
|
<span className="sr-only">{trustReviewedPublish ? "已开启" : "已关闭"}</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 队列切换 */}
|
{/* 队列切换 */}
|
||||||
<div className="flex items-center gap-1.5 mb-4 flex-wrap" role="tablist" aria-label="审核队列">
|
<div className="flex items-center gap-1.5 mb-4 flex-wrap" role="tablist" aria-label="审核队列">
|
||||||
{(
|
{(
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { fetchAdminPendingPosts, type Post } from "@/lib/api";
|
import { fetchAdminPendingPosts, type Post } from "@/lib/api";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { canModerateAny } from "@/lib/roles";
|
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
|
||||||
import Forbidden from "../Forbidden";
|
import Forbidden from "../Forbidden";
|
||||||
import ModerationAdmin from "./ModerationAdmin";
|
import ModerationAdmin from "./ModerationAdmin";
|
||||||
|
|
||||||
@@ -33,11 +33,16 @@ export default async function ModerationPage() {
|
|||||||
initError = e instanceof Error ? e.message : "获取待审核内容失败";
|
initError = e instanceof Error ? e.message : "获取待审核内容失败";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canConfigureSettings = isSuperOrOwner(me.user.role);
|
||||||
|
const settings = await getPublicSettingsCached();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModerationAdmin
|
<ModerationAdmin
|
||||||
initialPosts={initialPosts}
|
initialPosts={initialPosts}
|
||||||
initialPostsTotal={initialTotal}
|
initialPostsTotal={initialTotal}
|
||||||
initError={initError}
|
initError={initError}
|
||||||
|
canConfigureSettings={canConfigureSettings}
|
||||||
|
initialTrustReviewedPublish={settings.trust_reviewed_publish !== false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -494,7 +494,14 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{/* 管理操作已收进单个下拉按钮;普通读者此处不渲染内容 */}
|
{/* 管理操作已收进单个下拉按钮;普通读者此处不渲染内容 */}
|
||||||
<PostActions postId={id} authorId={post.user.id} boardId={post.board_id} pinned={post.pinned} recommended={post.recommended} />
|
<PostActions
|
||||||
|
postId={id}
|
||||||
|
authorId={post.user.id}
|
||||||
|
boardId={post.board_id}
|
||||||
|
pinned={post.pinned}
|
||||||
|
recommended={post.recommended}
|
||||||
|
status={post.status}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 元信息条:阅读数据居左、发布/更新时间居右(窄屏自动折行);
|
{/* 元信息条:阅读数据居左、发布/更新时间居右(窄屏自动折行);
|
||||||
|
|||||||
@@ -3,9 +3,17 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Trash2, MessagesSquare, ChevronRight, ChevronDown } from "lucide-react";
|
import { Trash2, MessagesSquare, ChevronRight, ChevronDown, BadgeCheck } from "lucide-react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import { apiCreateComment, apiDeleteComment, apiAcceptBounty, apiAcceptQuestion, type CommentNode, type User } from "@/lib/api";
|
import {
|
||||||
|
apiCreateComment,
|
||||||
|
apiDeleteComment,
|
||||||
|
apiAcceptBounty,
|
||||||
|
apiAcceptQuestion,
|
||||||
|
apiAdminApproveComment,
|
||||||
|
type CommentNode,
|
||||||
|
type User,
|
||||||
|
} from "@/lib/api";
|
||||||
import { canModerateBoard } from "@/lib/roles";
|
import { canModerateBoard } from "@/lib/roles";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
@@ -59,6 +67,7 @@ export default function CommentSection({
|
|||||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||||
const [acceptingId, setAcceptingId] = useState<number | null>(null);
|
const [acceptingId, setAcceptingId] = useState<number | null>(null);
|
||||||
|
const [approvingId, setApprovingId] = useState<number | null>(null);
|
||||||
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
|
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
|
||||||
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
|
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -163,11 +172,27 @@ export default function CommentSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleApprove = async (commentId: number) => {
|
||||||
|
setApprovingId(commentId);
|
||||||
|
try {
|
||||||
|
await apiAdminApproveComment(commentId);
|
||||||
|
toast("已通过评审", "ok");
|
||||||
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "通过评审失败");
|
||||||
|
} finally {
|
||||||
|
setApprovingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能)
|
// 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能)
|
||||||
|
|
||||||
const canDelete = (comment: CommentNode) =>
|
const canDelete = (comment: CommentNode) =>
|
||||||
!!user && (user.id === comment.user.id || canModerateBoard(user, boardId));
|
!!user && (user.id === comment.user.id || canModerateBoard(user, boardId));
|
||||||
|
|
||||||
|
const canApprove = (comment: CommentNode) =>
|
||||||
|
!!user && comment.status === "pending" && canModerateBoard(user, boardId);
|
||||||
|
|
||||||
const canAccept = (comment: CommentNode) =>
|
const canAccept = (comment: CommentNode) =>
|
||||||
!!acceptMode &&
|
!!acceptMode &&
|
||||||
!!user &&
|
!!user &&
|
||||||
@@ -302,6 +327,18 @@ export default function CommentSection({
|
|||||||
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
|
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canApprove(c) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cmt-reply-btn !text-[var(--accent)] inline-flex items-center gap-1"
|
||||||
|
disabled={approvingId === c.id}
|
||||||
|
onClick={() => void handleApprove(c.id)}
|
||||||
|
data-tip="通过评审,公开此评论"
|
||||||
|
>
|
||||||
|
<BadgeCheck size={13} />
|
||||||
|
{approvingId === c.id ? "通过中…" : "通过评审"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canDelete(c) && (
|
{canDelete(c) && (
|
||||||
<button
|
<button
|
||||||
@@ -394,6 +431,18 @@ export default function CommentSection({
|
|||||||
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
|
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canApprove(c) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cmt-reply-btn !text-[var(--accent)] inline-flex items-center gap-1"
|
||||||
|
disabled={approvingId === c.id}
|
||||||
|
onClick={() => void handleApprove(c.id)}
|
||||||
|
data-tip="通过评审,公开此评论"
|
||||||
|
>
|
||||||
|
<BadgeCheck size={13} />
|
||||||
|
{approvingId === c.id ? "通过中…" : "通过评审"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canDelete(c) && (
|
{canDelete(c) && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check } from "lucide-react";
|
import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check, BadgeCheck } from "lucide-react";
|
||||||
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, type User } from "@/lib/api";
|
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, apiAdminApprovePost, type User } from "@/lib/api";
|
||||||
import { canModerateBoard, isAdminOrAbove } from "@/lib/roles";
|
import { canModerateBoard, isAdminOrAbove } from "@/lib/roles";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
@@ -15,9 +15,18 @@ interface PostActionsProps {
|
|||||||
boardId: number;
|
boardId: number;
|
||||||
pinned: number;
|
pinned: number;
|
||||||
recommended: boolean;
|
recommended: boolean;
|
||||||
|
/** 帖子审核状态:pending 时对可审者显示「通过评审」 */
|
||||||
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PostActions({ postId, authorId, boardId, pinned, recommended }: PostActionsProps) {
|
export default function PostActions({
|
||||||
|
postId,
|
||||||
|
authorId,
|
||||||
|
boardId,
|
||||||
|
pinned,
|
||||||
|
recommended,
|
||||||
|
status,
|
||||||
|
}: PostActionsProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -54,6 +63,7 @@ export default function PostActions({ postId, authorId, boardId, pinned, recomme
|
|||||||
// 作者本人或有该板块审核权的管理团队可管理;置顶/加精仅管理员及以上
|
// 作者本人或有该板块审核权的管理团队可管理;置顶/加精仅管理员及以上
|
||||||
const canManage = !!user && (user.id === authorId || canModerateBoard(user, boardId));
|
const canManage = !!user && (user.id === authorId || canModerateBoard(user, boardId));
|
||||||
const isAdmin = !!user && isAdminOrAbove(user.role);
|
const isAdmin = !!user && isAdminOrAbove(user.role);
|
||||||
|
const canApprove = !!user && status === "pending" && canModerateBoard(user, boardId);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
@@ -73,6 +83,20 @@ export default function PostActions({ postId, authorId, boardId, pinned, recomme
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleApprove = async () => {
|
||||||
|
setActing(true);
|
||||||
|
setOpen(false);
|
||||||
|
try {
|
||||||
|
await apiAdminApprovePost(Number(postId));
|
||||||
|
toast("已通过评审", "ok");
|
||||||
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "通过评审失败");
|
||||||
|
} finally {
|
||||||
|
setActing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handlePin = async () => {
|
const handlePin = async () => {
|
||||||
setActing(true);
|
setActing(true);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -137,6 +161,21 @@ export default function PostActions({ postId, authorId, boardId, pinned, recomme
|
|||||||
boxShadow: "var(--shadow-lg)",
|
boxShadow: "var(--shadow-lg)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{canApprove && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={handleApprove}
|
||||||
|
className={itemCls}
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
<BadgeCheck size={14} className="shrink-0" />
|
||||||
|
<span className="flex-1 text-left">通过评审</span>
|
||||||
|
</button>
|
||||||
|
<div style={{ height: 1, background: "var(--line)" }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -528,19 +528,25 @@ export async function fetchBoards(): Promise<{ boards: BoardWithCount[] }> {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 公开站点设置:accent 为站点主题色 hex(空串=内置默认)
|
// 公开站点设置:accent 为站点主题色 hex(空串=内置默认);
|
||||||
|
// trust_reviewed_publish 为已过审用户后续免审(缺省/未配置视为 true)
|
||||||
export interface PublicSettings {
|
export interface PublicSettings {
|
||||||
accent: string;
|
accent: string;
|
||||||
|
trust_reviewed_publish: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSR 读取公开站点设置(主题色注入用);后端不可用时降级为默认色,不阻塞渲染
|
// SSR 读取公开站点设置(主题色注入用);后端不可用时降级为默认色,不阻塞渲染
|
||||||
export async function fetchPublicSettings(): Promise<PublicSettings> {
|
export async function fetchPublicSettings(): Promise<PublicSettings> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/settings`, ssrInit());
|
const res = await fetch(`${API_BASE}/api/settings`, ssrInit());
|
||||||
if (!res.ok) return { accent: "" };
|
if (!res.ok) return { accent: "", trust_reviewed_publish: true };
|
||||||
return (await res.json()) as PublicSettings;
|
const data = (await res.json()) as Partial<PublicSettings>;
|
||||||
|
return {
|
||||||
|
accent: data.accent ?? "",
|
||||||
|
trust_reviewed_publish: data.trust_reviewed_publish !== false,
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { accent: "" };
|
return { accent: "", trust_reviewed_publish: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1417,19 +1423,31 @@ export async function apiGetSettings(): Promise<PublicSettings> {
|
|||||||
// no-store:主题同步必须读到管理员刚保存的最新配色,不走浏览器缓存
|
// no-store:主题同步必须读到管理员刚保存的最新配色,不走浏览器缓存
|
||||||
const res = await fetchWithRefresh("/api/settings", { method: "GET", cache: "no-store" });
|
const res = await fetchWithRefresh("/api/settings", { method: "GET", cache: "no-store" });
|
||||||
if (!res.ok) throw new Error("获取站点设置失败");
|
if (!res.ok) throw new Error("获取站点设置失败");
|
||||||
return res.json();
|
const data = (await res.json()) as Partial<PublicSettings>;
|
||||||
|
return {
|
||||||
|
accent: data.accent ?? "",
|
||||||
|
trust_reviewed_publish: data.trust_reviewed_publish !== false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新站点主题色;accent 传空串恢复默认
|
export type UpdateSiteSettingsBody = {
|
||||||
export async function apiUpdateSiteSettings(accent: string): Promise<PublicSettings> {
|
accent?: string;
|
||||||
|
trust_reviewed_publish?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新站点设置(字段可选);accent 传空串恢复默认主题色
|
||||||
|
export async function apiUpdateSiteSettings(body: UpdateSiteSettingsBody): Promise<PublicSettings> {
|
||||||
const res = await fetchWithRefresh("/api/admin/settings", {
|
const res = await fetchWithRefresh("/api/admin/settings", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||||
body: JSON.stringify({ accent }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(data.error || "保存设置失败");
|
if (!res.ok) throw new Error(data.error || "保存设置失败");
|
||||||
return { accent: data.accent ?? "" };
|
return {
|
||||||
|
accent: data.accent ?? "",
|
||||||
|
trust_reviewed_publish: data.trust_reviewed_publish !== false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 板块(客户端,供角色授权弹窗勾选) =====
|
// ===== 板块(客户端,供角色授权弹窗勾选) =====
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface RtPresenceData {
|
|||||||
|
|
||||||
export interface RtSettingsData {
|
export interface RtSettingsData {
|
||||||
accent: string;
|
accent: string;
|
||||||
|
trust_reviewed_publish?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 群聊新消息:data 为落库的 ChatMessage(含 sender)
|
// 群聊新消息:data 为落库的 ChatMessage(含 sender)
|
||||||
|
|||||||
Reference in New Issue
Block a user