整体UI调整
This commit is contained in:
@@ -1,284 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
import { useMockUser } from "@/components/profile/use-mock-user";
|
||||
import type { CommentInfo } from "@/lib/api/types";
|
||||
|
||||
import { COMMENT_MAX_LENGTH, COMMENT_SELF_NAME, COMMENT_SORTS, formatCommentTime, type CommentTarget } from "./data";
|
||||
import { CommentReplyPreview } from "./comment-reply-preview";
|
||||
import { HeartIcon } from "./heart-icon";
|
||||
import { InlineReplyBox } from "./inline-reply-box";
|
||||
import { useComments } from "./use-comments";
|
||||
|
||||
const fallbackAvatar = "/images/default_avatar.svg";
|
||||
|
||||
const panelStyles = {
|
||||
detail: {
|
||||
section: "rounded-2xl bg-[rgba(24,23,46,0.88)] outline-[#383657]",
|
||||
item: "py-4",
|
||||
more: "mt-4 h-[43px] rounded-[7px]",
|
||||
},
|
||||
play: {
|
||||
section: "rounded-xl bg-[rgba(19,15,22,0.91)] outline-[rgba(199,145,89,0.7)]",
|
||||
item: "py-3",
|
||||
more: "mt-3 h-8 rounded-lg",
|
||||
},
|
||||
};
|
||||
|
||||
type ReplyTarget = {
|
||||
id: number;
|
||||
userName: string;
|
||||
};
|
||||
|
||||
function CommentComposer({
|
||||
onSubmit,
|
||||
posting,
|
||||
error,
|
||||
}: {
|
||||
onSubmit: (content: string) => Promise<boolean>;
|
||||
posting: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const { user } = useMockUser();
|
||||
const avatar = user?.avatar || fallbackAvatar;
|
||||
const displayName = user?.name || COMMENT_SELF_NAME;
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const content = text.trim();
|
||||
if (!content || posting) return;
|
||||
const ok = await onSubmit(content);
|
||||
if (ok) setText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<form onSubmit={submit} className="flex h-[76px] items-center gap-4 rounded-[10px] bg-[#211F38] px-4 py-3">
|
||||
<DecryptedImage
|
||||
src={avatar}
|
||||
fallback={fallbackAvatar}
|
||||
alt=""
|
||||
className="size-9 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<label className="flex h-[52px] min-w-0 flex-1 items-center gap-3 rounded-lg bg-[#110F1F] px-3 py-2.5 outline outline-1 -outline-offset-1 outline-[#383657] focus-within:outline-[#F12B9E]">
|
||||
<input
|
||||
value={text}
|
||||
maxLength={COMMENT_MAX_LENGTH}
|
||||
disabled={posting}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
placeholder="说说你对这部剧的看法…"
|
||||
aria-label="评论内容"
|
||||
className="min-w-0 flex-1 bg-transparent text-xs leading-[18px] text-white outline-none placeholder:text-[#948CA8] disabled:opacity-60"
|
||||
/>
|
||||
<span className="flex shrink-0 gap-2 text-xs leading-[18px]">
|
||||
<span className="text-[#F12B9E]">已登录</span>
|
||||
<span className="text-[#948CA8]">{displayName}</span>
|
||||
<span className="text-[#736E87]">
|
||||
{text.length} / {COMMENT_MAX_LENGTH}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!text.trim() || posting}
|
||||
className="cursor-pointer h-[52px] w-[120px] shrink-0 rounded-[10px] bg-[#F12B9E] text-xs leading-[18px] font-semibold text-white transition-colors enabled:hover:bg-[#F23DA8] disabled:opacity-60"
|
||||
>
|
||||
{posting ? "发布中…" : "发表评论"}
|
||||
</button>
|
||||
</form>
|
||||
{error && !posting ? <p className="text-xs leading-[18px] text-[#FF8FA8]">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** PC 评论面板(详情 / 播放页)— GET /api/app/comment/list */
|
||||
export function CommentPanel({
|
||||
variant = "detail",
|
||||
target,
|
||||
}: {
|
||||
variant?: keyof typeof panelStyles;
|
||||
target: CommentTarget | null;
|
||||
}) {
|
||||
const {
|
||||
sort,
|
||||
setSort,
|
||||
comments,
|
||||
total,
|
||||
hasMore,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
toggleLike,
|
||||
post,
|
||||
posting,
|
||||
postError,
|
||||
reload,
|
||||
} = useComments({ target });
|
||||
const styles = panelStyles[variant];
|
||||
const [replyTo, setReplyTo] = useState<ReplyTarget | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
|
||||
|
||||
const startReply = (comment: CommentInfo) => {
|
||||
const id = Number(comment.id);
|
||||
if (!Number.isInteger(id) || id < 1) return;
|
||||
setReplyTo({ id, userName: (comment.userName || "").trim() || "用户" });
|
||||
};
|
||||
|
||||
const toggleExpanded = (commentId: number) => {
|
||||
setExpandedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(commentId)) next.delete(commentId);
|
||||
else next.add(commentId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const submitRoot = async (content: string) => post(content);
|
||||
|
||||
const submitReply = async (content: string) => {
|
||||
if (!replyTo) return false;
|
||||
const ok = await post(content, { parentsId: replyTo.id });
|
||||
if (ok) setReplyTo(null);
|
||||
return ok;
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
data-component="comment-panel"
|
||||
className={`flex w-[1196px] shrink-0 flex-col p-6 outline outline-1 -outline-offset-1 ${styles.section}`}
|
||||
>
|
||||
<div className="flex h-10 items-center justify-between">
|
||||
<h2 className="text-[22px] leading-[30px] text-white">评论 ({total})</h2>
|
||||
<div className="flex h-8 items-center gap-3 rounded-[7px] bg-[#110F1F] px-2 text-xs leading-[18px]">
|
||||
{COMMENT_SORTS.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
aria-pressed={sort === item}
|
||||
onClick={() => setSort(item)}
|
||||
className={`cursor-pointer ${sort === item ? "text-white" : "text-[#948CA8] hover:text-white"}`}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CommentComposer
|
||||
onSubmit={submitRoot}
|
||||
posting={posting && !replyTo}
|
||||
error={!replyTo ? postError : null}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-[#948CA8]">评论加载中…</p>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center gap-3 py-8">
|
||||
<p className="text-sm text-[#948CA8]">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reload}
|
||||
className="cursor-pointer rounded-lg bg-[#211F38] px-4 py-2 text-xs text-white hover:opacity-90"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-[#948CA8]">暂无评论</p>
|
||||
) : (
|
||||
<ul>
|
||||
{comments.map((comment) => {
|
||||
const liked = Boolean(comment.isLike);
|
||||
const commentId = Number(comment.id) || 0;
|
||||
const expanded = expandedIds.has(commentId);
|
||||
const replies = comment.subComment ?? [];
|
||||
const isReplying = replyTo?.id === commentId;
|
||||
return (
|
||||
<li key={comment.id} className={`flex gap-4 border-b border-[#2E2B47] ${styles.item}`}>
|
||||
<DecryptedImage
|
||||
src={comment.avatar}
|
||||
fallback="/images/default_avatar.svg"
|
||||
alt=""
|
||||
className="size-12 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<p className="text-sm leading-[21px] text-[#948CA8]">{comment.userName}</p>
|
||||
<p className="text-base leading-6 text-white">{comment.text}</p>
|
||||
<div className="flex items-center gap-2 text-xs leading-[18px] text-[#948CA8]">
|
||||
<span className="font-semibold">{formatCommentTime(comment.createdAt)}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={liked ? "取消点赞" : "点赞"}
|
||||
aria-pressed={liked}
|
||||
onClick={() => void toggleLike(comment)}
|
||||
className="cursor-pointer flex items-center gap-2 font-semibold"
|
||||
>
|
||||
<HeartIcon filled={liked} />
|
||||
{comment.countLike ?? 0}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (isReplying ? setReplyTo(null) : startReply(comment))}
|
||||
className={`cursor-pointer hover:text-white ${isReplying ? "text-[#F12B9E]" : ""}`}
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
</div>
|
||||
{isReplying && replyTo ? (
|
||||
<InlineReplyBox
|
||||
userName={replyTo.userName}
|
||||
posting={posting}
|
||||
error={postError}
|
||||
onSubmit={submitReply}
|
||||
onCancel={() => setReplyTo(null)}
|
||||
/>
|
||||
) : null}
|
||||
{expanded && replies.length > 1 ? (
|
||||
<div className="flex flex-col gap-2 rounded-lg bg-[#211F38] px-3 py-2.5 text-xs leading-[18px] text-[#EBE8F5]">
|
||||
{replies.map((reply) => (
|
||||
<p key={reply.id}>
|
||||
{reply.replyUserName
|
||||
? `${reply.userName || ""} 回复 ${reply.replyUserName}:${reply.text || ""}`
|
||||
: `${reply.userName || ""}:${reply.text || ""}`}
|
||||
</p>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(commentId)}
|
||||
className="cursor-pointer self-start text-[#F12B9E]"
|
||||
>
|
||||
收起回复
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<CommentReplyPreview
|
||||
comment={comment}
|
||||
className="flex flex-col gap-1.5 rounded-lg bg-[#211F38] px-3 py-2.5 text-xs leading-[18px]"
|
||||
textClassName="text-[#EBE8F5]"
|
||||
onMore={replies.length > 1 ? () => toggleExpanded(commentId) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!loading && !error && comments.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasMore || loadingMore}
|
||||
onClick={loadMore}
|
||||
className={`cursor-pointer ${styles.more} bg-[#211F38] text-xs leading-[18px] text-[#948CA8] enabled:hover:text-white disabled:opacity-60`}
|
||||
>
|
||||
{loadingMore ? "加载中…" : hasMore ? "加载更多" : "没有更多了"}
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { CommentInfo } from "@/lib/api/types";
|
||||
|
||||
/** 直接展示接口 subComment / commentCount,不做字段改名 */
|
||||
export function CommentReplyPreview({
|
||||
comment,
|
||||
className = "",
|
||||
textClassName = "",
|
||||
onMore,
|
||||
}: {
|
||||
comment: CommentInfo;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
onMore?: () => void;
|
||||
}) {
|
||||
const first = comment.subComment?.[0];
|
||||
const more = Number(comment.commentCount) || comment.subComment?.length || 0;
|
||||
if (!first && more < 1) return null;
|
||||
|
||||
const replyLabel = first
|
||||
? first.replyUserName
|
||||
? `${first.userName || ""} 回复 ${first.replyUserName}:${first.text || ""}`
|
||||
: `${first.userName || ""}:${first.text || ""}`
|
||||
: `共 ${more} 条回复`;
|
||||
|
||||
return (
|
||||
<div data-component="comment-reply-preview" className={className}>
|
||||
<p className={textClassName}>{replyLabel}</p>
|
||||
{more > 0 && onMore ? (
|
||||
<button type="button" onClick={onMore} className="cursor-pointer self-start text-[#F12B9E]">
|
||||
查看更多 {more} 条回复 ›
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { COMMENT_OBJECT_TYPE, type CommentInfo, type CommentObjectType } from "@/lib/api/types";
|
||||
|
||||
export type { CommentInfo };
|
||||
|
||||
export type CommentTarget = {
|
||||
objectId: number;
|
||||
objectType: CommentObjectType;
|
||||
};
|
||||
|
||||
export const COMMENT_SORTS = ["热门", "最新"] as const;
|
||||
export type CommentSort = (typeof COMMENT_SORTS)[number];
|
||||
|
||||
export const COMMENT_SELF_NAME = "剧迷1024";
|
||||
export const COMMENT_MAX_LENGTH = 200;
|
||||
export const COMMENT_PAGE_SIZE = 6;
|
||||
/** 列表请求超时,避免上游卡住拖住 UI */
|
||||
export const COMMENT_FETCH_TIMEOUT_MS = 8000;
|
||||
|
||||
export function sortToOrder(sort: CommentSort): 1 | 2 {
|
||||
return sort === "热门" ? 1 : 2;
|
||||
}
|
||||
|
||||
/** 过滤无效评论,保留接口原字段 */
|
||||
export function filterCommentList(list: CommentInfo[] | null | undefined): CommentInfo[] {
|
||||
return (list ?? []).filter((item) => {
|
||||
const id = Number(item?.id);
|
||||
return Number.isInteger(id) && id > 0;
|
||||
});
|
||||
}
|
||||
|
||||
/** 本地乐观插入用,字段名与 CommentInfo 一致 */
|
||||
export function createSelfComment(
|
||||
text: string,
|
||||
options?: { userName?: string; avatar?: string },
|
||||
): CommentInfo {
|
||||
return {
|
||||
id: -Date.now(),
|
||||
userName: (options?.userName || "").trim() || COMMENT_SELF_NAME,
|
||||
avatar: (options?.avatar || "").trim() || "/images/default_avatar.svg",
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
countLike: 0,
|
||||
isLike: false,
|
||||
commentCount: 0,
|
||||
subComment: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function formatCommentCount(value: number) {
|
||||
return value >= 10000 ? `${(value / 10000).toFixed(1)}w` : String(value);
|
||||
}
|
||||
|
||||
/** 仅做展示格式化,不改字段名;空则回退原串 */
|
||||
export function formatCommentTime(value?: string | null) {
|
||||
const raw = (value || "").trim();
|
||||
if (!raw) return "";
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return raw;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function commentTarget(
|
||||
objectId: number | string | null | undefined,
|
||||
objectType: CommentObjectType,
|
||||
): CommentTarget | null {
|
||||
const id = Number(objectId);
|
||||
if (!Number.isInteger(id) || id < 1) return null;
|
||||
return { objectId: id, objectType };
|
||||
}
|
||||
|
||||
export function playletCommentTarget(playletId: number | string | null | undefined) {
|
||||
return commentTarget(playletId, COMMENT_OBJECT_TYPE.playlet);
|
||||
}
|
||||
|
||||
export function playletChapterCommentTarget(chapterId: number | string | null | undefined) {
|
||||
return commentTarget(chapterId, COMMENT_OBJECT_TYPE.playletChapter);
|
||||
}
|
||||
|
||||
export function videoCommentTarget(videoId: number | string | null | undefined) {
|
||||
return commentTarget(videoId, COMMENT_OBJECT_TYPE.video);
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
|
||||
import { COMMENT_MAX_LENGTH, COMMENT_SORTS, formatCommentTime, type CommentTarget } from "./data";
|
||||
import { CommentReplyPreview } from "./comment-reply-preview";
|
||||
import { HeartIcon } from "./heart-icon";
|
||||
import { InlineReplyBox } from "./inline-reply-box";
|
||||
import { useComments } from "./use-comments";
|
||||
|
||||
function SheetHead({ title, onClose, children }: { title: ReactNode; onClose: () => void; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="收起"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer flex size-6 items-center justify-center text-white"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
|
||||
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<h2 className="min-w-0 flex-1 text-center text-base leading-[22px] font-medium text-white">{title}</h2>
|
||||
{children ?? <span className="size-6" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** H5 短剧播放页评论底部弹层 */
|
||||
export function H5CommentSheet({ onClose, target }: { onClose: () => void; target: CommentTarget | null }) {
|
||||
const {
|
||||
sort,
|
||||
setSort,
|
||||
comments,
|
||||
total,
|
||||
hasMore,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
toggleLike,
|
||||
post,
|
||||
posting,
|
||||
postError,
|
||||
reload,
|
||||
} = useComments({ target });
|
||||
const [text, setText] = useState("");
|
||||
const [replyTo, setReplyTo] = useState<{ id: number; userName: string } | null>(null);
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const content = text.trim();
|
||||
if (!content || posting || replyTo) return;
|
||||
const ok = await post(content);
|
||||
if (ok) setText("");
|
||||
};
|
||||
|
||||
const submitReply = async (content: string) => {
|
||||
if (!replyTo) return false;
|
||||
const ok = await post(content, { parentsId: replyTo.id });
|
||||
if (ok) setReplyTo(null);
|
||||
return ok;
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-component="h5-comment-sheet" className="flex h-[70vh] flex-col">
|
||||
<div className="px-4 pt-[18px] pb-3">
|
||||
<SheetHead title={`评论 ${total}`} onClose={onClose}>
|
||||
<div className="flex gap-3 text-xs leading-[18px]">
|
||||
{COMMENT_SORTS.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
aria-pressed={sort === item}
|
||||
onClick={() => setSort(item)}
|
||||
className={`cursor-pointer ${sort === item ? "text-white" : "text-white/35"}`}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SheetHead>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4">
|
||||
{loading ? (
|
||||
<p className="py-10 text-center text-sm text-white/50">评论加载中…</p>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center gap-3 py-10">
|
||||
<p className="text-sm text-white/50">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reload}
|
||||
className="cursor-pointer rounded-md bg-white/10 px-4 py-2 text-xs text-white"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-white/50">暂无评论</p>
|
||||
) : (
|
||||
<ul>
|
||||
{comments.map((comment) => {
|
||||
const liked = Boolean(comment.isLike);
|
||||
const commentId = Number(comment.id) || 0;
|
||||
const isReplying = replyTo?.id === commentId;
|
||||
return (
|
||||
<li key={comment.id} className="flex gap-3 border-b border-white/5 py-3">
|
||||
<DecryptedImage
|
||||
src={comment.avatar}
|
||||
fallback="/images/default_avatar.svg"
|
||||
alt=""
|
||||
className="size-8 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<p className="text-xs leading-[18px] text-white/50">{comment.userName}</p>
|
||||
<p className="text-sm leading-5 text-white">{comment.text}</p>
|
||||
<div className="flex items-center gap-3 text-[11px] leading-4 text-white/35">
|
||||
<span>{formatCommentTime(comment.createdAt)}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={liked}
|
||||
onClick={() => void toggleLike(comment)}
|
||||
className="cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<HeartIcon filled={liked} />
|
||||
{comment.countLike ?? 0}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isReplying) {
|
||||
setReplyTo(null);
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(commentId) || commentId < 1) return;
|
||||
setReplyTo({
|
||||
id: commentId,
|
||||
userName: (comment.userName || "").trim() || "用户",
|
||||
});
|
||||
}}
|
||||
className={`cursor-pointer ${isReplying ? "text-[#F12B9E]" : "hover:text-white"}`}
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
</div>
|
||||
{isReplying && replyTo ? (
|
||||
<InlineReplyBox
|
||||
compact
|
||||
userName={replyTo.userName}
|
||||
posting={posting}
|
||||
error={postError}
|
||||
onSubmit={submitReply}
|
||||
onCancel={() => setReplyTo(null)}
|
||||
/>
|
||||
) : null}
|
||||
<CommentReplyPreview
|
||||
comment={comment}
|
||||
className="rounded-md bg-white/5 px-2.5 py-2 text-xs leading-[18px]"
|
||||
textClassName="text-white/70"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{!loading && !error && comments.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasMore || loadingMore}
|
||||
onClick={loadMore}
|
||||
className="cursor-pointer my-3 h-9 w-full rounded-md bg-white/5 text-xs text-white/50 disabled:opacity-50"
|
||||
>
|
||||
{loadingMore ? "加载中…" : hasMore ? "加载更多" : "没有更多了"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="border-t border-white/5 px-4 py-2.5">
|
||||
{!replyTo && postError ? <p className="mb-2 text-xs text-[#FF8FA8]">{postError}</p> : null}
|
||||
<form onSubmit={submit} className="flex items-center gap-2.5">
|
||||
<input
|
||||
value={text}
|
||||
maxLength={COMMENT_MAX_LENGTH}
|
||||
disabled={posting || Boolean(replyTo)}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
placeholder="发表评论…"
|
||||
aria-label="评论内容"
|
||||
className="h-9 min-w-0 flex-1 rounded-full bg-white/5 px-4 text-sm text-white outline-none placeholder:text-white/35 disabled:opacity-60"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!text.trim() || posting || Boolean(replyTo)}
|
||||
className="cursor-pointer h-9 shrink-0 rounded-full bg-[#F12B9E] px-4 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{posting && !replyTo ? "发送中" : "发送"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
|
||||
import { COMMENT_MAX_LENGTH, COMMENT_SORTS, formatCommentCount, formatCommentTime } from "./data";
|
||||
import { CommentReplyPreview } from "./comment-reply-preview";
|
||||
import { InlineReplyBox } from "./inline-reply-box";
|
||||
import type { UseCommentsResult } from "./use-comments";
|
||||
|
||||
type H5CommentsTabProps = {
|
||||
/** 由页面 useComments 托管,便于 Tab 标题同步总数 */
|
||||
state: UseCommentsResult;
|
||||
footerExtra?: ReactNode;
|
||||
};
|
||||
|
||||
/** H5 视频播放页「评论」Tab */
|
||||
export function H5CommentsTab({ state, footerExtra }: H5CommentsTabProps) {
|
||||
const {
|
||||
sort,
|
||||
setSort,
|
||||
comments,
|
||||
total,
|
||||
hasMore,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
toggleLike,
|
||||
post,
|
||||
posting,
|
||||
postError,
|
||||
reload,
|
||||
} = state;
|
||||
const [text, setText] = useState("");
|
||||
const [replyTo, setReplyTo] = useState<{ id: number; userName: string } | null>(null);
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const content = text.trim();
|
||||
if (!content || posting || replyTo) return;
|
||||
const ok = await post(content);
|
||||
if (ok) setText("");
|
||||
};
|
||||
|
||||
const submitReply = async (content: string) => {
|
||||
if (!replyTo) return false;
|
||||
const ok = await post(content, { parentsId: replyTo.id });
|
||||
if (ok) setReplyTo(null);
|
||||
return ok;
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-component="h5-comments-tab" className="flex flex-col">
|
||||
<div className="flex flex-col gap-3 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base leading-[22px] font-medium text-white">{total}条评论</h2>
|
||||
<div className="flex rounded-full bg-white/5 p-0.5">
|
||||
{COMMENT_SORTS.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
aria-pressed={sort === item}
|
||||
onClick={() => setSort(item)}
|
||||
className={`cursor-pointer rounded-full px-2.5 py-1 text-[10px] leading-4 ${
|
||||
sort === item ? "bg-white/10 text-white" : "text-white/65"
|
||||
}`}
|
||||
>
|
||||
{item === "最新" ? "最新" : "最热"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-white/50">评论加载中…</p>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center gap-3 py-8">
|
||||
<p className="text-sm text-white/50">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reload}
|
||||
className="cursor-pointer rounded-[3px] bg-white/10 px-4 py-2 text-xs text-white"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-white/50">暂无评论</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-5">
|
||||
{comments.map((comment) => {
|
||||
const liked = Boolean(comment.isLike);
|
||||
const commentId = Number(comment.id) || 0;
|
||||
const isReplying = replyTo?.id === commentId;
|
||||
return (
|
||||
<li key={comment.id} className="flex gap-3">
|
||||
<DecryptedImage
|
||||
src={comment.avatar}
|
||||
fallback="/images/default_avatar.svg"
|
||||
alt=""
|
||||
className="size-10 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
|
||||
<p className="text-sm leading-5 text-white/65">{comment.userName}</p>
|
||||
<p className="text-sm leading-5 break-words text-white">{comment.text}</p>
|
||||
<div className="flex items-center justify-between text-xs leading-[18px]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-white/65">{formatCommentTime(comment.createdAt)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`cursor-pointer ${isReplying ? "text-[#F12B9E]" : "text-white"}`}
|
||||
onClick={() => {
|
||||
if (isReplying) {
|
||||
setReplyTo(null);
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(commentId) || commentId < 1) return;
|
||||
setReplyTo({
|
||||
id: commentId,
|
||||
userName: (comment.userName || "").trim() || "用户",
|
||||
});
|
||||
}}
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={liked}
|
||||
onClick={() => void toggleLike(comment)}
|
||||
className="cursor-pointer flex items-center gap-1 text-white/65"
|
||||
>
|
||||
<svg viewBox="0 0 18 18" className="size-[18px]" aria-hidden="true">
|
||||
<path
|
||||
d="M9 15.5c-.22 0-.45-.08-.62-.23C5.97 13.25 2.25 10.65 2.25 7.2c0-2.25 1.6-4.05 3.6-4.05 1.24 0 2.37.68 3.15 1.7.78-1.02 1.9-1.7 3.15-1.7 2 0 3.6 1.8 3.6 4.05 0 3.45-3.72 6.05-6.13 8.07-.17.15-.4.23-.62.23Z"
|
||||
fill={liked ? "#F52C56" : "none"}
|
||||
stroke="#F52C56"
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
</svg>
|
||||
{formatCommentCount(Number(comment.countLike) || 0)}
|
||||
</button>
|
||||
</div>
|
||||
{isReplying && replyTo ? (
|
||||
<InlineReplyBox
|
||||
compact
|
||||
userName={replyTo.userName}
|
||||
posting={posting}
|
||||
error={postError}
|
||||
onSubmit={submitReply}
|
||||
onCancel={() => setReplyTo(null)}
|
||||
/>
|
||||
) : null}
|
||||
<CommentReplyPreview
|
||||
comment={comment}
|
||||
className="flex flex-col gap-1 rounded-[3px] bg-white/5 px-3 py-1.5 text-xs leading-[18px]"
|
||||
textClassName="text-white/80"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!loading && !error && comments.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasMore || loadingMore}
|
||||
onClick={loadMore}
|
||||
className="cursor-pointer h-9 w-full rounded-[3px] bg-white/5 text-xs text-white/50 disabled:opacity-50"
|
||||
>
|
||||
{loadingMore ? "加载中…" : hasMore ? "加载更多" : "没有更多了"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="sticky bottom-0 z-10 bg-[#1A1A1A]">
|
||||
{!replyTo && postError ? <p className="px-4 pt-2 text-xs text-[#FF8FA8]">{postError}</p> : null}
|
||||
<form onSubmit={submit} className="flex h-[58px] items-center px-4 py-1.5">
|
||||
<label className="flex h-full flex-1 items-center gap-2 rounded-[3px] bg-white/5 px-3 py-1.5">
|
||||
<input
|
||||
value={text}
|
||||
maxLength={COMMENT_MAX_LENGTH}
|
||||
disabled={posting || Boolean(replyTo)}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
placeholder={posting && !replyTo ? "发布中…" : "发表评论…"}
|
||||
enterKeyHint="send"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm leading-5 text-white outline-none placeholder:text-white/35 disabled:opacity-60"
|
||||
/>
|
||||
<svg viewBox="0 0 24 24" className="size-6 shrink-0" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke="rgba(255,255,255,0.65)" strokeWidth="1.5" />
|
||||
<path
|
||||
d="M8.5 14a4 4 0 0 0 7 0"
|
||||
stroke="rgba(255,255,255,0.65)"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="9" cy="10" r="1" fill="rgba(255,255,255,0.65)" />
|
||||
<circle cx="15" cy="10" r="1" fill="rgba(255,255,255,0.65)" />
|
||||
</svg>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
{footerExtra}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export function HeartIcon({ filled, className = "size-[18px]" }: { filled: boolean; className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 14 14" fill="none" className={className} aria-hidden="true">
|
||||
<path
|
||||
d="M7 11.5c-.2 0-.4-.07-.55-.2C4.6 9.8 2.5 8.2 2.5 5.9 2.5 4.5 3.5 3.5 4.8 3.5c.9 0 1.7.5 2.2 1.2.5-.7 1.3-1.2 2.2-1.2 1.3 0 2.3 1 2.3 2.4 0 2.3-2.1 3.9-3.95 5.4-.15.13-.35.2-.55.2Z"
|
||||
fill={filled ? "#F12B9E" : "none"}
|
||||
stroke={filled ? "#F12B9E" : "rgba(255,255,255,0.55)"}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export {
|
||||
COMMENT_FETCH_TIMEOUT_MS,
|
||||
COMMENT_MAX_LENGTH,
|
||||
COMMENT_PAGE_SIZE,
|
||||
COMMENT_SELF_NAME,
|
||||
COMMENT_SORTS,
|
||||
commentTarget,
|
||||
createSelfComment,
|
||||
filterCommentList,
|
||||
formatCommentCount,
|
||||
formatCommentTime,
|
||||
playletChapterCommentTarget,
|
||||
playletCommentTarget,
|
||||
sortToOrder,
|
||||
videoCommentTarget,
|
||||
type CommentSort,
|
||||
type CommentTarget,
|
||||
} from "./data";
|
||||
export { CommentPanel } from "./comment-panel";
|
||||
export { CommentReplyPreview } from "./comment-reply-preview";
|
||||
export { H5CommentSheet } from "./h5-comment-sheet";
|
||||
export { H5CommentsTab } from "./h5-comments-tab";
|
||||
export { HeartIcon } from "./heart-icon";
|
||||
export { useComments, type UseCommentsOptions, type UseCommentsResult } from "./use-comments";
|
||||
export type { CommentInfo } from "@/lib/api/types";
|
||||
@@ -1,102 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
|
||||
import { COMMENT_MAX_LENGTH } from "./data";
|
||||
|
||||
/** 挂在单条评论下方的回复输入框 */
|
||||
export function InlineReplyBox({
|
||||
userName,
|
||||
posting,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
compact = false,
|
||||
}: {
|
||||
userName: string;
|
||||
posting: boolean;
|
||||
error?: string | null;
|
||||
onSubmit: (content: string) => Promise<boolean>;
|
||||
onCancel: () => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const content = text.trim();
|
||||
if (!content || posting) return;
|
||||
const ok = await onSubmit(content);
|
||||
if (ok) setText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="inline-reply-box"
|
||||
className={`flex flex-col gap-2 ${
|
||||
compact
|
||||
? "mt-1 rounded-md bg-white/[0.06] p-2.5 outline outline-1 -outline-offset-1 outline-white/15"
|
||||
: "mt-2 rounded-xl bg-[#211F38] p-3 outline outline-1 -outline-offset-1 outline-[#4A4668]"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-between leading-[18px] ${
|
||||
compact ? "text-[11px] text-white/55" : "text-xs text-[#948CA8]"
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
回复 <span className="font-medium text-[#F12B9E]">@{userName}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="cursor-pointer text-white/50 transition-colors hover:text-white"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={submit} className="flex items-center gap-2">
|
||||
<label
|
||||
className={`flex min-w-0 flex-1 items-center gap-2 transition-[outline-color] ${
|
||||
compact
|
||||
? "h-10 rounded-[3px] bg-[#0F0F0F] px-3 outline outline-1 -outline-offset-1 outline-white/20 focus-within:outline-[#F12B9E]"
|
||||
: "h-11 rounded-lg bg-[#110F1F] px-3 outline outline-1 -outline-offset-1 outline-[#5A5678] focus-within:outline-[#F12B9E]"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
maxLength={COMMENT_MAX_LENGTH}
|
||||
disabled={posting}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
placeholder={`回复 @${userName}…`}
|
||||
aria-label={`回复 ${userName}`}
|
||||
className={`min-w-0 flex-1 bg-transparent text-white outline-none placeholder:text-white/40 disabled:opacity-60 ${
|
||||
compact ? "text-sm leading-5" : "text-sm leading-5"
|
||||
}`}
|
||||
/>
|
||||
<span className="shrink-0 text-[10px] leading-4 text-white/30">
|
||||
{text.length}/{COMMENT_MAX_LENGTH}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!text.trim() || posting}
|
||||
className={`cursor-pointer shrink-0 font-medium text-white transition-colors enabled:hover:bg-[#F23DA8] disabled:opacity-45 ${
|
||||
compact
|
||||
? "h-10 rounded-[3px] bg-[#F12B9E] px-3.5 text-xs"
|
||||
: "h-11 rounded-lg bg-[#F12B9E] px-4 text-sm"
|
||||
}`}
|
||||
>
|
||||
{posting ? "发送中" : "发送"}
|
||||
</button>
|
||||
</form>
|
||||
{error ? <p className="text-xs leading-[18px] text-[#FF8FA8]">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "@heroui/react";
|
||||
|
||||
import { openLoginDialog } from "@/components/auth/login-dialog";
|
||||
import { getCommentList, sendComment, setCommentLike } from "@/lib/api/comment";
|
||||
import type { CommentInfo } from "@/lib/api/types";
|
||||
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
|
||||
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
|
||||
|
||||
import {
|
||||
COMMENT_FETCH_TIMEOUT_MS,
|
||||
COMMENT_PAGE_SIZE,
|
||||
filterCommentList,
|
||||
sortToOrder,
|
||||
type CommentSort,
|
||||
type CommentTarget,
|
||||
} from "./data";
|
||||
|
||||
export type UseCommentsOptions = {
|
||||
target: CommentTarget | null;
|
||||
initialSort?: CommentSort;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export type UseCommentsResult = {
|
||||
sort: CommentSort;
|
||||
setSort: (sort: CommentSort) => void;
|
||||
comments: CommentInfo[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
posting: boolean;
|
||||
error: string | null;
|
||||
postError: string | null;
|
||||
loadMore: () => void;
|
||||
toggleLike: (comment: CommentInfo) => Promise<void>;
|
||||
post: (text: string, options?: { parentsId?: number }) => Promise<boolean>;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
function formatCommentError(err: unknown, fallback = "评论加载失败") {
|
||||
if (err instanceof DOMException && err.name === "TimeoutError") return "加载超时,请重试";
|
||||
if (err instanceof Error) {
|
||||
if (/aborted|timeout|超时/i.test(err.message) || err.name === "AbortError") return "加载超时,请重试";
|
||||
return err.message || fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function abortAfter(ms: number) {
|
||||
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
|
||||
return AbortSignal.timeout(ms);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function mergeSignals(a: AbortSignal, b: AbortSignal) {
|
||||
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.any === "function") {
|
||||
return AbortSignal.any([a, b]);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort();
|
||||
if (a.aborted || b.aborted) {
|
||||
controller.abort();
|
||||
return controller.signal;
|
||||
}
|
||||
a.addEventListener("abort", onAbort, { once: true });
|
||||
b.addEventListener("abort", onAbort, { once: true });
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
async function resolveAuthToken() {
|
||||
let token = getAuthToken();
|
||||
if (!token) {
|
||||
try {
|
||||
await ensureGuestSession();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
token = getAuthToken();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function patchComment(list: CommentInfo[], id: number, patch: Partial<CommentInfo>): CommentInfo[] {
|
||||
return list.map((item) => (Number(item.id) === id ? { ...item, ...patch } : item));
|
||||
}
|
||||
|
||||
export function useComments({
|
||||
target,
|
||||
initialSort = "热门",
|
||||
pageSize = COMMENT_PAGE_SIZE,
|
||||
}: UseCommentsOptions): UseCommentsResult {
|
||||
const [sort, setSort] = useState<CommentSort>(initialSort);
|
||||
const [comments, setComments] = useState<CommentInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pageNum, setPageNum] = useState(1);
|
||||
const [loading, setLoading] = useState(Boolean(target));
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [posting, setPosting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [postError, setPostError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const fetchSeq = useRef(0);
|
||||
const likingIds = useRef(new Set<number>());
|
||||
|
||||
const objectId = target?.objectId ?? 0;
|
||||
const objectType = target?.objectType;
|
||||
const enabled = Boolean(target && objectId > 0 && objectType != null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
await Promise.resolve();
|
||||
if (cancelled) return;
|
||||
|
||||
if (!enabled || objectType == null) {
|
||||
setComments([]);
|
||||
setTotal(0);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = ++fetchSeq.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPageNum(1);
|
||||
likingIds.current.clear();
|
||||
|
||||
try {
|
||||
const data = await getCommentList(
|
||||
{
|
||||
objectId,
|
||||
objectType,
|
||||
pageNum: 1,
|
||||
pageSize,
|
||||
sortOrder: sortToOrder(sort),
|
||||
},
|
||||
{
|
||||
token: getAuthToken(),
|
||||
signal: mergeSignals(controller.signal, abortAfter(COMMENT_FETCH_TIMEOUT_MS)),
|
||||
},
|
||||
);
|
||||
if (cancelled || seq !== fetchSeq.current) return;
|
||||
setComments(filterCommentList(data?.list));
|
||||
setTotal(Number(data?.totalCount) || 0);
|
||||
setPageNum(1);
|
||||
} catch (err: unknown) {
|
||||
if (cancelled || seq !== fetchSeq.current || controller.signal.aborted) return;
|
||||
setComments([]);
|
||||
setTotal(0);
|
||||
setError(formatCommentError(err));
|
||||
} finally {
|
||||
if (!cancelled && seq === fetchSeq.current) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [enabled, objectId, objectType, sort, pageSize, reloadKey]);
|
||||
|
||||
const hasMore = comments.length < total;
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!enabled || objectType == null || loading || loadingMore || !hasMore) return;
|
||||
|
||||
const nextPage = pageNum + 1;
|
||||
const controller = new AbortController();
|
||||
setLoadingMore(true);
|
||||
|
||||
void getCommentList(
|
||||
{
|
||||
objectId,
|
||||
objectType,
|
||||
pageNum: nextPage,
|
||||
pageSize,
|
||||
sortOrder: sortToOrder(sort),
|
||||
},
|
||||
{
|
||||
token: getAuthToken(),
|
||||
signal: mergeSignals(controller.signal, abortAfter(COMMENT_FETCH_TIMEOUT_MS)),
|
||||
},
|
||||
)
|
||||
.then((data) => {
|
||||
const mapped = filterCommentList(data?.list);
|
||||
setComments((current) => {
|
||||
const seen = new Set(current.map((item) => Number(item.id)));
|
||||
return [...current, ...mapped.filter((item) => !seen.has(Number(item.id)))];
|
||||
});
|
||||
setTotal(Number(data?.totalCount) || total);
|
||||
setPageNum(nextPage);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(formatCommentError(err, "加载更多失败"));
|
||||
})
|
||||
.finally(() => setLoadingMore(false));
|
||||
}, [enabled, objectId, objectType, loading, loadingMore, hasMore, pageNum, pageSize, sort, total]);
|
||||
|
||||
const toggleLike = useCallback(async (comment: CommentInfo) => {
|
||||
const commentId = Number(comment.id);
|
||||
if (!Number.isInteger(commentId) || commentId < 1) return;
|
||||
if (likingIds.current.has(commentId)) return;
|
||||
|
||||
const token = await resolveAuthToken();
|
||||
if (!token) {
|
||||
openLoginDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIsLike = !Boolean(comment.isLike);
|
||||
const nextCountLike = Math.max(0, (Number(comment.countLike) || 0) + (nextIsLike ? 1 : -1));
|
||||
const snapshot = { isLike: comment.isLike, countLike: comment.countLike };
|
||||
|
||||
likingIds.current.add(commentId);
|
||||
setComments((current) => patchComment(current, commentId, { isLike: nextIsLike, countLike: nextCountLike }));
|
||||
|
||||
try {
|
||||
const result = await setCommentLike(
|
||||
{ commentId, isLike: nextIsLike },
|
||||
token,
|
||||
abortAfter(COMMENT_FETCH_TIMEOUT_MS),
|
||||
);
|
||||
setComments((current) =>
|
||||
patchComment(current, commentId, {
|
||||
isLike: result?.isLike ?? nextIsLike,
|
||||
countLike: typeof result?.countLike === "number" ? result.countLike : nextCountLike,
|
||||
}),
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
setComments((current) => patchComment(current, commentId, snapshot));
|
||||
handleUnauthorized(err);
|
||||
} finally {
|
||||
likingIds.current.delete(commentId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const post = useCallback(
|
||||
async (text: string, options?: { parentsId?: number }) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || posting) return false;
|
||||
if (!enabled || objectType == null) {
|
||||
setPostError("无法发布评论");
|
||||
return false;
|
||||
}
|
||||
|
||||
setPostError(null);
|
||||
const token = await resolveAuthToken();
|
||||
if (!token) {
|
||||
openLoginDialog();
|
||||
return false;
|
||||
}
|
||||
|
||||
setPosting(true);
|
||||
|
||||
try {
|
||||
await sendComment(
|
||||
{
|
||||
objectId,
|
||||
objectType,
|
||||
text: trimmed,
|
||||
parentsId: options?.parentsId ?? 0,
|
||||
},
|
||||
token,
|
||||
abortAfter(COMMENT_FETCH_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
// 审核通过前不写入列表(对齐 1045)
|
||||
toast.success("发布成功,评论审核后才会展示");
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
if (handleUnauthorized(err)) {
|
||||
setPostError("登录已过期或没有权限,请重新登录");
|
||||
return false;
|
||||
}
|
||||
const message = formatCommentError(err, "发表失败,请稍后重试");
|
||||
setPostError(message);
|
||||
toast.danger(message);
|
||||
return false;
|
||||
} finally {
|
||||
setPosting(false);
|
||||
}
|
||||
},
|
||||
[enabled, objectId, objectType, posting],
|
||||
);
|
||||
|
||||
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
|
||||
|
||||
return {
|
||||
sort,
|
||||
setSort,
|
||||
comments,
|
||||
total,
|
||||
hasMore,
|
||||
loading,
|
||||
loadingMore,
|
||||
posting,
|
||||
error,
|
||||
postError,
|
||||
loadMore,
|
||||
toggleLike,
|
||||
post,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user