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
+123
View File
@@ -0,0 +1,123 @@
import { mapContentListItem } from "@/components/ranking/data";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { getPlayletHomeConfigListCached } from "@/lib/api/playlet";
import type {
HomeModuleData,
HomeTopicData,
PlayletContentListItem,
PlayletData,
PlayletHomeConfigListResp,
VideoInfo,
} from "@/lib/api/types";
export const MORE_PAGE_SIZE = 24;
export type MoreTab = "短剧" | "视频";
export type MorePageData = {
configId: string;
title: string;
subtitle: string;
dataType: 1 | 2;
tab: MoreTab;
items: ShortDramaItem[];
topics: HomeTopicData[];
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
};
export function parseMoreTab(value: string | null | undefined): MoreTab {
return value === "视频" ? "视频" : "短剧";
}
function resolveDataType(config?: HomeModuleData | null, fallback: 1 | 2 = 1): 1 | 2 {
return config?.dataType === 2 ? 2 : fallback;
}
function asContentItem(
item: PlayletContentListItem | PlayletData | VideoInfo,
dataType: 1 | 2,
): PlayletContentListItem {
if (item && typeof item === "object" && ("playlet" in item || "video" in item || "advertise" in item || "type" in item)) {
return item as PlayletContentListItem;
}
if (dataType === 2) return { type: 0, video: item as VideoInfo };
return { type: 0, playlet: item as PlayletData };
}
export function buildMoreList(
data: PlayletHomeConfigListResp | null | undefined,
configId: string,
fallbackPage = 1,
fallbackPageSize = MORE_PAGE_SIZE,
): MorePageData {
const config = data?.config;
const dataType = resolveDataType(config);
const tab: MoreTab = dataType === 2 ? "视频" : "短剧";
const page = data?.pageNum || fallbackPage;
const pageSize = data?.pageSize || fallbackPageSize;
const totalCount = data?.totalCount || 0;
const items = (data?.list ?? [])
.map((item) => mapContentListItem(asContentItem(item, dataType), dataType))
.filter((item): item is ShortDramaItem => Boolean(item));
const topics = data?.topicList ?? config?.topicList ?? [];
return {
configId,
title: (config?.title || config?.label || "").trim() || "更多内容",
subtitle: (config?.desc || "").trim() || "按频道浏览更多内容,快速发现下一部想看的作品",
dataType,
tab,
items,
topics,
page,
pageSize,
totalCount,
totalPages: Math.max(1, Math.ceil(totalCount / pageSize) || 1),
};
}
function homeConfigLookup(idOrSlug: string): { id?: string; slug?: string } {
if (/^\d+$/.test(idOrSlug)) return { id: idOrSlug };
return { slug: idOrSlug };
}
/** 规范路径段:优先 config.slug,回退 id */
export function moreConfigPathKey(config?: { id?: number | string | null; slug?: string | null } | null, fallback = "") {
return (config?.slug?.trim() || String(config?.id ?? "").trim() || fallback).trim();
}
export async function loadMorePage(configId: string, page = 1): Promise<MorePageData> {
const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
try {
const data = await getPlayletHomeConfigListCached({
...homeConfigLookup(configId),
pageNum: safePage,
pageSize: MORE_PAGE_SIZE,
});
const pathKey = moreConfigPathKey(data.config, configId);
return buildMoreList(data, pathKey || configId, safePage, MORE_PAGE_SIZE);
} catch (error) {
console.error("[more/home-config]", error);
return buildMoreList(null, configId, safePage, MORE_PAGE_SIZE);
}
}
/** @deprecated 无 configId 时的空态 */
export function emptyMorePage(tab: MoreTab = "短剧"): MorePageData {
return {
configId: "",
title: "更多内容",
subtitle: "按频道浏览更多短剧与视频,快速发现下一部想看的作品",
dataType: tab === "视频" ? 2 : 1,
tab,
items: [],
topics: [],
page: 1,
pageSize: MORE_PAGE_SIZE,
totalCount: 0,
totalPages: 1,
};
}
+16
View File
@@ -0,0 +1,16 @@
import { moreListHref } from "./route";
type MoreConfigKey = {
id?: number | string | null;
slug?: string | null;
};
/** 首页模块「查看更多」:优先 slug,无 slug 回退 id → /more/{slug|id} */
export function morePageHref(config: MoreConfigKey | number | string, page = 1) {
if (typeof config === "number" || typeof config === "string") {
return moreListHref(config, page);
}
const key = (config.slug?.trim() || String(config.id ?? "").trim()).trim();
if (!key) return "/more";
return moreListHref(key, page);
}
+159
View File
@@ -0,0 +1,159 @@
import { AdBannersSlot, AdIconsSlot, FriendLinksSlot, H5FriendLinksSlot } from "@/components/common/ads/slots";
import Link from "next/link";
import type { ReactNode } from "react";
import { ChannelHeader, H5ChannelHeader } from "@/components/common/channel-header";
import { Pagination } from "@/components/common/pagination";
import { buildPageHrefMap } from "@/components/common/pagination-map";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { FloatingActions } from "@/components/home/floating-actions";
import { topicCountLabel, topicCoverSrc } from "@/components/home/home-module-style";
import { DownloadBanner } from "@/components/home/page-sections";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { H5ShortDramaCard, ShortDramaCard } from "@/components/short-drama/short-drama-card";
import { topicDetailHref } from "@/components/topic/href";
import { H5VideoCard, VideoCard } from "@/components/video/video-card";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import type { MorePageData } from "./data";
import { moreListHref } from "./route";
type CardRenderer = (item: ShortDramaItem) => ReactNode;
const layouts = {
短剧: {
gridClassName: "grid-cols-[repeat(auto-fill,266px)] gap-5",
h5GridClassName: "grid-cols-2 gap-x-[7px] gap-y-3",
renderCard: (item: ShortDramaItem) => <ShortDramaCard key={item.id} item={item} />,
renderH5Card: (item: ShortDramaItem) => <H5ShortDramaCard key={item.id} item={item} />,
},
视频: {
gridClassName: "grid-cols-[repeat(auto-fill,324px)] gap-5",
h5GridClassName: "grid-cols-2 gap-x-[7px] gap-y-3",
renderCard: (item: ShortDramaItem) => <VideoCard key={item.id} item={item} />,
renderH5Card: (item: ShortDramaItem) => <H5VideoCard key={item.id} item={item} />,
},
} satisfies Record<MorePageData["tab"], { gridClassName: string; h5GridClassName: string; renderCard: CardRenderer; renderH5Card: CardRenderer }>;
function TopicGrid({
topics,
dataType,
compact,
}: {
topics: MorePageData["topics"];
dataType: MorePageData["dataType"];
compact?: boolean;
}) {
if (!topics.length) return null;
return (
<div className={compact ? "grid grid-cols-2 gap-[7px]" : "grid grid-cols-2 gap-5 lg:grid-cols-4"}>
{topics.map((topic) => {
const cover = topicCoverSrc(topic);
const countText = topicCountLabel(topic, dataType, "部");
return (
<Link
key={topic.id}
href={topicDetailHref(topic, dataType)}
className={`relative overflow-hidden rounded-lg bg-[#110F1F] ${compact ? "aspect-[16/10]" : "h-[231px]"}`}
>
<DecryptedImage
src={cover}
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]"
: "truncate text-2xl leading-8 font-medium text-[#F2F2FA]"
}
>
{topic.name}
</h3>
{countText ? (
<span
className={
compact
? "mt-1 inline-flex rounded bg-black/50 px-1.5 py-0.5 text-[10px] text-[#F68804]"
: "mt-3 inline-flex rounded bg-black/50 px-2.5 py-1 text-xs text-[#F68804]"
}
>
{countText}
</span>
) : null}
</div>
</Link>
);
})}
</div>
);
}
export function MorePage({ data }: { data: MorePageData }) {
const layout = layouts[data.tab];
const header = {
eyebrow: "MORE",
title: data.title,
subtitle: data.subtitle,
};
const hrefMap =
data.configId && data.totalPages > 1
? buildPageHrefMap(data.totalPages, (page) => moreListHref(data.configId, page))
: undefined;
return (
<>
<SeoBreadcrumb items={[{ name: "首页", href: "/" }, { name: data.title || "更多内容" }]} />
<main data-component="more-page" className="flex w-full min-w-0 flex-col gap-[18px] overflow-x-clip px-4 py-3 lg:hidden">
<H5ChannelHeader {...header} />
<AdIconsSlot variant="h5" />
<TopicGrid topics={data.topics} dataType={data.dataType} compact />
{data.items.length ? (
<div className={`grid ${layout.h5GridClassName}`}>{data.items.map((item) => layout.renderH5Card(item))}</div>
) : !data.topics.length ? (
<p className="py-16 text-center text-sm leading-5 text-white/45">暂无更多内容</p>
) : null}
{hrefMap ? <Pagination page={data.page} total={data.totalPages} hrefMap={hrefMap} className="[&_form]:hidden" /> : null}
<AdBannersSlot variant="h5" />
<H5FriendLinksSlot />
</main>
<main className="hidden lg:block">
<div className="design-frame flex flex-col gap-6 py-[43px]">
<ChannelHeader {...header} />
<AdIconsSlot variant="pc" />
<TopicGrid topics={data.topics} dataType={data.dataType} />
{data.items.length ? (
<div className={`grid scroll-mt-24 justify-between ${layout.gridClassName}`}>
{data.items.map((item) => layout.renderCard(item))}
</div>
) : !data.topics.length ? (
<p className="py-20 text-center text-sm leading-5 text-white/45">暂无更多内容</p>
) : null}
</div>
{/* 分页放在 design-frame 外,避免 Chrome CSS zoom 热区错位导致点击无响应 */}
{hrefMap ? (
<div className="flex justify-center px-6 py-6">
<Pagination page={data.page} total={data.totalPages} hrefMap={hrefMap} />
</div>
) : null}
<div className="design-frame flex flex-col gap-6 pb-[43px]">
<AdBannersSlot variant="pc" />
<DownloadBanner />
<FriendLinksSlot />
</div>
</main>
<FloatingActions />
</>
);
}
+15
View File
@@ -0,0 +1,15 @@
/** 首页模块更多页:/more/{id} 、/more/{id}/{page} */
export function moreListHref(configId: number | string, page = 1) {
const id = String(configId).trim();
if (!id) return "/more";
const base = `/more/${encodeURIComponent(id)}`;
return page > 1 ? `${base}/${page}` : base;
}
export function parseMorePageSegment(segments: string[] | undefined): number | null {
if (!segments?.length) return 1;
if (segments.length !== 1) return null;
const num = Number(segments[0]);
if (!Number.isInteger(num) || num < 1) return null;
return num;
}