Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-26 17:51:31 +07:00
co-authored by Claude Opus 5.5
commit 61d0a11dfa
371 changed files with 35836 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
/** 恒定线速度:px/s,图标多少只影响时长 */
const SCROLL_PX_PER_SEC = 36;
type AdIconMarqueeProps = {
children: ReactNode;
className?: string;
/** 段内间距,需与尾部 spacer 对齐以无缝衔接 */
gapClassName?: string;
/** 段尾 spacer 宽度,应等于 gap(gap-2→w-2,gap-3→w-3) */
spacerClassName?: string;
/** 内容签名:列表变化时重算时长 */
contentKey?: string;
};
/** Icon 广告从右向左无缝自动滚动(内容复制两段,translateX -50%) */
export function AdIconMarquee({
children,
className = "",
gapClassName = "gap-3",
spacerClassName = "w-3",
contentKey = "",
}: AdIconMarqueeProps) {
const segmentRef = useRef<HTMLDivElement>(null);
const [durationSec, setDurationSec] = useState(20);
useLayoutEffect(() => {
const el = segmentRef.current;
if (!el) return;
const update = () => {
const width = el.getBoundingClientRect().width;
if (width <= 0) return;
setDurationSec(Math.max(width / SCROLL_PX_PER_SEC, 8));
};
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, [contentKey]);
return (
<div
data-component="ad-icon-marquee"
className={`overflow-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${className}`}
>
<div
className="ad-icon-marquee-track flex w-max items-start"
style={{ animationDuration: `${durationSec}s` }}
>
<div ref={segmentRef} className={`flex shrink-0 items-start ${gapClassName}`}>
{children}
<span className={`${spacerClassName} shrink-0`} aria-hidden />
</div>
<div className={`flex shrink-0 items-start ${gapClassName}`} aria-hidden>
{children}
<span className={`${spacerClassName} shrink-0`} aria-hidden />
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { AdIcons } from "./ads/ad-icons";
/** @deprecated 使用 AdIcons;保留别名兼容旧引用 */
export function AdIconRow({
labels = true,
items,
advitise,
className = "",
}: {
labels?: boolean;
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
className?: string;
}) {
return <AdIcons variant="pc" labels={labels} items={items} advitise={advitise} className={className} />;
}
+94
View File
@@ -0,0 +1,94 @@
import { DecryptedImage } from "@/components/common/decrypted-image";
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { adHref, adTrackProps, pickBannerAds } from "./pick";
type AdBannersProps = {
variant?: "pc" | "h5";
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
className?: string;
};
/** 公共 Banner 广告:对接 advitise.detailbannerList;无数据不渲染 */
export function AdBanners({
variant = "pc",
items,
advitise,
className = "",
}: AdBannersProps) {
const isH5 = variant === "h5";
const apiList = pickBannerAds(items, advitise);
if (!apiList.length) return null;
if (isH5) {
return (
<div
data-component="ad-banners"
data-variant="h5"
className={`flex flex-col gap-[18px] ${className}`}
>
{apiList.map((ad, index) => {
const href = adHref(ad.link, "#h5-download");
const external = Boolean(ad.link?.trim());
return (
<a
key={ad.id || `banner-${index}`}
href={href}
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className="block h-10 overflow-hidden rounded-xl"
{...adTrackProps(ad, {
slotKey: `content_banner_${index + 1}`,
slotName: `内容横幅第${index + 1}位`,
adType: "banner",
})}
>
<DecryptedImage
src={ad.images || null}
fallback={IMG_PLACEHOLDER}
alt={ad.title || "推广"}
className="h-full w-full object-cover"
/>
</a>
);
})}
</div>
);
}
return (
<div
data-component="ad-banners"
data-variant="pc"
className={`grid grid-cols-2 gap-5 ${className}`}
>
{apiList.map((ad, index) => {
const href = adHref(ad.link);
const external = Boolean(ad.link?.trim());
return (
<a
key={ad.id || `banner-${index}`}
href={href}
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className="block h-[97px] overflow-hidden rounded-xl"
{...adTrackProps(ad, {
slotKey: `content_banner_${index + 1}`,
slotName: `内容横幅第${index + 1}位`,
adType: "banner",
})}
>
<DecryptedImage
src={ad.images || null}
fallback={IMG_PLACEHOLDER}
alt={ad.title || "推广"}
className="h-full w-full object-cover"
/>
</a>
);
})}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { DecryptedImage } from "@/components/common/decrypted-image";
import { AdIconMarquee } from "@/components/common/ad-icon-marquee";
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { adContentKey, adHref, adTrackProps, pickIconAds } from "./pick";
type AdIconsProps = {
variant?: "pc" | "h5";
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
/** PC 是否显示标题;H5 始终显示 */
labels?: boolean;
className?: string;
};
/** 公共 Icon 广告:对接 advitise.nineGridList,无数据不渲染;从右向左无缝滚动 */
export function AdIcons({
variant = "pc",
items,
advitise,
labels = true,
className = "",
}: AdIconsProps) {
const isH5 = variant === "h5";
const apiList = pickIconAds(items, advitise, variant);
if (!apiList.length) return null;
const showLabels = isH5 || labels;
const iconClass = isH5
? "flex w-12 shrink-0 flex-col items-center gap-1"
: "flex w-20 shrink-0 flex-col items-center gap-1";
const imgClass = isH5
? "aspect-square size-12 rounded-xl border border-white/30 object-cover"
: "size-20 rounded-xl border border-white/30 object-cover";
const titleClass = isH5
? "max-w-full truncate text-xs leading-[18px] text-white/90"
: "text-center text-sm leading-5 text-white/90";
return (
<AdIconMarquee
className={className}
gapClassName={isH5 ? "gap-2" : "gap-3"}
spacerClassName={isH5 ? "w-2" : "w-3"}
contentKey={adContentKey(apiList)}
>
{apiList.map((ad, index) => {
const href = adHref(ad.link, isH5 ? "#h5-download" : "#download");
const external = Boolean(ad.link?.trim());
return (
<a
key={ad.id || `ad-${index}`}
href={href}
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className={iconClass}
{...adTrackProps(ad, {
slotKey: `icon_grid_${index + 1}`,
slotName: `九宫格第${index + 1}位`,
adType: "icon",
})}
>
<DecryptedImage
src={ad.images || null}
fallback={IMG_PLACEHOLDER}
alt={ad.title || ""}
className={imgClass}
/>
{showLabels ? <span className={titleClass}>{ad.title || "广告"}</span> : null}
</a>
);
})}
</AdIconMarquee>
);
}
+4
View File
@@ -0,0 +1,4 @@
/** 仅导出可进 Client Component 的广告 UI;Slot 请从 `@/components/common/ads/slots` 引入 */
export { AdIcons } from "./ad-icons";
export { AdBanners } from "./ad-banners";
export { pickIconAds, pickBannerAds, pickFriendAds } from "./pick";
+56
View File
@@ -0,0 +1,56 @@
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
export function hasAdVisual(item: AdvertiseInfo) {
return Boolean(item.images?.trim() || item.title?.trim());
}
/** Icon:统一对接 advitise.nineGridList(可显式传 items 覆盖) */
export function pickIconAds(
items?: AdvertiseInfo[] | null,
advitise?: AdvitiseData | null,
_variant: "pc" | "h5" = "pc",
) {
void _variant;
const source = items ?? advitise?.nineGridList ?? [];
return source.filter(hasAdVisual);
}
/** Banner:detailbannerList(需有图) */
export function pickBannerAds(items?: AdvertiseInfo[] | null, advitise?: AdvitiseData | null) {
const source = items ?? advitise?.detailbannerList ?? [];
return source.filter((item) => Boolean(item.images?.trim()));
}
/** 友情链接:friendList(需有标题) */
export function pickFriendAds(items?: AdvertiseInfo[] | null, advitise?: AdvitiseData | null) {
const source = items ?? advitise?.friendList ?? [];
return source.filter((item): item is AdvertiseInfo & { title: string } => Boolean(item.title?.trim()));
}
export function adHref(link?: string, fallback = "#download") {
const value = link?.trim();
if (!value) return fallback;
return value.startsWith("http") || value.startsWith("/") ? value : `https://${value}`;
}
export function adContentKey(list: AdvertiseInfo[]) {
return list.map((item) => item.id || item.title || item.images || "").join("|") || "mock";
}
/** 外链广告埋点属性(对齐 hkdj / jha-1043 data-track-ad) */
export function adTrackProps(
ad: AdvertiseInfo,
options?: { slotKey?: string; slotName?: string; adType?: string },
) {
const adId = String(ad.advertiseCode || ad.id || "").trim();
if (!adId) return {};
return {
"data-track-ad": "external" as const,
"data-ad-id": adId,
"data-ad-clickable": "1",
"data-ad-type": options?.adType || "banner",
"data-ad-slot-key": options?.slotKey || ad.advertiseLocationCode || undefined,
"data-ad-slot-name": options?.slotName || ad.advertiseLocationName || ad.title || undefined,
"data-creative-id": String(ad.id || ""),
};
}
+43
View File
@@ -0,0 +1,43 @@
import { getAdvitiseCached } from "@/lib/api/home";
import { AdBanners } from "./ad-banners";
import { AdIcons } from "./ad-icons";
import { FriendLinks, H5FriendLinks } from "../friend-links";
/** 服务端:Icon 广告(home/base → advitise) */
export async function AdIconsSlot({
variant = "pc",
labels = true,
className = "",
}: {
variant?: "pc" | "h5";
labels?: boolean;
className?: string;
}) {
const advitise = await getAdvitiseCached();
return <AdIcons variant={variant} advitise={advitise} labels={labels} className={className} />;
}
/** 服务端:Banner 广告 */
export async function AdBannersSlot({
variant = "pc",
className = "",
}: {
variant?: "pc" | "h5";
className?: string;
}) {
const advitise = await getAdvitiseCached();
return <AdBanners variant={variant} advitise={advitise} className={className} />;
}
/** 服务端:PC 友情链接 */
export async function FriendLinksSlot() {
const advitise = await getAdvitiseCached();
return <FriendLinks advitise={advitise} />;
}
/** 服务端:H5 友情链接 */
export async function H5FriendLinksSlot() {
const advitise = await getAdvitiseCached();
return <H5FriendLinks advitise={advitise} />;
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { useRouter } from "next/navigation";
export function BackButton({ fallback = "/", className = "" }: { fallback?: string; className?: string }) {
const router = useRouter();
const goBack = () => {
if (window.history.length > 1) router.back();
else router.push(fallback);
};
return (
<button data-component="back-button" type="button" aria-label="返回" onClick={goBack} className={`cursor-pointer size-6 ${className}`}>
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
<path d="M15 5l-7 7 7 7" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import Link from "next/link";
import { useLayoutEffect, useRef, useState } from "react";
import { tagPageHref, type TagContentKind } from "@/components/tag/href";
import { cardTagCanLink, normalizeCardTags, type CardTagItem } from "./pick-card-tags";
/**
* 卡片左下角标签:按容器宽度尽量完整展示;
* 多个放不下时只显示 1 个(可截断),避免「激… · 爆… · 仙…」。
* 跳转优先 slug,无 slug 采用 id。
*
* 注意:空态与有标签态必须使用相同根节点(div),避免 SSR/水合 span↔div 不一致。
*/
export function CardTags({
tags,
kind,
className = "",
}: {
tags?: Array<CardTagItem | string> | string | null;
/** 短剧 / 视频标签库,决定跳转后查哪类内容 */
kind?: TagContentKind;
className?: string;
}) {
const list = normalizeCardTags(tags);
const listKey = list.map((tag) => `${tag.slug ?? ""}:${tag.id ?? ""}:${tag.name}`).join("\0");
const containerRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(() => Math.max(1, list.length ? 1 : 0));
const linkKind = kind ?? "playlet";
useLayoutEffect(() => {
const container = containerRef.current;
const measure = measureRef.current;
if (!list.length) {
setVisibleCount(0);
return;
}
if (!container || !measure || list.length <= 1) {
setVisibleCount(1);
return;
}
const fit = () => {
const maxWidth = container.clientWidth;
if (maxWidth <= 0) {
setVisibleCount(1);
return;
}
const items = measure.querySelectorAll<HTMLElement>("[data-measure-tag]");
if (!items.length) {
setVisibleCount(1);
return;
}
const origin = items[0].offsetLeft;
let count = 0;
for (let i = 0; i < items.length; i++) {
const right = items[i].offsetLeft + items[i].offsetWidth - origin;
if (right > maxWidth + 0.5) break;
count = i + 1;
}
setVisibleCount(Math.max(1, count));
};
fit();
const observer = new ResizeObserver(fit);
observer.observe(container);
return () => observer.disconnect();
}, [listKey, list.length]);
const visible = list.slice(0, Math.max(visibleCount, list.length ? 1 : 0));
const single = visible.length === 1;
return (
<div className={`relative min-w-0 flex-1 ${className}`}>
{list.length ? (
<>
<div
ref={measureRef}
className="pointer-events-none absolute top-0 left-0 flex items-center gap-1 whitespace-nowrap opacity-0"
aria-hidden="true"
>
{list.map((tag, index) => (
<span key={`${tag.slug ?? tag.id ?? tag.name}-${index}`} data-measure-tag className="flex items-center gap-1">
{index > 0 ? <span className="text-current/40">·</span> : null}
<span>{tag.name}</span>
</span>
))}
</div>
<div ref={containerRef} className="flex min-w-0 items-center gap-1 overflow-hidden">
{visible.map((tag, index) => (
<span
key={`${tag.slug ?? tag.id ?? tag.name}-${index}`}
className={`flex items-center gap-1 ${single ? "min-w-0" : "shrink-0"}`}
>
{index > 0 ? <span className="shrink-0 text-current/40">·</span> : null}
{cardTagCanLink(tag) ? (
<Link
href={tagPageHref(tag, linkKind)}
className={`transition-colors hover:text-[#F12B9E] ${
single ? "min-w-0 truncate" : "whitespace-nowrap"
}`}
>
{tag.name}
</Link>
) : (
<span className={single ? "min-w-0 truncate" : "whitespace-nowrap"}>{tag.name}</span>
)}
</span>
))}
</div>
</>
) : null}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
/** 服务端安全导出:纯函数与 Client 组件拆分,避免 pickCardTags 被标成 client */
export {
CARD_TAG_MAX,
cardTagCanLink,
normalizeCardTags,
pickCardTags,
pickPlayletCardTags,
pickVideoCardTags,
type CardTagItem,
} from "./pick-card-tags";
export { CardTags } from "./card-tags-client";
+27
View File
@@ -0,0 +1,27 @@
type ChannelHeaderProps = {
eyebrow?: string;
title: string;
subtitle?: string;
};
/** PC 标题:与 H5 同页时仅 H5 使用 h1,避免重复;传空字符串可隐藏副标题 */
export function ChannelHeader({ eyebrow = "CHANNEL", title, subtitle = "高能反转,短集连看" }: ChannelHeaderProps) {
return (
<header data-component="channel-header" className="flex flex-col gap-[7px]">
<p className="text-xs leading-[18px] font-semibold text-[#F12B9E]">{eyebrow}</p>
<p className="text-[34px] leading-10 font-semibold text-white">{title}</p>
{subtitle ? <p className="text-xs leading-[18px] text-[#9994AD]">{subtitle}</p> : null}
</header>
);
}
/** H5 频道页唯一 h1(移动优先);传空字符串可隐藏副标题 */
export function H5ChannelHeader({ eyebrow = "CHANNEL", title, subtitle = "高能反转,短集连看" }: ChannelHeaderProps) {
return (
<header className="flex flex-col gap-1.5">
<p className="text-xs leading-[18px] font-semibold text-[#F12B9E]">{eyebrow}</p>
<h1 className="text-lg leading-6 font-semibold text-white">{title}</h1>
{subtitle ? <p className="text-xs leading-[18px] text-[#9994AD]">{subtitle}</p> : null}
</header>
);
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { useSyncExternalStore, type ReactNode } from "react";
const subscribe = () => () => {};
export function ClientOnly({ children, fallback = null }: { children: ReactNode; fallback?: ReactNode }) {
const mounted = useSyncExternalStore(
subscribe,
() => true,
() => false,
);
return mounted ? children : fallback;
}
@@ -0,0 +1,284 @@
"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>
);
}
@@ -0,0 +1,35 @@
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>
);
}
+82
View File
@@ -0,0 +1,82 @@
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);
}
@@ -0,0 +1,204 @@
"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>
);
}
@@ -0,0 +1,209 @@
"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>
);
}
@@ -0,0 +1,12 @@
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>
);
}
+25
View File
@@ -0,0 +1,25 @@
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";
@@ -0,0 +1,102 @@
"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>
);
}
@@ -0,0 +1,315 @@
"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,
};
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useRef, useState, type ImgHTMLAttributes } from "react";
import { useDecryptedSrc } from "@/hooks/use-decrypted-src";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
type DecryptedImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
src?: string | null;
fallback?: string;
/** 首屏/关键图:立即解密,不懒加载 */
priority?: boolean;
/**
* 视口外不发起解密请求。默认:非 priority 时开启。
* 解密图无法靠原生 loading=lazy 省流量,必须在进视口后再 fetch。
*/
lazy?: boolean;
/** IntersectionObserver rootMargin,提前预取(默认上下 320px) */
lazyRootMargin?: string;
};
export function DecryptedImage({
src,
fallback = IMG_PLACEHOLDER,
priority = false,
lazy,
lazyRootMargin = "320px 0px",
alt = "",
onError,
loading,
decoding,
fetchPriority,
...props
}: DecryptedImageProps) {
const enableLazy = lazy ?? !priority;
const imgRef = useRef<HTMLImageElement>(null);
const [inView, setInView] = useState(!enableLazy);
useEffect(() => {
if (!enableLazy || inView) return;
const node = imgRef.current;
if (!node) return;
if (typeof IntersectionObserver === "undefined") {
setInView(true);
return;
}
const io = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting || entry.intersectionRatio > 0)) {
setInView(true);
io.disconnect();
}
},
{ root: null, rootMargin: lazyRootMargin, threshold: 0 },
);
io.observe(node);
return () => io.disconnect();
}, [enableLazy, inView, lazyRootMargin]);
const resolved = useDecryptedSrc(src, { priority, fallback, enabled: inView });
return (
<img
{...props}
ref={imgRef}
alt={alt}
src={resolved}
loading={loading ?? (priority ? "eager" : "lazy")}
decoding={decoding ?? (priority ? "sync" : "async")}
fetchPriority={fetchPriority ?? (priority ? "high" : "auto")}
onError={(event) => {
if (event.currentTarget.src !== fallback) {
event.currentTarget.src = fallback;
}
onError?.(event);
}}
/>
);
}
+85
View File
@@ -0,0 +1,85 @@
import Link from "next/link";
import { useId, type ReactNode } from "react";
import { ReloadButton } from "./reload-button";
const actionClass =
"flex h-10 w-[108px] items-center justify-center rounded-[22px] text-sm leading-5 font-semibold transition-opacity hover:opacity-90 lg:h-11 lg:w-[120px]";
type EmptyStateProps = {
title?: string;
description?: string;
showHome?: boolean;
/** undefined=默认刷新;null=不显示刷新;函数=自定义 */
onReload?: (() => void) | null;
actions?: ReactNode;
className?: string;
};
export function EmptyState({
title = "这里还没有内容",
description = "暂时没有找到相关短剧,换个分类或返回首页看看吧",
showHome = true,
onReload,
actions,
className = "px-4 pt-14 pb-12 lg:px-[110px] lg:pt-32 lg:pb-24",
}: EmptyStateProps) {
const showReload = onReload !== null;
const hasActions = actions !== undefined || showHome || showReload;
return (
<section
data-component="empty-state"
className={`flex flex-col items-center justify-center gap-4 bg-[radial-gradient(ellipse_50%_50%_at_50%_50%,#2E1233_0%,#07060E_100%)] text-center lg:gap-5 ${className}`}
>
<EmptyIllustration />
<div className="flex flex-col items-center gap-2 lg:gap-2.5">
<h2 className="text-lg leading-[26px] font-semibold text-white lg:text-2xl lg:leading-[30px]">{title}</h2>
{description ? (
<p className="max-w-[300px] text-xs leading-[18px] text-[#948CA8] lg:max-w-none lg:text-sm lg:leading-5">
{description}
</p>
) : null}
</div>
{hasActions ? (
<div className="flex gap-3">
{actions ?? (
<>
{showHome ? (
<Link href="/" className={`${actionClass} bg-[#F12B9E] text-white`}>
返回首页
</Link>
) : null}
{showReload ? <ReloadButton onReload={onReload ?? undefined} /> : null}
</>
)}
</div>
) : null}
</section>
);
}
function EmptyIllustration() {
const glowId = useId();
return (
<svg viewBox="0 0 220 180" className="h-36 w-44 lg:h-[180px] lg:w-[220px]" aria-hidden="true">
<defs>
<radialGradient id={glowId}>
<stop offset="0" stopColor="#F12B9E" stopOpacity="0.28" />
<stop offset="1" stopColor="#F12B9E" stopOpacity="0" />
</radialGradient>
</defs>
<circle cx="110" cy="84" r="80" fill={`url(#${glowId})`} />
<rect x="45" y="63" width="130" height="82" rx="17" fill="#211F38" stroke="#F12B9E" strokeOpacity="0.65" strokeWidth="2" />
<rect x="78" y="84" width="64" height="8" rx="4" fill="#F12B9E" fillOpacity="0.75" />
<rect x="72" y="106" width="76" height="6" rx="3" fill="#6B6385" />
<rect x="87" y="120" width="46" height="6" rx="3" fill="#4D476B" />
<g fill="#F12B9E" fillOpacity="0.8">
<circle cx="32" cy="44" r="4" />
<circle cx="186" cy="58" r="4" />
<circle cx="178" cy="138" r="4" />
</g>
</svg>
);
}
+65
View File
@@ -0,0 +1,65 @@
import Link from "next/link";
export function FilterChipLink({
label,
href,
active,
}: {
label: string;
href: string;
active?: boolean;
}) {
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`shrink-0 whitespace-nowrap rounded-[14px] px-3 py-1.5 text-xs leading-[18px] transition-colors ${
active ? "bg-[#F12B9E] text-white" : "bg-[#26223A] text-[#B7B2C7] hover:text-white"
}`}
>
{label}
</Link>
);
}
export function FilterOptionLink({
label,
href,
active,
}: {
label: string;
href: string;
active?: boolean;
}) {
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`shrink-0 whitespace-nowrap text-xs leading-[18px] ${active ? "text-[#F12B9E]" : "text-[#9994A3]/84 hover:text-white"}`}
>
{label}
</Link>
);
}
export function TabLink({
label,
href,
active,
}: {
label: string;
href: string;
active?: boolean;
}) {
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`rounded-2xl px-4 py-[7px] text-xs leading-[18px] transition-colors ${
active ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
}`}
>
{label}
</Link>
);
}
@@ -0,0 +1 @@
export { FriendLinksSlot, H5FriendLinksSlot } from "./ads/slots";
+75
View File
@@ -0,0 +1,75 @@
import { H5SectionHeading } from "@/components/home/h5-cards";
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { adHref, pickFriendAds } from "./ads/pick";
/** PC 友情链接:对接 advitise.friendList,无数据不渲染 */
export function FriendLinks({
items,
advitise,
}: {
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
}) {
const apiList = pickFriendAds(items, advitise);
if (!apiList.length) return null;
// 不用 design-unscaled:嵌套 zoom 会在 Chrome 把热区错位叠到上方分页上
return (
<section data-component="friend-links" id="links" className="border-t border-white/10 pt-1">
<h2 className="text-2xl leading-8 font-semibold text-white/90">友情链接</h2>
<div className="mt-[18px] flex flex-wrap gap-3">
{apiList.map((item, index) => {
const href = adHref(item.link, "#links");
const external = Boolean(item.link?.trim());
return (
<a
key={item.id || `${item.title}-${index}`}
href={href}
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className="inline-flex min-w-[146px] items-center justify-center rounded-md bg-white/5 px-2 py-[9px] text-base leading-[22px] text-white/55"
>
{item.title}
</a>
);
})}
</div>
</section>
);
}
/** H5 友情链接:对接 advitise.friendList,无数据不渲染 */
export function H5FriendLinks({
items,
advitise,
}: {
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
}) {
const apiList = pickFriendAds(items, advitise);
if (!apiList.length) return null;
return (
<section id="h5-links" className="flex flex-col gap-3">
<H5SectionHeading title="友情链接" more={false} />
<div className="flex flex-wrap gap-[18px]">
{apiList.map((item, index) => {
const href = adHref(item.link, "#h5-links");
const external = Boolean(item.link?.trim());
return (
<a
key={item.id || `${item.title}-${index}`}
href={href}
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className="rounded-md border border-white/5 bg-white/10 px-3 py-[9px] text-xs leading-[18px] text-white/90"
>
{item.title}
</a>
);
})}
</div>
</section>
);
}
@@ -0,0 +1,6 @@
import { AdIconsSlot } from "./ads/slots";
/** @deprecated 使用 AdIconsSlot */
export async function H5AdGridSlot({ className = "" }: { className?: string }) {
return <AdIconsSlot variant="h5" className={className} />;
}
+16
View File
@@ -0,0 +1,16 @@
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { AdIcons } from "./ads/ad-icons";
/** @deprecated 使用 AdIcons variant="h5";保留别名兼容旧引用 */
export function H5AdGrid({
items,
advitise,
className = "",
}: {
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
className?: string;
}) {
return <AdIcons variant="h5" items={items} advitise={advitise} className={className} />;
}
@@ -0,0 +1,6 @@
import { AdBannersSlot } from "./ads/slots";
/** @deprecated 使用 AdBannersSlot */
export async function H5BannerAdsSlot({ className = "" }: { className?: string }) {
return <AdBannersSlot variant="h5" className={className} />;
}
+16
View File
@@ -0,0 +1,16 @@
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { AdBanners } from "./ads/ad-banners";
/** @deprecated 使用 AdBanners variant="h5";保留别名兼容旧引用 */
export function H5BannerAds({
items,
advitise,
className = "",
}: {
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
className?: string;
}) {
return <AdBanners variant="h5" items={items} advitise={advitise} className={className} />;
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { BackButton } from "@/components/common/back-button";
type H5PageHeaderProps = {
title: string;
/** 无历史记录时的回退路径 */
fallback?: string;
/** 右侧插槽;默认占位与返回同宽,保证标题居中 */
right?: ReactNode;
className?: string;
};
/**
* H5 页顶栏:返回 + 居中标题,滚动时固定在视口顶部(仅 lg 以下)。
*/
export function H5PageHeader({ title, fallback = "/", right, className = "" }: H5PageHeaderProps) {
return (
<>
<header
data-component="h5-page-header"
className={`fixed inset-x-0 top-0 z-20 flex h-12 items-center bg-black px-4 lg:hidden ${className}`}
>
<BackButton fallback={fallback} />
<h1 className="min-w-0 flex-1 truncate text-center text-lg leading-6 font-semibold text-white">{title}</h1>
<div className="flex size-6 shrink-0 items-center justify-center">{right ?? null}</div>
</header>
{/* 占位,避免 fixed 顶栏遮挡内容 */}
<div className="h-12 shrink-0 lg:hidden" aria-hidden="true" />
</>
);
}
+62
View File
@@ -0,0 +1,62 @@
/** 媒体卡片统一 hover:粉光 + 封面微放大 + 标题变粉
* 注意:阴影在根节点,根节点不能 overflow-hidden,否则光晕会被裁切;圆角裁切只放封面容器。
*
* PC:标题 / 描述 / 标签 / 热度 / 集数用 media-card-* 字号 × --page-unscale,
* 抵消 design-frame zoom,窗口缩小时视觉大小不变(见 globals.css)。
*/
export const MEDIA_CARD_ROOT =
"group rounded-lg bg-[#110F1F] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]";
export const MEDIA_CARD_COVER =
"absolute inset-0 h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-105";
export const MEDIA_CARD_TITLE = "transition-colors duration-300 group-hover:text-[#F12B9E]";
/** PC 卡片标题:视觉 24px(抵消 page-scale);shrink-0 避免 flex+truncate 纵向裁切字形 */
export const MEDIA_CARD_TITLE_PC = `media-card-title block min-h-[1.2em] shrink-0 truncate font-medium text-white ${MEDIA_CARD_TITLE}`;
/** PC 卡片标题:视觉 20px(抵消 page-scale) */
export const MEDIA_CARD_TITLE_PC_MD = `media-card-title-md block min-h-[1.2em] shrink-0 truncate font-medium text-white ${MEDIA_CARD_TITLE}`;
/** PC 大卡标题:视觉 32px(抵消 page-scale) */
export const MEDIA_CARD_TITLE_PC_LG = "media-card-title-lg block min-h-[1.2em] shrink-0 truncate font-semibold text-[#F2F2FA]";
/** PC 大卡标题(可换行):视觉 32px */
export const MEDIA_CARD_TITLE_PC_LG_CLAMP = "media-card-title-lg shrink-0 line-clamp-2 font-semibold text-[#F2F2FA]";
/** H5 卡片标题:18px */
export const MEDIA_CARD_TITLE_H5 = `block min-h-[1.2em] shrink-0 truncate text-lg leading-6 font-medium text-white ${MEDIA_CARD_TITLE}`;
/** PC 封面角标「N集」:视觉 14px(抵消 page-scale) */
export const MEDIA_CARD_EPISODES = "media-card-episodes";
/** H5 封面角标「N集」 */
export const MEDIA_CARD_EPISODES_H5 = "text-xs leading-[18px]";
/** PC 描述:视觉 14px(抵消 page-scale) */
export const MEDIA_CARD_DESC = "media-card-desc block min-h-[1.2em] shrink-0 truncate";
/** PC 描述:视觉 16px(抵消 page-scale) */
export const MEDIA_CARD_DESC_MD = "media-card-desc-md shrink-0";
/** H5 描述:12px */
export const MEDIA_CARD_DESC_SM = "media-card-desc-sm block min-h-[1.2em] shrink-0 truncate";
/** PC 标签 / 热度:视觉 14px(抵消 page-scale) */
export const MEDIA_CARD_META = "media-card-meta shrink-0";
/** PC 标签 / 热度:视觉 16px(抵消 page-scale) */
export const MEDIA_CARD_META_MD = "media-card-desc-md";
/** H5 标签 / 热度:12px */
export const MEDIA_CARD_META_H5 = "media-card-meta-sm";
/** H5 标题+描述区 */
export const MEDIA_CARD_INFO_H5 = "flex min-w-0 flex-col gap-0.5 px-2 pt-1.5";
/** H5 标签热度行:紧凑、可截断标签 */
export const MEDIA_CARD_FOOTER_H5 = `flex min-w-0 items-center justify-between gap-1.5 px-2 pt-0.5 pb-1.5 ${MEDIA_CARD_META_H5}`;
/** @deprecated 保留空串兼容旧引用 */
export const MEDIA_CARD_CONTAINER = "";
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { Spinner } from "@heroui/react";
type PageLoadingProps = {
label?: string;
className?: string;
};
export function PageLoading({ label = "加载中…", className = "" }: PageLoadingProps) {
return (
<div data-component="page-loading"
role="status"
aria-live="polite"
className={`flex min-h-[240px] flex-col items-center justify-center gap-3 text-sm leading-5 text-white/55 ${className}`}
>
<Spinner size="lg" color="accent" className="text-[#F12B9E]" />
<span>{label}</span>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { navigatePageHref } from "./pagination-map";
type PaginationJumpProps = {
total: number;
onJump?: (page: number) => void;
/** 可序列化页码 → 地址,供 RSC 父级使用 */
hrefMap?: Record<number, string>;
};
export function PaginationJump({ total, onJump, hrefMap }: PaginationJumpProps) {
const [jump, setJump] = useState("");
const submitJump = () => {
const target = Number(jump);
if (!Number.isInteger(target) || target < 1 || target > total) return;
const href = hrefMap?.[target];
if (href) navigatePageHref(href);
else onJump?.(target);
setJump("");
};
return (
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
submitJump();
}}
>
<span className="text-sm leading-5 text-white/55">前往</span>
<input
type="number"
min={1}
max={total}
value={jump}
onChange={(event) => setJump(event.target.value)}
aria-label="页码"
className="h-8 w-16 rounded-2xl bg-[#211F38] text-center text-xs font-semibold text-[#948CA8]/90 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<span className="text-sm leading-5 text-white/55">页</span>
<button type="submit" className="cursor-pointer h-8 w-20 rounded-2xl bg-[#F12B9E] text-xs font-semibold text-white">
确定
</button>
</form>
);
}
+15
View File
@@ -0,0 +1,15 @@
/** 供列表页生成可序列化页码 → href(可在 RSC 中调用) */
export function buildPageHrefMap(total: number, hrefForPage: (page: number) => string): Record<number, string> {
const map: Record<number, string> = {};
for (let i = 1; i <= total; i++) map[i] = hrefForPage(i);
return map;
}
/**
* 分页硬跳转:避开 design-frame CSS zoom 下 Next 软导航静默失败。
* 仅在客户端调用。
*/
export function navigatePageHref(href: string) {
if (typeof window === "undefined") return;
window.location.assign(href);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import type { MouseEvent, ReactNode } from "react";
import { navigatePageHref } from "./pagination-map";
import { PaginationJump } from "./pagination-jump";
type PaginationBase = {
page: number;
total: number;
className?: string;
showJump?: boolean;
};
type PaginationCallbackProps = PaginationBase & {
onChange: (page: number) => void;
hrefMap?: never;
};
type PaginationLinkProps = PaginationBase & {
/** 页码 → href,须为可序列化对象(RSC 不可传函数给 Client) */
hrefMap: Record<number, string>;
onChange?: never;
};
export type PaginationProps = PaginationCallbackProps | PaginationLinkProps;
const itemClass = "flex h-8 min-w-9 items-center justify-center rounded-2xl px-2 text-xs leading-[18px]";
function pageItems(page: number, total: number): (number | "gap")[] {
if (total <= 7) return Array.from({ length: total }, (_, index) => index + 1);
if (page <= 3) return [1, 2, 3, "gap", total];
if (page >= total - 2) return [1, "gap", total - 2, total - 1, total];
return [1, "gap", page - 1, page, page + 1, "gap", total];
}
function Chevron({ direction }: { direction: "left" | "right" }) {
return (
<svg viewBox="0 0 14 14" fill="none" className={`size-3.5 ${direction === "left" ? "rotate-180" : ""}`} aria-hidden="true">
<path d="M5.5 4l3 3-3 3" stroke="#8F8F9F" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function PageControl({
disabled,
href,
onClick,
ariaLabel,
children,
}: {
disabled: boolean;
href?: string;
onClick?: () => void;
ariaLabel: string;
children: ReactNode;
}) {
const className = `${itemClass} bg-[#211F38] ${disabled ? "pointer-events-none opacity-40" : "cursor-pointer"}`;
if (href && !disabled) {
return (
<a
href={href}
aria-label={ariaLabel}
className={className}
onClick={(event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
navigatePageHref(href);
}}
>
{children}
</a>
);
}
return (
<button type="button" aria-label={ariaLabel} disabled={disabled} onClick={onClick} className={className}>
{children}
</button>
);
}
function PageNumber({
item,
current,
href,
onClick,
}: {
item: number;
current: number;
href?: string;
onClick?: () => void;
}) {
const active = item === current;
const className = `${itemClass} ${active ? "bg-[#F12B9E] font-semibold text-white" : "bg-[#211F38] text-[#948CA8] hover:text-white"} ${href || onClick ? "cursor-pointer" : ""}`;
if (href && !active) {
return (
<a
href={href}
className={className}
onClick={(event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
navigatePageHref(href);
}}
>
{item}
</a>
);
}
if (href && active) {
return (
<span aria-current="page" className={className}>
{item}
</span>
);
}
return (
<button type="button" aria-current={active ? "page" : undefined} onClick={onClick} className={className}>
{item}
</button>
);
}
export function Pagination({ page, total, className = "", showJump = true, ...rest }: PaginationProps) {
const hrefMap = "hrefMap" in rest ? rest.hrefMap : undefined;
const onChange = "onChange" in rest ? rest.onChange : undefined;
return (
<nav
data-component="pagination"
className={`relative z-20 flex flex-wrap items-center justify-center gap-2 ${className}`}
aria-label="分页"
>
<PageControl
disabled={page === 1}
href={hrefMap && page > 1 ? hrefMap[page - 1] : undefined}
onClick={onChange ? () => onChange(page - 1) : undefined}
ariaLabel="上一页"
>
<Chevron direction="left" />
</PageControl>
{pageItems(page, total).map((item, index) =>
item === "gap" ? (
<span key={`gap-${index}`} className="text-xs text-[#948CA8]">
…
</span>
) : (
<PageNumber
key={item}
item={item}
current={page}
href={hrefMap?.[item]}
onClick={onChange ? () => onChange(item) : undefined}
/>
),
)}
<PageControl
disabled={page === total}
href={hrefMap && page < total ? hrefMap[page + 1] : undefined}
onClick={onChange ? () => onChange(page + 1) : undefined}
ariaLabel="下一页"
>
<Chevron direction="right" />
</PageControl>
{showJump ? <PaginationJump total={total} onJump={onChange} hrefMap={hrefMap} /> : null}
</nav>
);
}
+185
View File
@@ -0,0 +1,185 @@
export const CARD_TAG_MAX = 3;
export type CardTagItem = {
/** 标签 ID;无 slug 时用于跳转 /tag/{kind}/{id} */
id?: number;
/** 标签 slug;有则优先跳转 /tag/{kind}/{slug} */
slug?: string;
name: string;
};
/**
* 按 index 对齐 ids / names / slugs;缺则空着,不自行补展示文案。
* 短剧:pickPlayletCardTags(item) → tagIds + tagNames + tagSlugs
* 视频:pickVideoCardTags(item) → tags + tagsName + tagSlugs(字段名与短剧不同)
*/
export function pickCardTags(
ids?: Array<number | string | null | undefined> | null,
names?: Array<string | null | undefined> | null,
extras?: Array<string[] | undefined | null>,
slugs?: Array<string | null | undefined> | null,
): CardTagItem[] {
const seen = new Set<string>();
const out: CardTagItem[] = [];
const idList = ids ?? [];
const nameList = names ?? [];
const slugList = slugs ?? [];
const len = Math.max(idList.length, nameList.length, slugList.length);
for (let i = 0; i < len; i++) {
const id = coerceTagId(idList[i]);
const slug = String(slugList[i] ?? "").trim() || undefined;
// 展示文案只用名称(或 slug);禁止把数字 id 当成标签名
const name = String(nameList[i] ?? "").trim() || slug || "";
if (!name) continue;
const key = slug ? `slug:${slug}` : id != null ? `id:${id}` : `name:${name}`;
if (seen.has(key) || seen.has(`name:${name}`)) continue;
seen.add(key);
seen.add(`name:${name}`);
out.push({ id, slug, name });
if (out.length >= CARD_TAG_MAX) return out;
}
for (const list of extras ?? []) {
for (const raw of list ?? []) {
const name = String(raw || "").trim();
if (!name || seen.has(`name:${name}`)) continue;
seen.add(`name:${name}`);
out.push({ name });
if (out.length >= CARD_TAG_MAX) return out;
}
}
return out;
}
/**
* 短剧列表/卡片标签:
* 仅用 tagIds + tagNames + tagSlugs(按 index 对齐);跳转优先 slug,无 slug 用 id。
* 缺字段则空着,不用 themeNames / anchor 等补位。
*/
export function pickPlayletCardTags(item: {
tagIds?: Array<number | string | null | undefined> | null;
tagNames?: Array<string | null | undefined> | null;
tagSlugs?: Array<string | null | undefined> | null;
}): CardTagItem[] {
return pickCardTags(item.tagIds, flattenTagNames(item.tagNames), undefined, flattenTagNames(item.tagSlugs));
}
function coerceTagId(raw: unknown): number | undefined {
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) return Math.floor(raw);
if (typeof raw === "string" && /^\d+$/.test(raw.trim())) {
const id = Number(raw.trim());
return id > 0 ? id : undefined;
}
return undefined;
}
function flattenTagNames(
list?: Array<string | null | undefined | Array<string | null | undefined>> | null,
): string[] {
if (!list?.length) return [];
const out: string[] = [];
for (const item of list) {
if (Array.isArray(item)) {
for (const nested of item) {
const name = String(nested ?? "").trim();
if (name) out.push(name);
}
continue;
}
const name = String(item ?? "").trim();
if (name) out.push(name);
}
return out;
}
function isVideoTagObject(
tag: unknown,
): tag is { id?: number; name?: string; tagName?: string; slug?: string } {
return tag != null && typeof tag === "object" && !Array.isArray(tag);
}
/**
* 视频列表/卡片标签(与短剧字段名不同,见 swagger video/list):
* - tags → 标签 id(短剧为 tagIds)
* - tagsName → 标签名称(短剧为 tagNames)
* - tagSlugs → 标签 slug(与短剧同名);跳转优先 slug,无 slug 用 id
* 详情态 tags 可能为对象数组(含 name / slug)。
*/
export function pickVideoCardTags(item: {
tags?: Array<
| number
| string
| { id?: number; name?: string; tagName?: string; slug?: string }
| Array<number | string | null | undefined>
| null
| undefined
> | null;
/** swagger:标签名称 tagsName */
tagsName?: Array<string | null | undefined | Array<string | null | undefined>> | null;
/** swagger:标签 slug tagSlugs */
tagSlugs?: Array<string | null | undefined | Array<string | null | undefined>> | null;
}): CardTagItem[] {
const rawTags = item.tags ?? [];
// 详情态:tags 为对象数组
if (rawTags.some(isVideoTagObject)) {
return normalizeCardTags(
rawTags.filter(isVideoTagObject).map((tag) => ({
id: tag.id,
slug: tag.slug,
name: String(tag.name || tag.tagName || "").trim(),
})),
);
}
const ids: number[] = [];
for (const tag of rawTags) {
if (Array.isArray(tag)) {
for (const nested of tag) {
const id = coerceTagId(nested);
if (id != null) ids.push(id);
}
continue;
}
const id = coerceTagId(tag);
if (id != null) ids.push(id);
}
return pickCardTags(ids, flattenTagNames(item.tagsName), undefined, flattenTagNames(item.tagSlugs));
}
export function normalizeCardTags(
tags?: Array<CardTagItem | string> | string | null,
): CardTagItem[] {
const list = Array.isArray(tags) ? tags : tags ? [tags] : [];
const out: CardTagItem[] = [];
const seen = new Set<string>();
for (const item of list) {
if (typeof item === "string") {
const name = item.trim();
if (!name || seen.has(`name:${name}`)) continue;
seen.add(`name:${name}`);
out.push({ name });
} else {
const name = String(item?.name || "").trim();
const slug = String(item?.slug || "").trim() || undefined;
const id =
typeof item?.id === "number" && Number.isFinite(item.id) && item.id > 0 ? item.id : undefined;
const label = name || slug || "";
if (!label) continue;
const key = slug ? `slug:${slug}` : id != null ? `id:${id}` : `name:${label}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ id, slug, name: label });
}
if (out.length >= CARD_TAG_MAX) break;
}
return out;
}
/** 可跳转:有 slug 或 id */
export function cardTagCanLink(tag: CardTagItem) {
return Boolean(tag.slug?.trim() || tag.id != null);
}
+38
View File
@@ -0,0 +1,38 @@
import { rankNumberFont } from "@/lib/fonts";
export function RankNumber({ rank, className = "text-[28px] leading-7" }: { rank?: number; className?: string }) {
if (rank === undefined) return null;
return (
<span
data-component="rank-number"
className={`${rankNumberFont.className} shrink-0 font-extrabold text-[#F12B9E] italic tabular-nums ${className}`}
>
{String(rank).padStart(2, "0")}
</span>
);
}
const rankBadgeBgs = [
"/images/rank_num1.png",
"/images/rank_num2.png",
"/images/rank_num3.png",
] as const;
const rankBadgeFallback = "/images/rank_num4.png";
export function CoverRankBadge({ rank, className = "" }: { rank?: number; className?: string }) {
if (rank === undefined) return null;
const bg = rankBadgeBgs[rank - 1] ?? rankBadgeFallback;
const textClass = rank <= 1 ? "text-xl leading-7 font-medium" : "text-lg leading-6 font-semibold";
return (
<span
data-component="cover-rank-badge"
className={`absolute bottom-0 left-0 flex h-[42px] w-[54px] items-center justify-center bg-left-bottom bg-no-repeat text-white tabular-nums ${textClass} ${className}`}
style={{ backgroundImage: `url(${bg})`, backgroundSize: "100% 100%" }}
>
{rank}
</span>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
const actionClass =
"flex h-10 w-[108px] items-center justify-center rounded-[22px] text-sm leading-5 font-semibold transition-opacity hover:opacity-90 lg:h-11 lg:w-[120px]";
export function ReloadButton({ onReload, className = "" }: { onReload?: () => void; className?: string }) {
return (
<button
type="button"
onClick={onReload ?? (() => window.location.reload())}
className={`cursor-pointer ${actionClass} bg-[#211F38] text-[#D1CCE0] outline outline-1 -outline-offset-1 outline-[#4D476B] ${className}`}
>
重新加载
</button>
);
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { Toast } from "@heroui/react";
/** 全局 Toast 宿主:深色主题 + 顶部居中(适配站点视觉) */
export function ToastHost() {
return <Toast.Provider placement="top" width={360} maxVisibleToasts={3} />;
}