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
+161
View File
@@ -0,0 +1,161 @@
import { pickPlayletCardTags, type CardTagItem } from "@/components/common/card-tags";
import type { BadgeTone } from "@/components/home/data";
import { resolveContentBadge } from "@/components/home/section-heading";
import { formatHeat } from "@/components/ranking/data";
import type { PlayletListItem, PlayletListResp, PlayletThemeItem } from "@/lib/api/types";
export const genres = ["全部", "甜宠", "逆袭", "霸总", "重生", "豪门", "古装", "玄幻", "都市"] as const;
/** 路由排序段(英文);展示文案见 sortLabels */
export const sorts = ["hot", "latest"] as const;
export const sortLabels: Record<(typeof sorts)[number], string> = {
hot: "热度优先",
latest: "最近更新",
};
/** 兼容旧中文路径 → 英文 sort */
export const sortPathAliases: Record<string, (typeof sorts)[number]> = {
hot: "hot",
latest: "latest",
热度优先: "hot",
最近更新: "latest",
};
export const regions = ["全部", "内地", "中国香港", "中国台湾", "韩国", "日本", "欧美"] as const;
export type Genre = (typeof genres)[number];
export type Sort = (typeof sorts)[number];
export type Region = (typeof regions)[number];
export type ShortDramaItem =
| {
kind: "drama";
id: string;
slug?: string;
title: string;
subtitle: string;
/** 左下角标签,最多 3 个(优先带 id 以便跳转) */
tags: CardTagItem[];
heat: string;
episodes: string;
badge?: BadgeTone;
cover: string;
/** 覆盖默认详情/播放链接(如历史继续看到某集) */
href?: string;
}
| {
kind: "ad";
id: string;
title: string;
subtitle: string;
cover: string;
href?: string;
};
export const PAGE_SIZE = 24;
export const TOTAL_PAGES = 11;
export function shortDramaSortType(sort: Sort): 1 | 2 {
return sort === "latest" ? 2 : 1;
}
export function mapPlayletListItem(item: PlayletListItem): ShortDramaItem | null {
if (item.type === 2) {
return {
kind: "ad",
id: `ad-${item.advertiseId || item.id || item.link || "unknown"}`,
title: (item.title || "").trim() || "广告",
subtitle: "",
cover: (item.cover || "").trim(),
href: (item.link || "").trim() || undefined,
};
}
if (!item.id) return null;
const tags = pickPlayletCardTags(item);
const slug = (item.slug || "").trim();
return {
kind: "drama",
id: String(item.id),
slug: slug || undefined,
title: (item.title || "").trim() || "未命名",
subtitle: (item.desc || "").trim(),
tags,
heat: formatHeat(item.heatValue ?? item.countBrowse),
episodes: item.totalEpisode ? `${item.totalEpisode}集` : "",
badge: resolveContentBadge(item),
cover: (item.cover || "").trim(),
};
}
export type ShortDramaListResult = {
items: ShortDramaItem[];
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
sort: Sort;
themes: PlayletThemeItem[];
themeSlug?: string;
};
export function buildShortDramaList(
data: PlayletListResp | null | undefined,
sort: Sort,
fallbackPage = 1,
fallbackPageSize = PAGE_SIZE,
options?: { themes?: PlayletThemeItem[]; themeSlug?: string },
): ShortDramaListResult {
const page = data?.pageNum || fallbackPage;
const pageSize = data?.pageSize || fallbackPageSize;
const totalCount = data?.totalCount || 0;
const items = (data?.list ?? [])
.map(mapPlayletListItem)
.filter((item): item is ShortDramaItem => Boolean(item));
return {
items,
page,
pageSize,
totalCount,
totalPages: Math.max(1, Math.ceil(totalCount / pageSize) || 1),
sort,
themes: options?.themes ?? [],
themeSlug: options?.themeSlug,
};
}
/** @deprecated 本地 mock,供「更多」等未接接口页面临时使用 */
const adSlots = new Set([4, 9, 14, 18, 23]);
const freshSlots = new Set([3, 11, 21]);
export function getShortDramas(genre: Genre, sort: Sort, page: number, region: Region = "全部"): ShortDramaItem[] {
const seed = `${genres.indexOf(genre)}-${sorts.indexOf(sort)}-${regions.indexOf(region)}-${page}`;
return Array.from({ length: PAGE_SIZE }, (_, index): ShortDramaItem => {
const id = `short-${seed}-${index}`;
const cover = "";
if (adSlots.has(index)) {
return { kind: "ad", id, title: "广告文案", subtitle: "我是副标题", cover };
}
return {
kind: "drama",
id,
title: "有恩必报",
subtitle: "权谋交锋爱意藏在局中",
tags: [{ name: genre === "全部" ? "标签" : genre }],
heat: "25.8万 热度",
episodes: "30集",
badge: freshSlots.has(index) ? "fresh" : "original",
cover,
};
});
}
/** 有 slug 时路径用 slug,否则回退 id */
export function dramaPathKey(id: string | number, slug?: string | null) {
return (slug?.trim() || String(id)).trim();
}
export function dramaHref(id: string | number, slug?: string | null) {
return `/short-drama/${dramaPathKey(id, slug)}`;
}
+82
View File
@@ -0,0 +1,82 @@
import { sortPathAliases, sorts, type Sort } from "./data";
/**
* 短剧列表路径:
* - /short-drama/{sort} sort = hot | latest
* - /short-drama/{sort}/{page}
* - /short-drama/{sort}/{themeSlug}
* - /short-drama/{sort}/{themeSlug}/{page}
*/
export function shortDramaListHref(sort: Sort = "hot", page = 1, themeSlug?: string | null) {
const base = `/short-drama/${sort}`;
const slug = themeSlug?.trim();
if (slug) {
const withTheme = `${base}/${encodeURIComponent(slug)}`;
return page > 1 ? `${withTheme}/${page}` : withTheme;
}
return page > 1 ? `${base}/${page}` : base;
}
export function resolveShortDramaSort(value: string): Sort | null {
try {
return sortPathAliases[decodeURIComponent(value)] ?? null;
} catch {
return sortPathAliases[value] ?? null;
}
}
export function isShortDramaSort(value: string): value is Sort {
return resolveShortDramaSort(value) != null;
}
export function parseShortDramaSortSegment(value: string): Sort | null {
return resolveShortDramaSort(value);
}
function decodeSegment(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function parsePageNumber(value: string): number | null {
const num = Number(value);
if (!Number.isInteger(num) || num < 1) return null;
return num;
}
export type ShortDramaListPath = {
themeSlug?: string;
page: number;
};
/**
* 解析列表尾段:纯数字视为页码,否则视为题材 slug。
* 非法组合返回 null → notFound。
*/
export function parseShortDramaListPath(segments: string[] | undefined): ShortDramaListPath | null {
if (!segments?.length) return { page: 1 };
if (segments.length === 1) {
const only = decodeSegment(segments[0]);
const page = parsePageNumber(only);
if (page != null) return { page };
if (!only) return null;
return { themeSlug: only, page: 1 };
}
if (segments.length === 2) {
const themeSlug = decodeSegment(segments[0]).trim();
const page = parsePageNumber(decodeSegment(segments[1]));
if (!themeSlug || page == null) return null;
// 题材段不能是纯页码形态(避免 /sort/2/3 歧义)
if (parsePageNumber(themeSlug) != null) return null;
return { themeSlug, page };
}
return null;
}
export { sorts };
@@ -0,0 +1,144 @@
import Link from "next/link";
import { CardTags } from "@/components/common/card-tags";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
MEDIA_CARD_COVER,
MEDIA_CARD_DESC,
MEDIA_CARD_DESC_SM,
MEDIA_CARD_EPISODES,
MEDIA_CARD_EPISODES_H5,
MEDIA_CARD_FOOTER_H5,
MEDIA_CARD_INFO_H5,
MEDIA_CARD_META,
MEDIA_CARD_ROOT,
MEDIA_CARD_TITLE_H5,
MEDIA_CARD_TITLE_PC,
} from "@/components/common/media-card-hover";
import { CoverRankBadge, RankNumber } from "@/components/common/rank-number";
import { Badge } from "@/components/home/section-heading";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { dramaHref, type ShortDramaItem } from "./data";
export function ShortDramaCard({ item, rank }: { item: ShortDramaItem; rank?: number }) {
if (item.kind === "ad") {
return (
<a
href={item.href || "#download"}
target={item.href ? "_blank" : undefined}
rel={item.href ? "sponsored nofollow noopener noreferrer" : undefined}
className={`block w-[266px] ${MEDIA_CARD_ROOT}`}
>
<div className="relative h-[355px] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
<div className="absolute inset-0 bg-black/40" />
<img src="/logo.svg" alt="海角社区" className="absolute top-[21px] left-[21px] h-[19px] w-[84px]" />
</div>
<div className="flex min-h-[96px] flex-col justify-center gap-1 px-2.5 py-3">
<h3 className={MEDIA_CARD_TITLE_PC}>{item.title}</h3>
<p className={`${MEDIA_CARD_DESC} text-white/60`}>{item.subtitle}</p>
</div>
</a>
);
}
const detailHref = item.href || dramaHref(item.id, item.slug);
return (
<article data-component="short-drama-card" className={`w-[266px] ${MEDIA_CARD_ROOT}`}>
<Link href={detailHref} className="block">
<div className="relative h-[355px] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
{item.badge ? (
<div className="absolute top-2 right-2">
<Badge tone={item.badge} />
</div>
) : null}
{item.episodes ? (
<span
className={`absolute right-2 bottom-2 rounded-md bg-black/60 px-2.5 py-1.5 font-medium text-white ${MEDIA_CARD_EPISODES}`}
>
{item.episodes}
</span>
) : null}
</div>
<div className="flex min-w-0 flex-col gap-1 px-2.5 pt-3">
<h3 className={`flex min-h-7 items-center gap-1 ${MEDIA_CARD_TITLE_PC}`}>
<RankNumber rank={rank} />
<span className="min-w-0 truncate">{item.title}</span>
</h3>
<p className={`${MEDIA_CARD_DESC} text-white/60`}>{item.subtitle}</p>
</div>
</Link>
<div className={`flex min-w-0 items-center justify-between gap-2 px-2.5 pt-1 pb-3 ${MEDIA_CARD_META}`}>
<CardTags tags={item.tags} kind="playlet" className="min-w-0 text-white/60" />
<span className="shrink-0 text-[#F92882]">{item.heat}</span>
</div>
</article>
);
}
export function H5ShortDramaCard({
item,
rank,
showEpisodes = true,
}: {
item: ShortDramaItem;
rank?: number;
showEpisodes?: boolean;
}) {
if (item.kind === "ad") {
return (
<a
href={item.href || "#h5-download"}
target={item.href ? "_blank" : undefined}
rel={item.href ? "sponsored nofollow noopener noreferrer" : undefined}
className={`block ${MEDIA_CARD_ROOT}`}
>
<div className="relative aspect-[3/4] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
<div className="absolute inset-0 bg-black/40" />
<img src="/logo.svg" alt="海角社区" className="absolute top-[17px] left-[13px] h-4 w-[71px]" />
</div>
<div className={`${MEDIA_CARD_INFO_H5} pb-1.5`}>
<h3 className={MEDIA_CARD_TITLE_H5}>{item.title}</h3>
<p className={`${MEDIA_CARD_DESC_SM} text-[#8C859C]`}>{item.subtitle}</p>
</div>
</a>
);
}
const detailHref = item.href || dramaHref(item.id, item.slug);
return (
<article data-component="h5-short-drama-card" className={MEDIA_CARD_ROOT}>
<Link href={detailHref} className="block">
<div className="relative aspect-[3/4] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
{item.badge ? (
<div className="absolute top-2 right-2">
<Badge tone={item.badge} className="h-6" />
</div>
) : null}
<CoverRankBadge rank={rank} />
{showEpisodes ? (
<span
className={`absolute right-2 bottom-2 rounded-lg bg-[#08060D]/88 px-1.5 py-0.5 text-white ${MEDIA_CARD_EPISODES_H5}`}
>
{item.episodes}
</span>
) : null}
</div>
<div className={MEDIA_CARD_INFO_H5}>
<h3 className={MEDIA_CARD_TITLE_H5}>{item.title}</h3>
<p className={`${MEDIA_CARD_DESC_SM} text-[#8C859C]`}>{item.subtitle}</p>
</div>
</Link>
<div className={MEDIA_CARD_FOOTER_H5}>
<CardTags tags={item.tags} kind="playlet" className="min-w-0 text-[#A89EB5]" />
<span className="shrink-0 text-[#F12B9E]">{item.heat}</span>
</div>
</article>
);
}
@@ -0,0 +1,166 @@
import { AdBannersSlot, AdIconsSlot, FriendLinksSlot, H5FriendLinksSlot } from "@/components/common/ads/slots";
import { ChannelHeader, H5ChannelHeader } from "@/components/common/channel-header";
import { EmptyState } from "@/components/common/empty-state";
import { FilterChipLink, FilterOptionLink } from "@/components/common/filter-link";
import { Pagination } from "@/components/common/pagination";
import { buildPageHrefMap } from "@/components/common/pagination-map";
import { FloatingActions } from "@/components/home/floating-actions";
import { H5SectionHeading } from "@/components/home/h5-cards";
import { DownloadBanner } from "@/components/home/page-sections";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import { sortLabels, sorts, type ShortDramaListResult } from "./data";
import { shortDramaListHref } from "./route";
import { H5ShortDramaCard, ShortDramaCard } from "./short-drama-card";
export function ShortDramaPage({ list }: { list: ShortDramaListResult }) {
const hrefMap = buildPageHrefMap(list.totalPages, (page) =>
shortDramaListHref(list.sort, page, list.themeSlug),
);
const resetHref = shortDramaListHref("hot", 1);
const themes = list.themes.filter((theme) => theme.name);
return (
<>
<SeoBreadcrumb items={[{ name: "首页", href: "/" }, { name: "短剧" }]} />
<main data-component="short-drama-page" className="flex w-full min-w-0 flex-col gap-[18px] overflow-x-clip px-4 py-3 lg:hidden">
<H5ChannelHeader title="短剧" />
<AdIconsSlot variant="h5" />
<div
id="h5-list"
className="flex scroll-mt-16 flex-col gap-[9px] rounded-2xl border border-[#292138] bg-[#110D19] px-[19px] py-4"
>
{themes.length ? (
<div className="flex items-start gap-3">
<span className="shrink-0 text-xs leading-[18px] text-[#948F9E]">题材:</span>
<div className="flex min-w-0 flex-1 flex-wrap gap-x-3.5 gap-y-2">
<FilterOptionLink
label="全部"
href={shortDramaListHref(list.sort, 1)}
active={!list.themeSlug}
/>
{themes.map((theme) => (
<FilterOptionLink
key={theme.id}
label={theme.name || ""}
href={shortDramaListHref(list.sort, 1, theme.slug)}
active={Boolean(theme.slug && theme.slug === list.themeSlug)}
/>
))}
</div>
</div>
) : null}
<div className="flex items-start gap-3">
<span className="shrink-0 text-xs leading-[18px] text-[#948F9E]">排序:</span>
<div className="flex min-w-0 flex-1 flex-wrap gap-x-3.5 gap-y-2">
{sorts.map((item) => (
<FilterOptionLink
key={item}
label={sortLabels[item]}
href={shortDramaListHref(item, 1, list.themeSlug)}
active={item === list.sort}
/>
))}
<FilterOptionLink label="重置筛选" href={resetHref} />
</div>
</div>
</div>
<section className="flex flex-col gap-[18px]">
<H5SectionHeading title="全部短剧" more={false} />
{list.items.length ? (
<>
<div className="grid grid-cols-2 gap-x-[7px] gap-y-3">
{list.items.map((item) => (
<H5ShortDramaCard key={item.id} item={item} />
))}
</div>
{list.totalPages > 1 ? (
<Pagination page={list.page} total={list.totalPages} hrefMap={hrefMap} showJump={false} />
) : null}
</>
) : (
<EmptyState
title="暂无短剧"
description="当前筛选条件下没有可展示的短剧,换个题材或排序试试吧"
className="rounded-2xl px-4 py-12"
/>
)}
</section>
<AdBannersSlot variant="h5" />
<H5FriendLinksSlot />
</main>
<main className="hidden lg:block">
<div className="design-frame flex flex-col gap-6 py-[43px]">
<ChannelHeader title="短剧" />
<AdIconsSlot variant="pc" />
<div
id="pc-list"
className="flex scroll-mt-24 flex-col gap-4 rounded-[17px] border border-[#34304B] bg-[#151326] px-5 py-4"
>
{themes.length ? (
<div className="flex items-start gap-3">
<span className="shrink-0 pt-1.5 text-xs leading-[18px] text-white">题材:</span>
<div className="flex min-w-0 flex-1 flex-wrap gap-2">
<FilterChipLink label="全部" href={shortDramaListHref(list.sort, 1)} active={!list.themeSlug} />
{themes.map((theme) => (
<FilterChipLink
key={theme.id}
label={theme.name || ""}
href={shortDramaListHref(list.sort, 1, theme.slug)}
active={Boolean(theme.slug && theme.slug === list.themeSlug)}
/>
))}
</div>
</div>
) : null}
<div className="flex items-start gap-3">
<span className="shrink-0 pt-1.5 text-xs leading-[18px] text-white">排序:</span>
<div className="flex min-w-0 flex-1 flex-wrap gap-2">
{sorts.map((item) => (
<FilterChipLink
key={item}
label={sortLabels[item]}
href={shortDramaListHref(item, 1, list.themeSlug)}
active={item === list.sort}
/>
))}
<FilterChipLink label="重置筛选" href={resetHref} />
</div>
</div>
</div>
{list.items.length ? (
<div className="grid grid-cols-[repeat(6,266px)] justify-between gap-y-6">
{list.items.map((item) => (
<ShortDramaCard key={item.id} item={item} />
))}
</div>
) : (
<EmptyState
title="暂无短剧"
description="当前筛选条件下没有可展示的短剧,换个题材或排序试试吧"
className="rounded-3xl px-[110px] py-24"
/>
)}
</div>
{/* 分页放在 design-frame 外,避免 Chrome CSS zoom 热区错位导致点击无响应 */}
{list.items.length && list.totalPages > 1 ? (
<div className="flex justify-center px-6 py-6">
<Pagination page={list.page} total={list.totalPages} hrefMap={hrefMap} />
</div>
) : null}
<div className="design-frame flex flex-col gap-6 pb-[43px]">
<AdBannersSlot variant="pc" />
<DownloadBanner />
<FriendLinksSlot />
</div>
</main>
<FloatingActions />
</>
);
}