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
+396
View File
@@ -0,0 +1,396 @@
import Link from "next/link";
import {
CardTags,
cardTagCanLink,
pickPlayletCardTags,
pickVideoCardTags,
} from "@/components/common/card-tags";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
MEDIA_CARD_DESC_MD,
MEDIA_CARD_EPISODES,
MEDIA_CARD_META_MD,
MEDIA_CARD_TITLE,
MEDIA_CARD_TITLE_PC,
} from "@/components/common/media-card-hover";
import { formatHeat } from "@/components/ranking/data";
import { morePageHref } from "@/components/more/href";
import { dramaHref } from "@/components/short-drama/data";
import { tagPageHref } from "@/components/tag/href";
import { videoHref } from "@/components/video/video-card";
import type { HomeModuleData, PlayletData, VideoInfo } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { H5SectionHeading } from "./h5-cards";
import { isVideoDataType, unwrapHomeModuleList, videoCoverSrc } from "./home-module-style";
function heatLabel(count?: number | null) {
// formatHeat → "95.6万 热度";样式 E 文案为「热度 95.6万」
return formatHeat(count).replace(/^(.+)\s热度$/, "热度 $1");
}
/** H5 样式 E:热度只显示数值(如 95.6万) */
function heatValueLabel(count?: number | null) {
return formatHeat(count).replace(/\s*热度$/, "");
}
const H5_E_RANK_BADGES = [
"/images/rank_num1.png",
"/images/rank_num2.png",
"/images/rank_num3.png",
] as const;
const H5_E_RANK_BADGE_FALLBACK = "/images/rank_num4.png";
function H5StyleERankBadge({
rank,
className = "absolute top-0 left-0 z-[1] h-5 w-[22px]",
}: {
rank: number;
className?: string;
}) {
const bg = H5_E_RANK_BADGES[rank - 1] ?? H5_E_RANK_BADGE_FALLBACK;
return (
<span
className={`flex items-center justify-center bg-left-top bg-no-repeat text-sm leading-none font-semibold text-white tabular-nums ${className}`}
style={{ backgroundImage: `url(${bg})`, backgroundSize: "100% 100%" }}
>
{rank}
</span>
);
}
function WatchButton({ href }: { href: string }) {
return (
<Link
href={href}
className="inline-flex h-10 w-[120px] items-center justify-center gap-1 rounded-lg border border-[rgba(241,43,158,0.1)] bg-[rgba(241,43,158,0.1)] text-xs leading-[18px] text-white transition-opacity hover:opacity-90"
>
立即观看
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
<circle cx="8" cy="8" r="6.15" fill="none" stroke="currentColor" strokeWidth="1" />
<path d="M7 5.5L10.5 8 7 10.5V5.5Z" fill="currentColor" />
</svg>
</Link>
);
}
/** 样式 E · 视频:竖排大卡(400 × 499,封面横版在上) */
function StyleEVideoCard({ item }: { item: VideoInfo }) {
const href = videoHref(String(item.id), item.slug);
const tags = pickVideoCardTags(item);
const heat = heatLabel(item.watchNum);
return (
<article className="group relative h-[499px] w-[400px] shrink-0 rounded-2xl bg-[#121729] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]">
<Link href={href} className="absolute top-[19px] left-[18px] block h-[230px] w-[360px] overflow-hidden rounded-t-2xl">
<DecryptedImage
src={videoCoverSrc(item)}
fallback={IMG_PLACEHOLDER}
alt={item.title || ""}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-105"
/>
</Link>
<div className="absolute top-[267px] left-5 flex w-[358px] flex-col gap-3">
<Link href={href} className={`${MEDIA_CARD_TITLE_PC} font-semibold`}>
{item.title || "未命名"}
</Link>
<p className={`line-clamp-3 h-[53px] text-[#A3A3B8] ${MEDIA_CARD_DESC_MD}`}>&nbsp;</p>
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-4">
<CardTags tags={tags} kind="video" className={`min-w-0 text-[#F68804] ${MEDIA_CARD_META_MD}`} />
<span className={`shrink-0 text-[#EF2385] ${MEDIA_CARD_META_MD}`}>{heat}</span>
</div>
<WatchButton href={href} />
</div>
</div>
</article>
);
}
/** 样式 E · 短剧:横排大卡(553 × 401,竖封面在左) */
function StyleEPlayletCard({ item }: { item: PlayletData }) {
const href = dramaHref(item.id, item.slug);
const tags = pickPlayletCardTags(item);
const heat = heatLabel(item.heatValue ?? item.countBrowse);
const episodes = item.totalEpisode ? `${item.totalEpisode}集` : "";
return (
<article className="group relative h-[401px] w-[553px] shrink-0 rounded-2xl bg-[#121729] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]">
<Link href={href} className="absolute top-6 left-6 block h-[353px] w-[247px] overflow-hidden rounded-2xl">
<DecryptedImage
src={item.cover || null}
fallback={IMG_PLACEHOLDER}
alt={item.title || ""}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-105"
/>
{episodes ? (
<span className={`absolute right-2 bottom-2 rounded-lg bg-black/60 px-2 py-[5px] font-medium text-white ${MEDIA_CARD_EPISODES}`}>
{episodes}
</span>
) : null}
</Link>
<div className="absolute top-[46px] left-[294px] flex h-[320px] w-[228px] flex-col gap-4 overflow-hidden">
<Link
href={href}
className={`media-card-title shrink-0 line-clamp-2 font-semibold text-[#F2F2FA] ${MEDIA_CARD_TITLE}`}
>
{item.title || "未命名"}
</Link>
<p className={`line-clamp-3 text-[#A3A3B8] ${MEDIA_CARD_DESC_MD}`}>{item.desc || "\u00A0"}</p>
<div className="mt-auto flex flex-col gap-5">
<div className="flex min-w-0 items-center gap-x-4">
<CardTags tags={tags} kind="playlet" className={`min-w-0 text-[#F68804] ${MEDIA_CARD_META_MD}`} />
<span className={`shrink-0 text-[#EF2385] ${MEDIA_CARD_META_MD}`}>{heat}</span>
</div>
<WatchButton href={href} />
</div>
</div>
</article>
);
}
function chunkRows<T>(items: T[], size: number): T[][] {
const rows: T[][] = [];
for (let i = 0; i < items.length; i += size) {
rows.push(items.slice(i, i + size));
}
return rows;
}
/** 样式 E:视频竖排 4×2 / 短剧横排 3×2 */
export function HomeModuleStyleE({ module }: { module: HomeModuleData }) {
const { videos, playlets } = unwrapHomeModuleList(module.list, module.dataType);
const isVideo = isVideoDataType(module.dataType) || (videos.length > 0 && playlets.length === 0);
if (isVideo) {
const rows = chunkRows(videos.slice(0, 8), 4);
return (
<div data-component="home-module-style-e-video" className="flex flex-col gap-8">
{rows.map((row, rowIndex) => (
<div key={rowIndex} className="flex items-start gap-8">
{row.map((item, i) => (
<StyleEVideoCard key={item.id} item={item} />
))}
</div>
))}
</div>
);
}
const rows = chunkRows(playlets.slice(0, 6), 3);
return (
<div data-component="home-module-style-e-playlet" className="flex flex-col gap-5">
{rows.map((row, rowIndex) => (
<div key={rowIndex} className="flex items-start gap-5">
{row.map((item) => (
<StyleEPlayletCard key={item.id} item={item} />
))}
</div>
))}
</div>
);
}
/** H5 · 短剧样式 E 横卡(封面左 + 信息右,带排行) */
function H5StyleEPlayletCard({ item, rank }: { item: PlayletData; rank: number }) {
const href = dramaHref(item.id, item.slug);
// 封面旁极窄,只展示 1 个标签,避免「激…|测试…」
const tags = pickPlayletCardTags(item).slice(0, 1);
const themeLabel = (item.themeNames?.[0] || item.tagNames?.[0] || "").trim();
const heat = heatValueLabel(item.heatValue ?? item.countBrowse);
const episodes = item.totalEpisode ? `${item.totalEpisode}集` : "";
const showCoins = Number(item.payType) !== 0;
return (
<article
data-component="h5-home-module-style-e-playlet-card"
className="group flex h-[180px] min-w-0 flex-1 gap-2 rounded-lg bg-[#121729] px-2 py-4 transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]"
>
<div className="flex w-[54px] shrink-0 flex-col gap-[18px]">
<Link href={href} className="relative block h-[82px] w-[54px] overflow-hidden rounded">
<DecryptedImage
src={item.cover || null}
fallback={IMG_PLACEHOLDER}
alt={item.title || ""}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-105"
/>
<H5StyleERankBadge rank={rank} />
{showCoins ? (
<span className="absolute top-0.5 right-0 z-[1] flex h-3.5 w-4 items-center justify-center rounded bg-[linear-gradient(139deg,#FFC41C_0%,#FF7226_100%)]">
<span className="text-[8px] font-bold leading-none text-white">¥</span>
</span>
) : null}
{episodes ? (
<span className="absolute right-0 bottom-0 rounded-tl bg-black/60 px-1.5 py-0.5 text-[10px] leading-4 text-white">
{episodes}
</span>
) : null}
</Link>
<div className="flex w-full flex-col gap-0.5 overflow-hidden">
{tags.length ? (
<p className="truncate text-[10px] leading-4 text-[#F68804]">
{tags.map((tag, i) => (
<span key={tag.slug ?? tag.id ?? tag.name}>
{i > 0 ? "|" : null}
{cardTagCanLink(tag) ? (
<Link href={tagPageHref(tag, "playlet")} className="hover:opacity-80">
{tag.name}
</Link>
) : (
tag.name
)}
</span>
))}
</p>
) : (
<p className="text-[10px] leading-4 text-[#F68804]">&nbsp;</p>
)}
<p className="flex items-center gap-0.5 text-xs leading-[18px] text-[#EF2385]">
<img src="/images/hot.svg" alt="" className="h-3 w-[9px] shrink-0" />
<span className="truncate">{heat}</span>
</p>
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-2 overflow-hidden">
{themeLabel ? (
<span className="inline-flex w-fit max-w-full truncate rounded bg-black/40 px-1 text-[10px] leading-[18px] text-[#F68804]">
{themeLabel}
</span>
) : null}
<Link href={href} className="line-clamp-2 text-sm leading-[22px] font-semibold text-[#F2F2FA]">
{item.title || "未命名"}
</Link>
<p className="line-clamp-2 text-[10px] leading-[18px] text-[#A3A3B8]">{item.desc || "\u00A0"}</p>
<Link
href={href}
className="inline-flex h-[30px] w-[65px] items-center justify-center rounded-lg border border-[rgba(241,43,158,0.1)] bg-[rgba(241,43,158,0.1)] text-xs leading-[18px] text-white"
>
立即观看
</Link>
</div>
</article>
);
}
/** H5 · 短剧样式 E:标题 + 三行两卡热榜 */
export function HomeModuleStyleEH5Playlet({ module }: { module: HomeModuleData }) {
const moreHref = morePageHref(module);
const title = (module.title || module.label || "推荐").trim();
const { playlets } = unwrapHomeModuleList(module.list, module.dataType);
const rows = chunkRows(playlets.slice(0, 6), 2);
if (!rows.length) return null;
return (
<div data-component="home-module-style-e-h5-playlet" className="flex w-full flex-col items-start gap-2 overflow-hidden">
<H5SectionHeading title={title} moreHref={moreHref} />
{rows.map((row, rowIndex) => (
<div
key={rowIndex}
className="flex w-full items-stretch gap-1 overflow-hidden rounded-2xl bg-[#10101A]"
>
{row.map((item, i) => (
<H5StyleEPlayletCard key={item.id} item={item} rank={rowIndex * 2 + i + 1} />
))}
</div>
))}
</div>
);
}
/** H5 · 视频样式 E 竖卡(封面在上,信息在下,带排行) */
function H5StyleEVideoCard({ item, rank }: { item: VideoInfo; rank: number }) {
const href = videoHref(String(item.id), item.slug);
const tags = pickVideoCardTags(item).slice(0, 1);
const themeLabel = (item.tagsName?.[0] || "").trim();
const heat = heatValueLabel(item.watchNum);
return (
<article
data-component="h5-home-module-style-e-video-card"
className="group flex h-[264px] min-w-0 flex-1 flex-col gap-2 rounded-lg bg-[#121729] px-2 py-4 transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]"
>
<Link href={href} className="relative block aspect-[152/83] w-full overflow-hidden rounded-lg">
<DecryptedImage
src={videoCoverSrc(item)}
fallback={IMG_PLACEHOLDER}
alt={item.title || ""}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-105"
/>
<H5StyleERankBadge rank={rank} className="absolute top-0 left-0 z-[1] h-[26px] w-7" />
</Link>
<div className="flex min-h-0 flex-1 flex-col justify-center gap-2 overflow-hidden">
{themeLabel ? (
<span className="inline-flex w-fit max-w-full truncate rounded bg-black/40 px-1 text-[10px] leading-[18px] text-[#F68804]">
{themeLabel}
</span>
) : null}
<Link href={href} className="truncate text-base leading-[22px] font-semibold text-[#F2F2FA]">
{item.title || "未命名"}
</Link>
<p className="line-clamp-2 text-xs leading-[18px] text-[#A3A3B8]">&nbsp;</p>
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 flex-col gap-0.5 overflow-hidden">
{tags.length ? (
<p className="truncate text-xs leading-[18px] text-[#F68804]">
{tags.map((tag, i) => (
<span key={tag.slug ?? tag.id ?? tag.name}>
{i > 0 ? "|" : null}
{cardTagCanLink(tag) ? (
<Link href={tagPageHref(tag, "video")} className="hover:opacity-80">
{tag.name}
</Link>
) : (
tag.name
)}
</span>
))}
</p>
) : (
<p className="text-xs leading-[18px] text-[#F68804]">&nbsp;</p>
)}
<p className="flex items-center gap-0.5 text-xs leading-[18px] text-[#EF2385]">
<img src="/images/hot.svg" alt="" className="h-3 w-[9px] shrink-0" />
<span className="truncate">{heat}</span>
</p>
</div>
<Link
href={href}
className="inline-flex h-8 w-[65px] shrink-0 items-center justify-center rounded-lg border border-[rgba(241,43,158,0.1)] bg-[rgba(241,43,158,0.1)] text-xs leading-[18px] text-white"
>
立即观看
</Link>
</div>
</div>
</article>
);
}
/** H5 · 视频样式 E:标题 + 三行两卡热榜竖版 */
export function HomeModuleStyleEH5Video({ module }: { module: HomeModuleData }) {
const moreHref = morePageHref(module);
const title = (module.title || module.label || "推荐").trim();
const { videos } = unwrapHomeModuleList(module.list, module.dataType);
const rows = chunkRows(videos.slice(0, 6), 2);
if (!rows.length) return null;
return (
<div data-component="home-module-style-e-h5-video" className="flex w-full flex-col items-start gap-2 overflow-hidden">
<H5SectionHeading title={title} moreHref={moreHref} />
{rows.map((row, rowIndex) => (
<div
key={rowIndex}
className="flex w-full items-stretch gap-1 overflow-hidden rounded-2xl bg-[#10101A]"
>
{row.map((item, i) => (
<H5StyleEVideoCard key={item.id} item={item} rank={rowIndex * 2 + i + 1} />
))}
</div>
))}
</div>
);
}