Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-26 17:51:31 +07:00
co-authored by Claude Opus 5.5
commit 61d0a11dfa
371 changed files with 35836 additions and 0 deletions
+245
View File
@@ -0,0 +1,245 @@
import Link from "next/link";
import { AdBannersSlot, AdIconsSlot, FriendLinksSlot, H5FriendLinksSlot } from "@/components/common/ads/slots";
import { ChannelHeader, H5ChannelHeader } from "@/components/common/channel-header";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { FilterChipLink, FilterOptionLink } from "@/components/common/filter-link";
import { MEDIA_CARD_TITLE_PC } from "@/components/common/media-card-hover";
import { Pagination } from "@/components/common/pagination";
import { buildPageHrefMap } from "@/components/common/pagination-map";
import { FloatingActions } from "@/components/home/floating-actions";
import { topicCoverSrc } from "@/components/home/home-module-style";
import { DownloadBanner } from "@/components/home/page-sections";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { HomeTopicData, PlayletData, VideoInfo } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import {
isTopicVideo,
topicTotalPages,
unwrapTopicListItem,
type TopicPageData,
} from "./data";
import { topicDetailHref } from "./href";
import { topicListHref, topicSortOptions } from "./route";
import { TopicPlayletCard, TopicVideoCard } from "./topic-cards";
const layouts = {
playlet: {
eyebrow: "短剧专题",
gridClassName: "grid grid-cols-[repeat(6,266px)] justify-between gap-y-6",
h5GridClassName: "grid grid-cols-2 gap-x-[7px] gap-y-3",
morePcClassName: "grid grid-cols-2 gap-5 lg:grid-cols-4",
},
video: {
eyebrow: "视频专题",
gridClassName: "grid grid-cols-[repeat(4,400px)] justify-between gap-y-6",
h5GridClassName: "grid grid-cols-2 gap-x-[7px] gap-y-3",
morePcClassName: "grid grid-cols-2 gap-5 lg:grid-cols-3",
},
} as const;
function MoreTopicGrid({
topics,
dataType,
compact,
gridClassName,
}: {
topics: HomeTopicData[];
dataType: 1 | 2;
compact?: boolean;
gridClassName: string;
}) {
if (!topics.length) return null;
return (
<section className="flex flex-col gap-3 lg:gap-5">
<h2 className={compact ? "text-base font-medium text-white" : "text-xl font-medium text-white"}>更多专题</h2>
<div className={compact ? "grid grid-cols-2 gap-[7px]" : gridClassName}>
{topics.map((topic) => (
<Link
key={topic.id}
href={topicDetailHref(topic, dataType)}
className={`relative overflow-hidden rounded-lg bg-[#110F1F] ${
compact ? (dataType === 2 ? "aspect-[7/4]" : "aspect-[16/10]") : "h-[231px]"
}`}
>
<DecryptedImage
src={topicCoverSrc(topic)}
fallback={IMG_PLACEHOLDER}
alt={topic.name || ""}
className="absolute inset-0 h-full w-full object-cover object-center"
/>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_38.85%_58.45%_at_66.18%_60.1%,rgba(255,255,255,0)_0%,rgba(0,0,0,0.55)_100%)]" />
<div className={compact ? "absolute bottom-3 left-3 right-3" : "absolute bottom-6 left-8 right-8"}>
<h3
className={
compact
? "truncate text-base font-medium text-[#F2F2FA]"
: `text-[#F2F2FA] ${MEDIA_CARD_TITLE_PC}`
}
>
{topic.name}
</h3>
</div>
</Link>
))}
</div>
</section>
);
}
function TopicList({
data,
compact,
}: {
data: TopicPageData;
compact?: boolean;
}) {
const dataType: 1 | 2 = isTopicVideo(data.dataType) ? 2 : 1;
const items = (data.list ?? [])
.map((item) => unwrapTopicListItem(item, dataType))
.filter((item): item is PlayletData | VideoInfo => Boolean(item?.id));
if (!items.length) {
return <p className={compact ? "py-16 text-center text-sm leading-5 text-white/45" : "py-20 text-center text-sm leading-5 text-white/45"}>暂无专题内容</p>;
}
const layout = dataType === 2 ? layouts.video : layouts.playlet;
return (
<div className={compact ? layout.h5GridClassName : layout.gridClassName}>
{items.map((item) =>
dataType === 2 ? (
<div key={item.id} className="min-w-0 h-full overflow-hidden">
<TopicVideoCard item={item as VideoInfo} compact={compact} />
</div>
) : (
<div key={item.id} className="min-w-0 h-full overflow-hidden">
<TopicPlayletCard item={item as PlayletData} compact={compact} />
</div>
),
)}
</div>
);
}
function TopicSortBar({
pathKey,
dataType,
sortType,
compact,
}: {
pathKey: string;
dataType: 1 | 2;
sortType: TopicPageData["sortType"];
compact?: boolean;
}) {
if (compact) {
return (
<div className="flex items-center gap-3 overflow-x-auto">
<span className="shrink-0 whitespace-nowrap text-xs leading-[18px] text-[#948F9E]">排序:</span>
<div className="flex shrink-0 items-center gap-x-3.5">
{topicSortOptions.map((item) => (
<FilterOptionLink
key={item.sortType}
label={item.label}
href={topicListHref(pathKey, { dataType, sortType: item.sortType, page: 1 })}
active={sortType === item.sortType}
/>
))}
</div>
</div>
);
}
return (
<div className="flex flex-nowrap items-center gap-3">
<span className="shrink-0 whitespace-nowrap text-xs leading-[18px] text-white">排序:</span>
{topicSortOptions.map((item) => (
<FilterChipLink
key={item.sortType}
label={item.label}
href={topicListHref(pathKey, { dataType, sortType: item.sortType, page: 1 })}
active={sortType === item.sortType}
/>
))}
</div>
);
}
export function TopicPage({ data }: { data: TopicPageData }) {
const isVideo = isTopicVideo(data.dataType);
const dataType: 1 | 2 = isVideo ? 2 : 1;
const layout = isVideo ? layouts.video : layouts.playlet;
const name = (data.name || "").trim() || "专题";
const desc = (data.desc || "").trim();
// 与标题相同或为空时不展示副标题,避免「科幻 / 科幻」重复
const subtitle = desc && desc !== name ? desc : "";
const moreTopics = data.moreTopics ?? [];
const pageNum = data.pageNum || 1;
const totalPages = topicTotalPages(data);
const pathKey = (data.slug || "").trim() || (data.id != null ? String(data.id) : data.routeKey);
const sortType = data.sortType === 2 || data.sortType === 3 ? data.sortType : 1;
const hrefMap =
totalPages > 1
? buildPageHrefMap(totalPages, (page) => topicListHref(pathKey, { dataType, page, sortType }))
: undefined;
return (
<>
<SeoBreadcrumb items={[{ name: "首页", href: "/" }, { name: "专题", href: "/topic" }, { name }]} />
<main
data-component="topic-page"
data-datatype={dataType}
className="flex w-full min-w-0 flex-col gap-[18px] overflow-x-clip px-4 py-3 lg:hidden"
>
<H5ChannelHeader eyebrow={layout.eyebrow} title={name} subtitle={subtitle} />
<AdIconsSlot variant="h5" />
<div className="flex scroll-mt-16 flex-col gap-[9px] rounded-2xl border border-[#292138] bg-[#110D19] px-[19px] py-4">
<TopicSortBar pathKey={pathKey} dataType={dataType} sortType={sortType} compact />
</div>
<TopicList data={data} compact />
{hrefMap ? <Pagination page={pageNum} total={totalPages} hrefMap={hrefMap} className="[&_form]:hidden" /> : null}
<MoreTopicGrid
topics={moreTopics}
dataType={dataType}
compact
gridClassName={layout.morePcClassName}
/>
<AdBannersSlot variant="h5" />
<H5FriendLinksSlot />
</main>
<main data-component="topic-page" data-datatype={dataType} className="hidden lg:block">
<div className="design-frame flex flex-col gap-6 py-[43px]">
<ChannelHeader eyebrow={layout.eyebrow} title={name} subtitle={subtitle} />
<AdIconsSlot variant="pc" />
<div className="flex scroll-mt-24 flex-col gap-4 rounded-[17px] border border-[#34304B] bg-[#151326] px-5 py-4">
<TopicSortBar pathKey={pathKey} dataType={dataType} sortType={sortType} />
</div>
<TopicList data={data} />
</div>
{/* 分页放在 design-frame 外,避免 Chrome CSS zoom 热区错位导致点击无响应 */}
{hrefMap ? (
<div className="flex justify-center px-6 py-6">
<Pagination page={pageNum} total={totalPages} hrefMap={hrefMap} />
</div>
) : null}
<div className="design-frame flex flex-col gap-6 pb-[43px]">
<MoreTopicGrid topics={moreTopics} dataType={dataType} gridClassName={layout.morePcClassName} />
<AdBannersSlot variant="pc" />
<DownloadBanner />
<FriendLinksSlot />
</div>
</main>
<FloatingActions />
</>
);
}