feat: 全站评论开关,并统一后台表单控件
关闭评论后前台隐藏评论能力;外观/设置/公告改用 AdminField 与 Stepper。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,7 +6,6 @@ import Link from "next/link";
|
||||
import {
|
||||
Trash2,
|
||||
MessagesSquare,
|
||||
MessageSquareOff,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
BadgeCheck,
|
||||
@@ -31,7 +30,8 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { canModerateBoard } from "@/lib/roles";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||
import { useAllowComments } from "@/components/CommentsPolicyProvider";
|
||||
import CommentsUnsupported from "@/components/CommentsUnsupported";
|
||||
import Avatar from "./Avatar";
|
||||
import Pagination from "./Pagination";
|
||||
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
||||
@@ -57,8 +57,6 @@ interface CommentSectionProps {
|
||||
acceptedCommentId?: number;
|
||||
/** 抽奖已开奖:中奖楼层评论 ID(每名中奖者仅其最早主评一条) */
|
||||
winnerCommentIds?: number[];
|
||||
/** 全站是否开放评论;缺省 true。关闭时隐藏发评/回复入口 */
|
||||
allowComments?: boolean;
|
||||
}
|
||||
|
||||
// 递归统计子树回复数(彻底删除级联提示用)
|
||||
@@ -116,10 +114,10 @@ export default function CommentSection({
|
||||
acceptMode = null,
|
||||
acceptedCommentId = 0,
|
||||
winnerCommentIds = [],
|
||||
allowComments: allowCommentsProp = true,
|
||||
}: CommentSectionProps) {
|
||||
const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0));
|
||||
const router = useRouter();
|
||||
const commentsOpen = useAllowComments();
|
||||
const [content, setContent] = useState("");
|
||||
// 内联回复目标:在该评论下方就地发子评论(同一时间只有一个回复框)
|
||||
const [replyTarget, setReplyTarget] = useState<{ id: number; nickname: string } | null>(null);
|
||||
@@ -141,25 +139,14 @@ export default function CommentSection({
|
||||
const [historyCommentId, setHistoryCommentId] = useState<number | null>(null);
|
||||
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
|
||||
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
|
||||
const [commentsOpen, setCommentsOpen] = useState(allowCommentsProp !== false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCommentsOpen(allowCommentsProp !== false);
|
||||
}, [allowCommentsProp]);
|
||||
|
||||
// 管理后台开关变更后即时隐藏发评入口(后端仍硬拒绝)
|
||||
useEffect(() => {
|
||||
return realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
|
||||
if (typeof data?.allow_comments === "boolean") {
|
||||
setCommentsOpen(data.allow_comments);
|
||||
if (!data.allow_comments) {
|
||||
setReplyTarget(null);
|
||||
setReplyContent("");
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
if (!commentsOpen) {
|
||||
setReplyTarget(null);
|
||||
setReplyContent("");
|
||||
}
|
||||
}, [commentsOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -1017,6 +1004,10 @@ export default function CommentSection({
|
||||
);
|
||||
};
|
||||
|
||||
if (!commentsOpen) {
|
||||
return <CommentsUnsupported />;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel p-6 sm:p-8 mt-6" aria-label="评论区">
|
||||
<div className="flex items-center gap-2.5 mb-6">
|
||||
@@ -1032,34 +1023,7 @@ export default function CommentSection({
|
||||
<span className="meta ml-auto text-[11.5px]">共 {totalPages} 页楼层</span>
|
||||
</div>
|
||||
|
||||
{!commentsOpen ? (
|
||||
<div
|
||||
className="mb-8 rounded-2xl border px-5 py-6 text-center sm:px-6"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--ink-3) 35%, var(--line))",
|
||||
background:
|
||||
"repeating-linear-gradient(-45deg, transparent, transparent 6px, color-mix(in srgb, var(--ink-3) 8%, transparent) 6px, color-mix(in srgb, var(--ink-3) 8%, transparent) 7px), var(--panel-2)",
|
||||
}}
|
||||
role="status"
|
||||
>
|
||||
<span
|
||||
className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-xl"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--ink-3) 14%, transparent)",
|
||||
color: "var(--ink-3)",
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
<MessageSquareOff size={22} />
|
||||
</span>
|
||||
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
评论功能暂时关闭
|
||||
</p>
|
||||
<p className="meta mt-1.5 text-[12.5px] leading-relaxed">
|
||||
站点当前暂停发表新评论与回复,历史评论仍可浏览。
|
||||
</p>
|
||||
</div>
|
||||
) : user ? (
|
||||
{user ? (
|
||||
<form onSubmit={handleSubmit} className="mb-8">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -1106,9 +1070,7 @@ export default function CommentSection({
|
||||
<p className="text-sm" style={{ color: "var(--ink-2)" }}>
|
||||
暂无评论
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-1">
|
||||
{commentsOpen ? "来说两句,抢占沙发" : "评论开放后即可参与讨论"}
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-1">来说两句,抢占沙发</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
52
frontend/components/CommentsPolicyProvider.tsx
Normal file
52
frontend/components/CommentsPolicyProvider.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||
|
||||
const AllowCommentsContext = createContext(true);
|
||||
|
||||
/** 前台是否开放评论(SSR 初值 + 后台开关热更新) */
|
||||
export function useAllowComments(): boolean {
|
||||
return useContext(AllowCommentsContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根布局注入:关闭评论后前台立即隐藏评论区/数量;开启则刷新以拉回评论。
|
||||
*/
|
||||
export default function CommentsPolicyProvider({
|
||||
allowComments,
|
||||
children,
|
||||
}: {
|
||||
allowComments: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(allowComments);
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(allowComments);
|
||||
}, [allowComments]);
|
||||
|
||||
useEffect(() => {
|
||||
return realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
|
||||
if (typeof data?.allow_comments !== "boolean") return;
|
||||
if (openRef.current === data.allow_comments) return;
|
||||
setOpen(data.allow_comments);
|
||||
router.refresh();
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<AllowCommentsContext.Provider value={open}>{children}</AllowCommentsContext.Provider>
|
||||
);
|
||||
}
|
||||
25
frontend/components/CommentsUnsupported.tsx
Normal file
25
frontend/components/CommentsUnsupported.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { MessageSquareOff } from "lucide-react";
|
||||
|
||||
/** 全站关闭评论时的前台说明:表达「不支持」而非暂时关闭,避免审核误判。 */
|
||||
export default function CommentsUnsupported() {
|
||||
return (
|
||||
<section className="panel p-6 sm:p-8 mt-6 text-center" aria-label="评论">
|
||||
<span
|
||||
className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-xl"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--ink-3) 14%, transparent)",
|
||||
color: "var(--ink-3)",
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
<MessageSquareOff size={22} />
|
||||
</span>
|
||||
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
本站不支持评论
|
||||
</p>
|
||||
<p className="meta mt-1.5 text-[12.5px] leading-relaxed">
|
||||
站点未提供评论功能,讨论请通过发帖进行。
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -13,10 +13,12 @@ export default function MobilePostBar({
|
||||
postId,
|
||||
liked,
|
||||
likeCount,
|
||||
allowComments = true,
|
||||
}: {
|
||||
postId: string;
|
||||
liked: boolean;
|
||||
likeCount: number;
|
||||
allowComments?: boolean;
|
||||
}) {
|
||||
const [showTop, setShowTop] = useState(false);
|
||||
|
||||
@@ -53,15 +55,17 @@ export default function MobilePostBar({
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToComments}
|
||||
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
aria-label="跳到评论框"
|
||||
>
|
||||
<MessageSquare size={18} />
|
||||
</button>
|
||||
{allowComments ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToComments}
|
||||
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
aria-label="跳到评论框"
|
||||
>
|
||||
<MessageSquare size={18} />
|
||||
</button>
|
||||
) : null}
|
||||
{/* 放大点赞按钮的触控区域(样式见 globals.css .j13-bar-like) */}
|
||||
<div className="j13-bar-like">
|
||||
<LikeButton postId={postId} liked={liked} likeCount={likeCount} variant="compact" />
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import { useAllowComments } from "@/components/CommentsPolicyProvider";
|
||||
|
||||
export default function PostInteractPanels({
|
||||
postId,
|
||||
@@ -177,6 +178,7 @@ function DeadlineCountdown({
|
||||
|
||||
/** 正文下方的问答状态卡(未解决 / 仅标记已解决);有采纳答案时改用 AcceptedAnswerCard */
|
||||
export function QuestionStatusCard({ postId, state }: { postId: number; state: QuestionState }) {
|
||||
const allowComments = useAllowComments();
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -241,7 +243,7 @@ export function QuestionStatusCard({ postId, state }: { postId: number; state: Q
|
||||
|
||||
<p className="j13-typed-meta">
|
||||
{state.solved
|
||||
? state.accepted_comment_id
|
||||
? state.accepted_comment_id && allowComments
|
||||
? (
|
||||
<>
|
||||
已采纳{" "}
|
||||
@@ -255,7 +257,9 @@ export function QuestionStatusCard({ postId, state }: { postId: number; state: Q
|
||||
</>
|
||||
)
|
||||
: "作者已标记为已解决"
|
||||
: "可在评论旁采纳答案,或手动标记已解决"}
|
||||
: allowComments
|
||||
? "可在评论旁采纳答案,或手动标记已解决"
|
||||
: "可由作者标记为已解决"}
|
||||
</p>
|
||||
|
||||
<div className="j13-typed-foot">
|
||||
@@ -466,6 +470,7 @@ function PollPanel({
|
||||
}
|
||||
|
||||
function BountyPanel({ state }: { postId: number; state: BountyState }) {
|
||||
const allowComments = useAllowComments();
|
||||
const statusBadge = state.accepted_comment_id
|
||||
? { cls: "badge-status badge-status-solved", label: "已采纳" }
|
||||
: state.expired || state.refunded
|
||||
@@ -507,7 +512,7 @@ function BountyPanel({ state }: { postId: number; state: BountyState }) {
|
||||
<p className="j13-typed-meta">
|
||||
<span className="j13-bounty-points tabular-nums">{state.points} 积分</span>
|
||||
{bodyHint ? ` · ${bodyHint}` : null}
|
||||
{state.can_accept ? " · 在评论旁点击「采纳」发放积分" : null}
|
||||
{state.can_accept && allowComments ? " · 在评论旁点击「采纳」发放积分" : null}
|
||||
</p>
|
||||
|
||||
{!state.accepted_comment_id && state.ends_at ? (
|
||||
@@ -518,7 +523,7 @@ function BountyPanel({ state }: { postId: number; state: BountyState }) {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state.accepted_comment_id > 0 && (
|
||||
{allowComments && state.accepted_comment_id > 0 && (
|
||||
<p className="j13-typed-meta">
|
||||
已采纳{" "}
|
||||
<a
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import type { PostListItem } from "@/lib/api";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
|
||||
import LikeButton from "./LikeButton";
|
||||
import Avatar from "./Avatar";
|
||||
@@ -15,7 +16,7 @@ import { IconComment, IconEye } from "./Icons";
|
||||
* 点击:标题 stretch-link 铺满整行进帖;仅头像/作者/回复者/点赞等加 above-link 抬层。
|
||||
* 板块胶囊仅展示,不可点,避免误进板块筛选。
|
||||
*/
|
||||
export default function PostRow({
|
||||
export default async function PostRow({
|
||||
post,
|
||||
showBoard = false,
|
||||
index = 0,
|
||||
@@ -31,8 +32,12 @@ export default function PostRow({
|
||||
showViews?: boolean;
|
||||
showLike?: boolean;
|
||||
}) {
|
||||
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
|
||||
const lastReply = post.last_reply;
|
||||
const replied = lastReply && new Date(lastReply.created_at).getTime() > new Date(post.created_at).getTime() + 60_000;
|
||||
const replied =
|
||||
allowComments &&
|
||||
lastReply &&
|
||||
new Date(lastReply.created_at).getTime() > new Date(post.created_at).getTime() + 60_000;
|
||||
const pt = normalizePostType(post.post_type);
|
||||
const statusBadge = pt !== "discussion" ? typeStatusLabel(pt, post.type_status) : "";
|
||||
const statusClass = statusBadge ? typeStatusClass(pt, post.type_status) : "";
|
||||
@@ -115,7 +120,7 @@ export default function PostRow({
|
||||
<span className="tabular-nums shrink-0">{formatRelative(lastReply!.created_at)}</span>
|
||||
</span>
|
||||
)}
|
||||
{!replied && post.comment_count === 0 && (
|
||||
{!replied && allowComments && post.comment_count === 0 && (
|
||||
<span className="hidden sm:inline" aria-hidden>
|
||||
· 暂无回复
|
||||
</span>
|
||||
@@ -124,15 +129,17 @@ export default function PostRow({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 sm:gap-4 shrink-0">
|
||||
<Link
|
||||
href={`/post/${post.id}`}
|
||||
tabIndex={-1}
|
||||
aria-label={`${post.comment_count} 条评论`}
|
||||
className={`${showViews ? "hidden sm:inline-flex" : "inline-flex"} above-link items-center gap-1.5 meta tabular-nums text-[13.5px] hover:text-[var(--accent)] transition-colors`}
|
||||
>
|
||||
<IconComment size={17} />
|
||||
{post.comment_count}
|
||||
</Link>
|
||||
{allowComments ? (
|
||||
<Link
|
||||
href={`/post/${post.id}`}
|
||||
tabIndex={-1}
|
||||
aria-label={`${post.comment_count} 条评论`}
|
||||
className={`${showViews ? "hidden sm:inline-flex" : "inline-flex"} above-link items-center gap-1.5 meta tabular-nums text-[13.5px] hover:text-[var(--accent)] transition-colors`}
|
||||
>
|
||||
<IconComment size={17} />
|
||||
{post.comment_count}
|
||||
</Link>
|
||||
) : null}
|
||||
{showViews && (
|
||||
<span
|
||||
className="hidden md:inline-flex items-center gap-1.5 meta tabular-nums"
|
||||
|
||||
81
frontend/components/admin/AdminField.tsx
Normal file
81
frontend/components/admin/AdminField.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { ChangeEvent, ReactNode } from "react";
|
||||
|
||||
/** 后台文本框:与聊天搜索同款壳层,聚焦只画外框一圈 */
|
||||
export default function AdminField({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
maxLength,
|
||||
as = "input",
|
||||
type = "text",
|
||||
autoComplete,
|
||||
disabled,
|
||||
className = "",
|
||||
minHeight,
|
||||
}: {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
hint?: ReactNode;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
as?: "input" | "textarea";
|
||||
type?: string;
|
||||
autoComplete?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
minHeight?: string;
|
||||
}) {
|
||||
const handle = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
onChange(e.target.value);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="admin-field-head">
|
||||
<label className="field-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
{maxLength != null && (
|
||||
<span className="admin-field-count" aria-hidden>
|
||||
{value.length}/{maxLength}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hint != null && hint !== "" && (
|
||||
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">{hint}</p>
|
||||
)}
|
||||
{as === "textarea" ? (
|
||||
<div className="admin-field-shell is-area">
|
||||
<textarea
|
||||
id={id}
|
||||
className="admin-field-input"
|
||||
style={minHeight ? { minHeight } : undefined}
|
||||
maxLength={maxLength}
|
||||
value={value}
|
||||
onChange={handle}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-field-shell">
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
className="admin-field-input"
|
||||
maxLength={maxLength}
|
||||
value={value}
|
||||
onChange={handle}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Search, X } from "lucide-react";
|
||||
import type { FormEvent } from "react";
|
||||
|
||||
/** 左图标、可清除、Enter 提交;不要再并排「搜索」按钮 */
|
||||
/** 左图标、可清除、Enter 提交;壳层聚焦与聊天搜索一致 */
|
||||
export default function AdminSearchField({
|
||||
value,
|
||||
onChange,
|
||||
@@ -30,11 +30,11 @@ export default function AdminSearchField({
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="relative flex-1 min-w-0" role="search">
|
||||
<form onSubmit={handleSubmit} className="admin-field-shell admin-search-shell relative flex-1 min-w-0" role="search">
|
||||
<Search
|
||||
size={15}
|
||||
className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
className="shrink-0 pointer-events-none"
|
||||
style={{ color: value ? "var(--accent)" : "var(--ink-3)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
<input
|
||||
@@ -43,14 +43,14 @@ export default function AdminSearchField({
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
className="field pl-10 pr-9 py-2.5"
|
||||
className="admin-field-input"
|
||||
/>
|
||||
{value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
aria-label="清除搜索"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 w-6 h-6 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
|
||||
className="w-6 h-6 inline-flex items-center justify-center rounded-full shrink-0 transition-colors hover:bg-[var(--accent-soft)]"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
<X size={14} />
|
||||
|
||||
94
frontend/components/admin/AdminStepper.tsx
Normal file
94
frontend/components/admin/AdminStepper.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
import { useEffect, useState, type KeyboardEvent } from "react";
|
||||
|
||||
/** 后台数字步进器:0 是合法值;加减与手输都会提交数字 */
|
||||
export default function AdminStepper({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max = 999,
|
||||
step = 1,
|
||||
unit,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
}: {
|
||||
id?: string;
|
||||
value: number;
|
||||
onChange: (n: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
ariaLabel: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const clamp = (n: number) => Math.min(max, Math.max(min, Math.round(n)));
|
||||
const [text, setText] = useState(() => String(value));
|
||||
|
||||
useEffect(() => {
|
||||
setText(String(value));
|
||||
}, [value]);
|
||||
|
||||
const emit = (n: number) => {
|
||||
const next = clamp(Number.isFinite(n) ? n : min);
|
||||
onChange(next);
|
||||
setText(String(next));
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
emit(value + step);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
emit(value - step);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`admin-stepper${disabled ? " is-disabled" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-stepper-btn"
|
||||
onClick={() => emit(value - step)}
|
||||
disabled={disabled || value <= min}
|
||||
aria-label="减少"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<input
|
||||
id={id}
|
||||
className="admin-stepper-input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={text}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.replace(/[^\d]/g, "");
|
||||
setText(raw);
|
||||
if (raw === "") return;
|
||||
onChange(clamp(Number(raw)));
|
||||
}}
|
||||
onBlur={() => emit(text === "" ? min : Number(text))}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{unit ? <span className="admin-stepper-unit">{unit}</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="admin-stepper-btn"
|
||||
onClick={() => emit(value + step)}
|
||||
disabled={disabled || value >= max}
|
||||
aria-label="增加"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export { default as AdminSettingRow } from "./AdminSettingRow";
|
||||
export { default as AdminSegmented } from "./AdminSegmented";
|
||||
export type { AdminSegmentOption } from "./AdminSegmented";
|
||||
export { default as AdminSearchField } from "./AdminSearchField";
|
||||
export { default as AdminField } from "./AdminField";
|
||||
export { default as AdminStepper } from "./AdminStepper";
|
||||
export { default as AdminPager } from "./AdminPager";
|
||||
export { default as AdminEmpty } from "./AdminEmpty";
|
||||
export { default as AdminStatCard } from "./AdminStatCard";
|
||||
|
||||
@@ -85,9 +85,11 @@ function BoardIntro({ board }: { board: BoardWithCount }) {
|
||||
function BoardPulse({
|
||||
stats,
|
||||
checkin,
|
||||
allowComments,
|
||||
}: {
|
||||
stats: BoardSidebarStats;
|
||||
checkin?: CheckinStatus | null;
|
||||
allowComments: boolean;
|
||||
}) {
|
||||
const cells = [
|
||||
{
|
||||
@@ -104,20 +106,24 @@ function BoardPulse({
|
||||
foot: "本日新增",
|
||||
up: stats.today_posts > 0,
|
||||
},
|
||||
{
|
||||
icon: <MessageCircle size={12.5} />,
|
||||
label: "回复总数",
|
||||
value: formatCompact(stats.comment_count),
|
||||
foot: `+${formatCompact(stats.today_comments)} 今日`,
|
||||
up: true,
|
||||
},
|
||||
{
|
||||
icon: <Heart size={12.5} />,
|
||||
label: "今日回复",
|
||||
value: formatCompact(stats.today_comments),
|
||||
foot: "本日新增",
|
||||
up: stats.today_comments > 0,
|
||||
},
|
||||
...(allowComments
|
||||
? [
|
||||
{
|
||||
icon: <MessageCircle size={12.5} />,
|
||||
label: "回复总数",
|
||||
value: formatCompact(stats.comment_count),
|
||||
foot: `+${formatCompact(stats.today_comments)} 今日`,
|
||||
up: true,
|
||||
},
|
||||
{
|
||||
icon: <Heart size={12.5} />,
|
||||
label: "今日回复",
|
||||
value: formatCompact(stats.today_comments),
|
||||
foot: "本日新增",
|
||||
up: stats.today_comments > 0,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return (
|
||||
<section className="j13-module" aria-label="板块数据">
|
||||
@@ -150,7 +156,7 @@ function BoardPulse({
|
||||
|
||||
/* ---------- 4. 本板热门 ---------- */
|
||||
|
||||
function BoardHot({ posts }: { posts: PostListItem[] }) {
|
||||
function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
|
||||
return (
|
||||
<section className="j13-module" aria-label="本板热门">
|
||||
<p className="j13-module-title mb-1.5">
|
||||
@@ -169,9 +175,11 @@ function BoardHot({ posts }: { posts: PostListItem[] }) {
|
||||
{p.title}
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-2.5 meta text-[11.5px]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageCircle size={11.5} /> {p.comment_count}
|
||||
</span>
|
||||
{allowComments ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageCircle size={11.5} /> {p.comment_count}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Heart size={11.5} /> {p.like_count}
|
||||
</span>
|
||||
@@ -188,7 +196,7 @@ function BoardHot({ posts }: { posts: PostListItem[] }) {
|
||||
|
||||
/* ---------- 5. 本板活跃 ---------- */
|
||||
|
||||
function BoardActive({ users }: { users: ActiveUser[] }) {
|
||||
function BoardActive({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
|
||||
return (
|
||||
<section className="j13-module" aria-label="本板活跃成员">
|
||||
<p className="j13-module-title mb-2">
|
||||
@@ -221,17 +229,23 @@ function BoardActive({ users }: { users: ActiveUser[] }) {
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<PenLine size={10.5} /> 发帖 {u.post_count}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageCircle size={10.5} /> 回复 {u.comment_count}
|
||||
</span>
|
||||
{allowComments ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageCircle size={10.5} /> 回复 {u.comment_count}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-[13px] tabular-nums"
|
||||
style={{ color: "var(--accent)" }}
|
||||
aria-label={`本周共 ${u.post_count + u.comment_count} 次互动`}
|
||||
aria-label={
|
||||
allowComments
|
||||
? `本周共 ${u.post_count + u.comment_count} 次互动`
|
||||
: `本周发帖 ${u.post_count}`
|
||||
}
|
||||
>
|
||||
{u.post_count + u.comment_count}
|
||||
{allowComments ? u.post_count + u.comment_count : u.post_count}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -252,6 +266,7 @@ type BoardPanelsProps = {
|
||||
hot: PostListItem[];
|
||||
activeUsers: ActiveUser[];
|
||||
checkin?: CheckinStatus | null;
|
||||
allowComments: boolean;
|
||||
};
|
||||
|
||||
function BoardPanelsBody(props: BoardPanelsProps) {
|
||||
@@ -259,9 +274,9 @@ function BoardPanelsBody(props: BoardPanelsProps) {
|
||||
<>
|
||||
<Moderators items={props.moderators} />
|
||||
<BoardIntro board={props.board} />
|
||||
<BoardPulse stats={props.stats} checkin={props.checkin} />
|
||||
<BoardHot posts={props.hot} />
|
||||
<BoardActive users={props.activeUsers} />
|
||||
<BoardPulse stats={props.stats} checkin={props.checkin} allowComments={props.allowComments} />
|
||||
<BoardHot posts={props.hot} allowComments={props.allowComments} />
|
||||
<BoardActive users={props.activeUsers} allowComments={props.allowComments} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,15 @@ import { formatCompact, formatShortDate } from "@/lib/format";
|
||||
|
||||
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
|
||||
|
||||
function PulseCard({ stats, checkin }: { stats: OverviewStats; checkin?: CheckinStatus | null }) {
|
||||
function PulseCard({
|
||||
stats,
|
||||
checkin,
|
||||
allowComments,
|
||||
}: {
|
||||
stats: OverviewStats;
|
||||
checkin?: CheckinStatus | null;
|
||||
allowComments: boolean;
|
||||
}) {
|
||||
const cells = [
|
||||
{
|
||||
icon: <Users size={12.5} />,
|
||||
@@ -43,13 +51,25 @@ function PulseCard({ stats, checkin }: { stats: OverviewStats; checkin?: Checkin
|
||||
foot: `+${formatCompact(stats.today_posts)} 今日`,
|
||||
up: true,
|
||||
},
|
||||
{
|
||||
icon: <MessageCircle size={12.5} />,
|
||||
label: "回复总数",
|
||||
value: formatCompact(stats.comments),
|
||||
foot: `+${formatCompact(stats.today_comments)} 今日`,
|
||||
up: true,
|
||||
},
|
||||
...(allowComments
|
||||
? [
|
||||
{
|
||||
icon: <MessageCircle size={12.5} />,
|
||||
label: "回复总数",
|
||||
value: formatCompact(stats.comments),
|
||||
foot: `+${formatCompact(stats.today_comments)} 今日`,
|
||||
up: true,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
icon: <TrendingUp size={12.5} />,
|
||||
label: "今日新帖",
|
||||
value: formatCompact(stats.today_posts),
|
||||
foot: "本日新增",
|
||||
up: stats.today_posts > 0,
|
||||
},
|
||||
]),
|
||||
{
|
||||
icon: <Activity size={12.5} />,
|
||||
label: "在线用户",
|
||||
@@ -138,7 +158,7 @@ function Announcements({ items }: { items: AnnouncementSummary[] }) {
|
||||
|
||||
/* ---------- 热门榜 ---------- */
|
||||
|
||||
function HotRanking({ posts }: { posts: PostListItem[] }) {
|
||||
function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
|
||||
return (
|
||||
<section className="j13-module" aria-label="热门讨论">
|
||||
<p className="j13-module-title mb-1.5">
|
||||
@@ -157,9 +177,11 @@ function HotRanking({ posts }: { posts: PostListItem[] }) {
|
||||
{p.title}
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-2.5 meta text-[11.5px]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageCircle size={11.5} /> {p.comment_count}
|
||||
</span>
|
||||
{allowComments ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageCircle size={11.5} /> {p.comment_count}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Heart size={11.5} /> {p.like_count}
|
||||
</span>
|
||||
@@ -211,7 +233,7 @@ function NewUsers({ users }: { users: NewUser[] }) {
|
||||
|
||||
/* ---------- 活跃成员(本周发帖 / 回复) ---------- */
|
||||
|
||||
function ActiveUsers({ users }: { users: ActiveUser[] }) {
|
||||
function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
|
||||
return (
|
||||
<section className="j13-module" aria-label="本周活跃成员">
|
||||
<p className="j13-module-title mb-2">
|
||||
@@ -241,17 +263,23 @@ function ActiveUsers({ users }: { users: ActiveUser[] }) {
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<PenLine size={10.5} /> 发帖 {u.post_count}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageCircle size={10.5} /> 回复 {u.comment_count}
|
||||
</span>
|
||||
{allowComments ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageCircle size={10.5} /> 回复 {u.comment_count}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-[13px] tabular-nums"
|
||||
style={{ color: "var(--accent)" }}
|
||||
aria-label={`本周共 ${u.post_count + u.comment_count} 次互动`}
|
||||
aria-label={
|
||||
allowComments
|
||||
? `本周共 ${u.post_count + u.comment_count} 次互动`
|
||||
: `本周发帖 ${u.post_count}`
|
||||
}
|
||||
>
|
||||
{u.post_count + u.comment_count}
|
||||
{allowComments ? u.post_count + u.comment_count : u.post_count}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -309,46 +337,44 @@ type PanelsProps = {
|
||||
announcements: AnnouncementSummary[];
|
||||
newUsers: NewUser[];
|
||||
checkin?: CheckinStatus | null;
|
||||
allowComments: boolean;
|
||||
};
|
||||
|
||||
export function CommunityPanels({
|
||||
function CommunityPanelsBody({
|
||||
stats,
|
||||
hot,
|
||||
activeUsers,
|
||||
announcements,
|
||||
newUsers,
|
||||
checkin,
|
||||
allowComments,
|
||||
}: PanelsProps) {
|
||||
return (
|
||||
<>
|
||||
<PulseCard stats={stats} checkin={checkin} allowComments={allowComments} />
|
||||
<Announcements items={announcements} />
|
||||
<HotRanking posts={hot} allowComments={allowComments} />
|
||||
<NewUsers users={newUsers} />
|
||||
<ActiveUsers users={activeUsers} allowComments={allowComments} />
|
||||
<FriendLinks />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommunityPanels(props: PanelsProps) {
|
||||
return (
|
||||
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="社区面板">
|
||||
<div className="sticky top-24 space-y-4">
|
||||
<PulseCard stats={stats} checkin={checkin} />
|
||||
<Announcements items={announcements} />
|
||||
<HotRanking posts={hot} />
|
||||
<NewUsers users={newUsers} />
|
||||
<ActiveUsers users={activeUsers} />
|
||||
<FriendLinks />
|
||||
<CommunityPanelsBody {...props} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommunityPanelsMobile({
|
||||
stats,
|
||||
hot,
|
||||
activeUsers,
|
||||
announcements,
|
||||
newUsers,
|
||||
checkin,
|
||||
}: PanelsProps) {
|
||||
export function CommunityPanelsMobile(props: PanelsProps) {
|
||||
return (
|
||||
<div className="space-y-4 lg:hidden" aria-label="社区面板">
|
||||
<PulseCard stats={stats} checkin={checkin} />
|
||||
<Announcements items={announcements} />
|
||||
<HotRanking posts={hot} />
|
||||
<NewUsers users={newUsers} />
|
||||
<ActiveUsers users={activeUsers} />
|
||||
<FriendLinks />
|
||||
<CommunityPanelsBody {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef } from "react";
|
||||
import { MessageSquareText, Clock, Sparkles } from "lucide-react";
|
||||
import { useAllowComments } from "@/components/CommentsPolicyProvider";
|
||||
|
||||
export type FeedSort = "latest" | "new" | "recommended";
|
||||
|
||||
@@ -22,18 +23,20 @@ function tabHref(key: FeedSort, boardId: number) {
|
||||
export default function FeedTabs({ current, boardId = 0 }: { current: FeedSort; boardId?: number }) {
|
||||
const router = useRouter();
|
||||
const tabRefs = useRef<(HTMLAnchorElement | null)[]>([]);
|
||||
const allowComments = useAllowComments();
|
||||
const keys = allowComments ? TAB_KEYS : TAB_KEYS.filter((k) => k !== "latest");
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent, i: number) => {
|
||||
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
|
||||
e.preventDefault();
|
||||
const next = (i + (e.key === "ArrowRight" ? 1 : TAB_KEYS.length - 1)) % TAB_KEYS.length;
|
||||
const next = (i + (e.key === "ArrowRight" ? 1 : keys.length - 1)) % keys.length;
|
||||
tabRefs.current[next]?.focus();
|
||||
router.push(tabHref(TAB_KEYS[next], boardId));
|
||||
router.push(tabHref(keys[next], boardId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 sm:gap-2" role="tablist" aria-label="帖子排序">
|
||||
{TAB_KEYS.map((key, i) => {
|
||||
{keys.map((key, i) => {
|
||||
const Icon = key === "latest" ? MessageSquareText : key === "new" ? Clock : Sparkles;
|
||||
return (
|
||||
<Link
|
||||
|
||||
Reference in New Issue
Block a user