@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 || ""),
|
||||
};
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 = "";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Toast } from "@heroui/react";
|
||||
|
||||
/** 全局 Toast 宿主:深色主题 + 顶部居中(适配站点视觉) */
|
||||
export function ToastHost() {
|
||||
return <Toast.Provider placement="top" width={360} maxVisibleToasts={3} />;
|
||||
}
|
||||
Reference in New Issue
Block a user