广告修改

This commit is contained in:
谢宇宁
2026-10-28 17:07:32 +07:00
parent d5d28c92ce
commit 654257a2d2
45 changed files with 363 additions and 576 deletions
@@ -48,7 +48,7 @@ import {
import type { PlayletThemeItem } from "@/lib/api/types";
import { buildServerForwardHeaders } from "@/lib/api/client-headers";
import { primeRequestForwardHeaders } from "@/lib/api/request-context";
import { AdBannersSlot, AdIconsSlot } from "@/components/common/ads/slots";
import { AdBannersSlot, AdSquaresSlot } from "@/components/common/ads/slots";
import {
buildPlayletDetailJSONLD,
buildPlayletPlayJSONLD,
@@ -387,9 +387,9 @@ async function ShortDramaRouteContent({ params }: { params: Params }) {
<DramaDetailPage
detail={mapped.detail}
related={mapped.related}
adIcons={<AdIconsSlot variant="pc" />}
adIcons={<AdSquaresSlot slot="AList" variant="pc" />}
promo={<AdBannersSlot variant="pc" />}
adGrid={<AdIconsSlot variant="h5" />}
adGrid={<AdSquaresSlot slot="AList" variant="h5" />}
bannerAds={<AdBannersSlot variant="h5" />}
/>
</>
@@ -428,8 +428,10 @@ async function ShortDramaRouteContent({ params }: { params: Params }) {
episode={play.episode}
src={play.src}
related={play.related}
adIcons={<AdIconsSlot variant="pc" />}
promo={<AdBannersSlot variant="pc" />}
topAds={<AdSquaresSlot slot="AList" variant="pc" />}
h5Ads={<AdSquaresSlot slot="AList" variant="h5" />}
h5Banners={<AdBannersSlot variant="h5" />}
/>
</>
);
+3 -3
View File
@@ -48,7 +48,7 @@ import {
resolvePageMetadata,
} from "@/lib/seo";
import { videoHref } from "@/components/video/video-card";
import { AdBannersSlot, AdIconsSlot } from "@/components/common/ads/slots";
import { AdBannersSlot, AdSquaresSlot } from "@/components/common/ads/slots";
type Params = Promise<{ sort: string; page?: string[] }>;
@@ -307,9 +307,9 @@ async function VideoRouteContent({ params }: { params: Params }) {
detail={mapped.detail}
src={mapped.src}
related={mapped.related}
adIcons={<AdIconsSlot variant="pc" labels={false} />}
promo={<AdBannersSlot variant="pc" />}
adGrid={<AdIconsSlot variant="h5" />}
topAds={<AdSquaresSlot slot="AList" variant="pc" />}
h5Ads={<AdSquaresSlot slot="AList" variant="h5" />}
bannerAds={<AdBannersSlot variant="h5" />}
/>
</>
+123 -18
View File
@@ -5,14 +5,21 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore
import { getAgeGateOpen, subscribeAgeGate } from "@/components/age-gate/age-gate-dialog";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { useChromeless } from "@/components/layout/route-visibility";
import { lockBodyScroll } from "@/lib/browser/scroll-lock";
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
/** 对齐 jha-1043 dialogAd.js:签名队列 + session 进度 / 关闭态 */
const QUEUE_VERSION = "2";
const DISMISSED_KEY = "dialogAdDismissed";
const PROGRESS_KEY = "dialogAdProgress";
/** 九宫格弹窗关闭态(按广告内容签名,内容变化后重新弹出) */
const GRID_DISMISSED_KEY = "gridAdDismissed";
let queueIdle = true;
/**
* 启动弹窗链是否已结束(弹窗广告 + 九宫格都处理完)。
* 初始为未结束:等弹窗组件判断完再放行,避免最新地址弹窗抢先和九宫格同时弹出。
*/
let queueIdle = false;
const listeners = new Set<() => void>();
function emit() {
@@ -101,12 +108,27 @@ function rememberProgress(signature: string, index: number) {
writeSession(PROGRESS_KEY, JSON.stringify({ signature, index }));
}
function pickGridAds(advitise?: AdvitiseData | null) {
return (advitise?.nineGridList ?? []).filter((item): item is AdvertiseInfo & { images: string } =>
Boolean(item.images?.trim()),
);
}
function buildGridSignature(ads: AdvertiseInfo[]) {
return ads.length ? JSON.stringify(ads.map((item) => [item.id, item.images, item.link, item.title])) : "";
}
function pickDialogAds(items?: AdvertiseInfo[] | null, advitise?: AdvitiseData | null) {
const source = items ?? advitise?.dialogList ?? [];
return source.filter((item): item is AdvertiseInfo & { images: string } => Boolean(item.images?.trim()));
}
/** 弹窗广告:使用 advitise.dialogList,全站队列展示(对齐 jha-1043) */
/**
* 启动弹窗链(对齐 huangguocg.com):年龄确认 → 弹窗广告 → 九宫格弹窗 → (首页)最新地址。
* 弹窗广告:advitise.dialogList(后台位置 1「弹窗」,素材 600×800),依次展示;
* 九宫格:advitise.nineGridList(后台位置 2「9宫格」,小方块),弹窗广告结束后弹出一次,
* 点任一应用或关闭即不再弹(同一会话、内容不变时)。
*/
export function AdDialogHost({
items,
advitise,
@@ -119,22 +141,41 @@ export function AdDialogHost({
const ads = useMemo(() => pickDialogAds(items, advitise), [items, advitise]);
const signature = useMemo(() => buildSignature(ads), [ads]);
const gridAds = useMemo(() => pickGridAds(advitise), [advitise]);
const gridSignature = useMemo(() => buildGridSignature(gridAds), [gridAds]);
const [index, setIndex] = useState(-1);
const [gridOpen, setGridOpen] = useState(false);
const hasDisplayedRef = useRef(false);
const enabled = !chromeless && !ageGateOpen && ads.length > 0;
const ready = !chromeless && !ageGateOpen;
const enabled = ready && ads.length > 0;
/** 弹窗广告结束(或没有)后:未关闭过九宫格则弹出,否则整条链结束 */
const startGrid = useCallback(() => {
if (gridAds.length && readSession(GRID_DISMISSED_KEY) !== gridSignature) {
setGridOpen(true);
setQueueIdle(false);
return;
}
setGridOpen(false);
setQueueIdle(true);
}, [gridAds.length, gridSignature]);
const dismissGrid = useCallback(() => {
writeSession(GRID_DISMISSED_KEY, gridSignature);
setGridOpen(false);
setQueueIdle(true);
}, [gridSignature]);
useEffect(() => {
let cancelled = false;
void (async () => {
await Promise.resolve();
if (cancelled) return;
if (!enabled) {
if (!ads.length) setQueueIdle(true);
return;
}
if (isAdQueueDone(signature)) {
if (!ready) return;
if (!enabled || isAdQueueDone(signature)) {
setIndex(-1);
setQueueIdle(true);
startGrid();
return;
}
const start = readProgress(signature);
@@ -142,7 +183,7 @@ export function AdDialogHost({
if (hasDisplayedRef.current) writeSession(DISMISSED_KEY, signature);
removeSession(PROGRESS_KEY);
setIndex(-1);
setQueueIdle(true);
startGrid();
return;
}
setIndex(start);
@@ -151,15 +192,15 @@ export function AdDialogHost({
return () => {
cancelled = true;
};
}, [enabled, signature, ads.length]);
}, [ready, enabled, signature, ads.length, startGrid]);
const finishQueue = useCallback(() => {
if (hasDisplayedRef.current) writeSession(DISMISSED_KEY, signature);
else removeSession(DISMISSED_KEY);
removeSession(PROGRESS_KEY);
setIndex(-1);
setQueueIdle(true);
}, [signature]);
startGrid();
}, [signature, startGrid]);
const showNext = useCallback(() => {
setIndex((current) => {
@@ -184,29 +225,43 @@ export function AdDialogHost({
setQueueIdle(false);
}, [index, ads.length, signature]);
useEffect(() => {
if (!gridOpen || index >= 0) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") dismissGrid();
};
const unlockScroll = lockBodyScroll();
window.addEventListener("keydown", onKeyDown);
return () => {
unlockScroll();
window.removeEventListener("keydown", onKeyDown);
};
}, [gridOpen, index, dismissGrid]);
useEffect(() => {
if (index < 0) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") showNext();
};
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
const unlockScroll = lockBodyScroll();
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = overflow;
unlockScroll();
window.removeEventListener("keydown", onKeyDown);
};
}, [index, showNext]);
const ad = index >= 0 && index < ads.length ? ads[index] : null;
if (!ad?.images) return null;
if (!ad?.images) {
return gridOpen ? <NineGridDialog ads={gridAds} onClose={dismissGrid} /> : null;
}
const picture = (
<DecryptedImage
src={ad.images}
alt={ad.title || "广告"}
priority
className="h-[468px] w-[300px] rounded-3xl object-cover lg:h-[602px] lg:w-[400px] lg:rounded-[40px]"
className="h-[400px] w-[300px] rounded-3xl object-cover lg:h-[560px] lg:w-[420px] lg:rounded-[32px]"
onError={skipBroken}
/>
);
@@ -251,3 +306,53 @@ export function AdDialogHost({
</div>
);
}
/** 九宫格弹窗:4 列应用图标宫格 + 底部关闭按钮;点任一应用或空白处即关闭 */
function NineGridDialog({ ads, onClose }: { ads: Array<AdvertiseInfo & { images: string }>; onClose: () => void }) {
return (
<div
data-component="nine-grid-dialog"
className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/85 px-6 py-10 backdrop-blur-md"
onClick={(event) => event.target === event.currentTarget && onClose()}
>
<div
role="dialog"
aria-modal="true"
aria-label="推荐应用"
className="flex w-full max-w-[360px] flex-col items-center gap-6 lg:max-w-[520px]"
onClick={(event) => event.target === event.currentTarget && onClose()}
>
<ul className="grid max-h-[70vh] w-full grid-cols-4 gap-x-3 gap-y-5 overflow-y-auto overscroll-contain lg:gap-x-6 lg:gap-y-7">
{ads.map((item, i) => (
<li key={item.id || `${item.images}-${i}`} className="min-w-0">
<a
href={item.link || undefined}
target="_blank"
rel="sponsored nofollow noopener noreferrer"
title={item.title || undefined}
onClick={onClose}
className="flex flex-col items-center gap-2"
>
<DecryptedImage
src={item.images}
alt={item.title || "推荐应用"}
priority
className="aspect-square w-full max-w-[70px] rounded-2xl object-cover lg:max-w-20"
/>
<span className="max-w-full truncate text-[13px] leading-[18px] text-white lg:text-[15px] lg:leading-[22px]">
{item.title || "推荐应用"}
</span>
</a>
</li>
))}
</ul>
<button type="button" aria-label="关闭" onClick={onClose} className="size-9 cursor-pointer">
<svg viewBox="0 0 36 36" className="size-full" aria-hidden="true">
<circle cx="18" cy="18" r="16" fill="none" stroke="white" strokeWidth="2" />
<path d="M12.5 12.5l11 11M23.5 12.5l-11 11" stroke="white" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
</div>
);
}
+3 -3
View File
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from
import { getAdOpen, hasAdShown, subscribeAd } from "@/components/ad/ad-dialog";
import { getAgeGateOpen, subscribeAgeGate } from "@/components/age-gate/age-gate-dialog";
import { useChromeless } from "@/components/layout/route-visibility";
import { lockBodyScroll } from "@/lib/browser/scroll-lock";
import type { AnnouncementInfo, ConfigInfo } from "@/lib/api/types";
import { OFFICIAL_TG_URL, SITE_DOMAIN } from "@/lib/seo/metadata";
@@ -95,10 +96,9 @@ export function AddressDialogHost({
useEffect(() => {
if (!open) return;
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
const unlockScroll = lockBodyScroll();
return () => {
document.body.style.overflow = overflow;
unlockScroll();
};
}, [open]);
+3 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useSyncExternalStore } from "react";
import { lockBodyScroll } from "@/lib/browser/scroll-lock";
import { useChromeless } from "@/components/layout/route-visibility";
/** 对齐 jha-1043 eightPop.js:sessionStorage + 关闭/退出均离开站点 */
@@ -65,10 +66,9 @@ export function AgeGateDialogHost() {
useEffect(() => {
if (!open) return;
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
const unlockScroll = lockBodyScroll();
return () => {
document.body.style.overflow = overflow;
unlockScroll();
};
}, [open]);
+3 -3
View File
@@ -3,6 +3,7 @@
import Link from "next/link";
import { useCallback, useEffect, useId, useState, useSyncExternalStore, type FormEvent, type ReactNode } from "react";
import { lockBodyScroll } from "@/lib/browser/scroll-lock";
import { useMockUser } from "@/components/auth/use-mock-user";
import {
ACCOUNT_RULE_HINT,
@@ -96,11 +97,10 @@ export function LoginDialogHost() {
useEffect(() => {
if (!mode) return;
const onKeyDown = (event: KeyboardEvent) => event.key === "Escape" && close();
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
const unlockScroll = lockBodyScroll();
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = overflow;
unlockScroll();
window.removeEventListener("keydown", onKeyDown);
};
}, [mode, close]);
+19 -8
View File
@@ -16,7 +16,10 @@ type AdIconMarqueeProps = {
contentKey?: string;
};
/** Icon 广告从右向左无缝自动滚动(内容复制两段,translateX -50%) */
/**
* Icon 广告从右向左无缝自动滚动(内容复制两段,translateX -50%)。
* 一行放得下时不复制、不滚动,避免广告少时出现重复图标。
*/
export function AdIconMarquee({
children,
className = "",
@@ -24,42 +27,50 @@ export function AdIconMarquee({
spacerClassName = "w-3",
contentKey = "",
}: AdIconMarqueeProps) {
const containerRef = useRef<HTMLDivElement>(null);
const segmentRef = useRef<HTMLDivElement>(null);
const [durationSec, setDurationSec] = useState(20);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const container = containerRef.current;
const el = segmentRef.current;
if (!el) return;
if (!container || !el) return;
const update = () => {
const width = el.getBoundingClientRect().width;
if (width <= 0) return;
setOverflowing(width > container.clientWidth + 1);
setDurationSec(Math.max(width / SCROLL_PX_PER_SEC, 8));
};
update();
const observer = new ResizeObserver(update);
observer.observe(container);
observer.observe(el);
return () => observer.disconnect();
}, [contentKey]);
return (
<div
ref={containerRef}
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` }}
className={`flex w-max items-start ${overflowing ? "ad-icon-marquee-track" : ""}`}
style={overflowing ? { animationDuration: `${durationSec}s` } : undefined}
>
<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>
{overflowing ? (
<div className={`flex shrink-0 items-start ${gapClassName}`} aria-hidden>
{children}
<span className={`${spacerClassName} shrink-0`} aria-hidden />
</div>
) : null}
</div>
</div>
);
-18
View File
@@ -1,18 +0,0 @@
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} />;
}
+3 -3
View File
@@ -11,7 +11,7 @@ type AdBannersProps = {
className?: string;
};
/** 公共 Banner 广告:对接 advitise.detailbannerList;无数据不渲染 */
/** 公共 Banner 广告:对接 advitise.detailbannerList(后台位置 3「详情横幅」,素材 720×150);无数据不渲染 */
export function AdBanners({
variant = "pc",
items,
@@ -38,7 +38,7 @@ export function AdBanners({
href={href}
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className="block h-10 overflow-hidden rounded-xl"
className="block aspect-[720/150] overflow-hidden rounded-xl"
>
<DecryptedImage
src={ad.images || null}
@@ -68,7 +68,7 @@ export function AdBanners({
href={href}
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className="block h-[97px] overflow-hidden rounded-xl"
className="block aspect-[720/150] overflow-hidden rounded-xl"
>
<DecryptedImage
src={ad.images || null}
-70
View File
@@ -1,70 +0,0 @@
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, 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}
>
<DecryptedImage
src={ad.images || null}
fallback={IMG_PLACEHOLDER}
alt={ad.title || ""}
className={imgClass}
/>
{showLabels ? <span className={titleClass}>{ad.title || "广告"}</span> : null}
</a>
);
})}
</AdIconMarquee>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { AdIconMarquee } from "@/components/common/ad-icon-marquee";
import { DecryptedImage } from "@/components/common/decrypted-image";
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { adContentKey, adHref, pickSquareAds, type SquareAdSlot } from "./pick";
type AdSquaresProps = {
/** BList:首页小图标;AList:播放页小图标 */
slot: SquareAdSlot;
variant?: "pc" | "h5";
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
className?: string;
};
/**
* 小方块广告(方形素材),无数据不渲染;一行放不下时自动滚动。
* 首页用 BList(位置 13「首页小图标」),播放页用 AList(位置 12「播放页小图标」)。
*/
export function AdSquares({ slot, variant = "pc", items, advitise, className = "" }: AdSquaresProps) {
const isH5 = variant === "h5";
const list = pickSquareAds(slot, items, advitise);
if (!list.length) return null;
return (
<section
data-component="ad-squares"
aria-label="推广"
className={`flex flex-col ${isH5 ? "gap-1.5" : "gap-2.5"} ${className}`}
>
<p className={`text-[#6F6C7B] ${isH5 ? "text-[11px] leading-4" : "text-xs leading-[18px]"}`}>推广</p>
<AdIconMarquee
gapClassName={isH5 ? "gap-2.5" : "gap-4"}
spacerClassName={isH5 ? "w-2.5" : "w-4"}
contentKey={adContentKey(list)}
>
{list.map((ad, index) => {
const href = adHref(ad.link, isH5 ? "#h5-download" : "#download");
const external = Boolean(ad.link?.trim());
return (
<a
key={ad.id || `square-${index}`}
href={href}
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
title={ad.title || undefined}
className={`group flex shrink-0 flex-col items-center gap-1.5 ${isH5 ? "w-[72px]" : "w-[120px]"}`}
>
<DecryptedImage
src={ad.images || null}
fallback={IMG_PLACEHOLDER}
alt={ad.title || "推广"}
className={`aspect-square w-full rounded-lg object-cover outline outline-1 -outline-offset-1 outline-white/10 transition-[outline-color] group-hover:outline-[#FFB020]/80 ${
isH5 ? "" : "rounded-xl"
}`}
/>
<span className={`max-w-full truncate text-[#D8D6DF] ${isH5 ? "text-[11px] leading-4" : "text-sm leading-5"}`}>
{ad.title || "推广"}
</span>
</a>
);
})}
</AdIconMarquee>
</section>
);
}
+2 -2
View File
@@ -1,4 +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";
export { AdSquares } from "./ad-squares";
export { pickBannerAds, pickSquareAds } from "./pick";
+9 -15
View File
@@ -1,18 +1,18 @@
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
export function hasAdVisual(item: AdvertiseInfo) {
return Boolean(item.images?.trim() || item.title?.trim());
}
/**
* 小方块广告(后台「站点」广告位):
* BList = 位置 13「首页小图标」,AList = 位置 12「播放页小图标」
*/
export type SquareAdSlot = "BList" | "AList";
/** Icon:统一对接 advitise.nineGridList(可显式传 items 覆盖) */
export function pickIconAds(
export function pickSquareAds(
slot: SquareAdSlot,
items?: AdvertiseInfo[] | null,
advitise?: AdvitiseData | null,
_variant: "pc" | "h5" = "pc",
) {
void _variant;
const source = items ?? advitise?.nineGridList ?? [];
return source.filter(hasAdVisual);
const source = items ?? advitise?.[slot] ?? [];
return source.filter((item) => Boolean(item.images?.trim()));
}
/** Banner:detailbannerList(需有图) */
@@ -21,12 +21,6 @@ export function pickBannerAds(items?: AdvertiseInfo[] | null, advitise?: Advitis
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;
+7 -19
View File
@@ -1,21 +1,21 @@
import { getAdvitiseCached } from "@/lib/api/home";
import { AdBanners } from "./ad-banners";
import { AdIcons } from "./ad-icons";
import { FriendLinks, H5FriendLinks } from "../friend-links";
import { AdSquares } from "./ad-squares";
import type { SquareAdSlot } from "./pick";
/** 服务端:Icon 广告(home/base → advitise) */
export async function AdIconsSlot({
/** 服务端:小方块广告(BList 首页小图标 / AList 播放页小图标) */
export async function AdSquaresSlot({
slot,
variant = "pc",
labels = true,
className = "",
}: {
slot: SquareAdSlot;
variant?: "pc" | "h5";
labels?: boolean;
className?: string;
}) {
const advitise = await getAdvitiseCached();
return <AdIcons variant={variant} advitise={advitise} labels={labels} className={className} />;
return <AdSquares slot={slot} variant={variant} advitise={advitise} className={className} />;
}
/** 服务端:Banner 广告 */
@@ -29,15 +29,3 @@ export async function AdBannersSlot({
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} />;
}
@@ -1 +0,0 @@
export { FriendLinksSlot, H5FriendLinksSlot } from "./ads/slots";
-75
View File
@@ -1,75 +0,0 @@
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>
);
}
@@ -1,6 +0,0 @@
import { AdIconsSlot } from "./ads/slots";
/** @deprecated 使用 AdIconsSlot */
export async function H5AdGridSlot({ className = "" }: { className?: string }) {
return <AdIconsSlot variant="h5" className={className} />;
}
-16
View File
@@ -1,16 +0,0 @@
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} />;
}
@@ -1,6 +0,0 @@
import { AdBannersSlot } from "./ads/slots";
/** @deprecated 使用 AdBannersSlot */
export async function H5BannerAdsSlot({ className = "" }: { className?: string }) {
return <AdBannersSlot variant="h5" className={className} />;
}
-16
View File
@@ -1,16 +0,0 @@
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} />;
}
+3 -3
View File
@@ -1,5 +1,6 @@
"use client";
import { lockBodyScroll } from "@/lib/browser/scroll-lock";
import { QRCodeSVG } from "qrcode.react";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type MouseEvent } from "react";
@@ -132,11 +133,10 @@ export function DownloadDialogHost({ appAddr }: { appAddr?: string | null }) {
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => event.key === "Escape" && close();
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
const unlockScroll = lockBodyScroll();
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = overflow;
unlockScroll();
window.removeEventListener("keydown", onKeyDown);
};
}, [open, close]);
@@ -4,7 +4,7 @@ import { toast } from "@heroui/react";
import { useEffect, useState, type ReactNode } from "react";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { AdBanners, AdIcons } from "@/components/common/ads";
import { AdBanners } from "@/components/common/ads";
import { BackButton } from "@/components/common/back-button";
import { pickPlayletCardTags } from "@/components/common/card-tags";
import { DecryptedImage } from "@/components/common/decrypted-image";
@@ -70,9 +70,9 @@ export function DramaDetailPage({
related?: RelatedDrama[];
/** 服务端渲染的 PC banner */
promo?: ReactNode;
/** 服务端渲染的 PC icon */
/** 服务端渲染的播放页小图标(AList):PC */
adIcons?: ReactNode;
/** 服务端渲染的 H5 icon */
/** 服务端渲染的播放页小图标(AList):H5 */
adGrid?: ReactNode;
/** 服务端渲染的 H5 banner */
bannerAds?: ReactNode;
@@ -254,7 +254,7 @@ export function DramaDetailPage({
</figure>
</article>
{adIcons ?? <AdIcons variant="pc" />}
{adIcons}
<EpisodeDirectory detail={detail} variant="panel" />
@@ -298,7 +298,7 @@ export function DramaDetailPage({
) : null}
</article>
{adGrid ?? <AdIcons variant="h5" />}
{adGrid}
<EpisodeDirectory detail={detail} variant="h5" />
+13 -4
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import { useEffect, useState, type ReactNode } from "react";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { AdBanners, AdIcons } from "@/components/common/ads";
import { AdBanners } from "@/components/common/ads";
import { pickPlayletCardTags } from "@/components/common/card-tags";
import {
dramaDetailHref,
@@ -67,15 +67,22 @@ export function DramaPlayPage({
episode,
src,
related = [],
adIcons,
promo,
topAds,
h5Ads,
h5Banners,
}: {
detail: DramaDetail;
episode: number;
src: string;
related?: RelatedDrama[];
adIcons?: ReactNode;
promo?: ReactNode;
/** 服务端渲染的播放页小图标(AList):PC 顶部 */
topAds?: ReactNode;
/** 服务端渲染的播放页小图标(AList):H5 正文下方 */
h5Ads?: ReactNode;
/** 服务端渲染的详情横幅(detailbannerList):H5 */
h5Banners?: ReactNode;
}) {
const [liked, setLiked] = useState(Boolean(detail.isLike));
const [displayCountLike, setDisplayCountLike] = useState(() => initialDisplayCountLike(detail));
@@ -229,6 +236,7 @@ export function DramaPlayPage({
className={`relative hidden flex-col gap-8 pb-14 lg:flex ${expanded ? "" : `pt-6 ${pcFrame}`}`}
>
{!expanded ? <PageBreadcrumb items={crumbs} /> : null}
{!expanded ? topAds : null}
<div className={expanded ? "contents" : "flex flex-col gap-8"}>
<div className={expanded ? "contents" : "flex h-[648px] gap-5"}>
@@ -294,7 +302,6 @@ export function DramaPlayPage({
{expanded ? <EpisodeDirectory detail={detail} current={episode} variant="panel" /> : null}
{adIcons ?? <AdIcons variant="pc" />}
<DramaRecommend items={related} />
@@ -317,6 +324,8 @@ export function DramaPlayPage({
onLike={toggleLike}
onCollect={toggleCollect}
onShare={share}
ads={h5Ads}
bannerAds={h5Banners}
/>
<div className="max-lg:hidden">
+10
View File
@@ -64,6 +64,10 @@ type H5PlayProps = {
onCollect: () => void;
onShare: () => void;
onPlaySuccess?: () => void;
/** 播放页小图标广告(AList) */
ads?: ReactNode;
/** 详情横幅广告(detailbannerList) */
bannerAds?: ReactNode;
};
function LockedNotice() {
@@ -152,6 +156,8 @@ export function H5Play({
onCollect,
onShare,
onPlaySuccess,
ads,
bannerAds,
}: H5PlayProps) {
const router = useRouter();
const playerRef = useRef<XgPlayerInstance | null>(null);
@@ -316,9 +322,13 @@ export function H5Play({
</nav>
</article>
{ads}
<EpisodeDirectory detail={detail} current={episode} variant="h5" />
<DramaRecommend items={related} compact />
{bannerAds}
</div>
<BottomSheet open={sheet === "speed"} onClose={closeSheet} label="倍速" className="gap-3 px-4 py-[18px]">
-14
View File
@@ -1,6 +1,5 @@
import Link from "next/link";
import { AdBanners } from "@/components/common/ads/ad-banners";
import { CardTags } from "@/components/common/card-tags";
import {
MEDIA_CARD_COVER,
@@ -12,7 +11,6 @@ import {
MEDIA_CARD_TITLE_PC_MD,
} from "@/components/common/media-card-hover";
import { dramaHref } from "@/components/short-drama/data";
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import type { Drama } from "./data";
import { Badge } from "./section-heading";
@@ -80,15 +78,3 @@ export function ThemeBlock({ title, subtitle, cover }: { title: string; subtitle
</article>
);
}
/** @deprecated 使用 AdBanners;covers 保留兼容,有接口数据时优先接口 */
export function PromoRow({
items,
advitise,
}: {
covers?: readonly string[];
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
}) {
return <AdBanners variant="pc" items={items} advitise={advitise} />;
}
-14
View File
@@ -1,14 +0,0 @@
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { AdIcons } from "@/components/common/ads/ad-icons";
/** @deprecated 使用 AdIcons;保留别名兼容首页旧引用 */
export function HomeAdRow({
items,
advitise,
}: {
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
}) {
return <AdIcons variant="pc" items={items} advitise={advitise} />;
}
+2 -6
View File
@@ -1,5 +1,4 @@
import { AdBanners, AdIcons } from "@/components/common/ads";
import { H5FriendLinks } from "@/components/common/friend-links";
import { AdSquares } from "@/components/common/ads";
import type { HomePlayletResponse } from "@/lib/api/types";
import { DownloadBannerH5 } from "./page-sections";
@@ -16,13 +15,10 @@ export function HomePageH5({ home }: { home?: HomePlayletResponse | null }) {
<NoticeMarquee items={home?.announcements} variant="h5" />
<AdIcons variant="h5" advitise={home?.advitise} />
<AdBanners variant="h5" advitise={home?.advitise} />
<AdSquares slot="BList" variant="h5" advitise={home?.advitise} />
{hasModules ? <HomeModules modules={home?.modules} variant="h5" /> : null}
<H5FriendLinks advitise={home?.advitise} />
<DownloadBannerH5 />
</main>
);
+2 -4
View File
@@ -1,5 +1,4 @@
import { AdIcons } from "@/components/common/ads";
import { FriendLinks } from "@/components/common/friend-links";
import { AdSquares } from "@/components/common/ads";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { HomePlayletResponse } from "@/lib/api/types";
@@ -31,12 +30,11 @@ export function HomePage({
<main data-component="home-page" className="design-frame hidden flex-col gap-8 pt-8 pb-10 lg:flex">
<HeroBanner banners={home?.homeBannerData} />
<NoticeMarquee items={home?.announcements} />
<AdIcons variant="pc" advitise={home?.advitise} />
<AdSquares slot="BList" variant="pc" advitise={home?.advitise} />
{hasModules ? <HomeModules modules={home?.modules} /> : null}
<DownloadBanner />
<FriendLinks advitise={home?.advitise} />
</main>
<FloatingActions />
</>
+3 -3
View File
@@ -6,6 +6,7 @@ import { useEffect, useState, type ReactNode } from "react";
import { openAppDownload, useAppDownloadHref } from "@/components/download/download-dialog";
import { footerNavItems } from "@/components/layout/footer-nav-items";
import { lockBodyScroll } from "@/lib/browser/scroll-lock";
import type { AppTranslationInfo, ConfigInfo, TranslationCategory, TranslationInfo } from "@/lib/api/types";
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL } from "@/lib/seo/metadata";
@@ -77,11 +78,10 @@ export function MobileMenu({
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
const unlockScroll = lockBodyScroll();
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = overflow;
unlockScroll();
window.removeEventListener("keydown", onKeyDown);
};
}, [open, onClose]);
-7
View File
@@ -1,4 +1,3 @@
import { AdBannersSlot, AdIconsSlot, FriendLinksSlot, H5FriendLinksSlot } from "@/components/common/ads/slots";
import type { ReactNode } from "react";
import { ChannelHeader, H5ChannelHeader } from "@/components/common/channel-header";
@@ -68,7 +67,6 @@ export function MorePage({ data }: { data: MorePageData }) {
<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 />
@@ -80,14 +78,11 @@ export function MorePage({ data }: { data: MorePageData }) {
{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} />
@@ -106,9 +101,7 @@ export function MorePage({ data }: { data: MorePageData }) {
</div>
) : null}
<div className="design-frame flex flex-col gap-6 pb-[43px]">
<AdBannersSlot variant="pc" />
<DownloadBanner />
<FriendLinksSlot />
</div>
</main>
<FloatingActions />
+2 -12
View File
@@ -40,18 +40,8 @@ export function formatHeat(count?: number | null) {
}
export function mapContentListItem(item: PlayletContentListItem, dataType: 1 | 2): ShortDramaItem | null {
if (item.type === 2) {
const ad = item.advertise;
if (!ad) return null;
return {
kind: "ad",
id: `ad-${ad.id || ad.link || "unknown"}`,
title: (ad.title || "").trim() || "广告",
subtitle: "",
cover: (ad.images || "").trim(),
href: (ad.link || "").trim() || undefined,
};
}
// 信息流广告(type=2)不展示:网页只保留后台 5 个站点广告位
if (item.type === 2) return null;
if (dataType === 1) {
const playlet = item.playlet;
-7
View File
@@ -1,4 +1,3 @@
import { AdBannersSlot, AdIconsSlot, FriendLinksSlot, H5FriendLinksSlot } from "@/components/common/ads/slots";
import type { ReactNode } from "react";
import { ChannelHeader, H5ChannelHeader } from "@/components/common/channel-header";
@@ -66,7 +65,6 @@ export function RankingPage({ tab, list }: { tab: RankingTab; list: RankingListR
/>
<main data-component="ranking-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" />
<section id="h5-list" className="flex scroll-mt-16 flex-col gap-[18px]">
<RankingTabs tabs={rankingTabs} tab={tab} />
@@ -94,14 +92,11 @@ export function RankingPage({ tab, list }: { tab: RankingTab; list: RankingListR
)}
</section>
<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" />
<div id="pc-list" className="scroll-mt-24 flex flex-col gap-6">
<RankingTabs tabs={rankingTabs} tab={tab} />
@@ -126,9 +121,7 @@ export function RankingPage({ tab, list }: { tab: RankingTab; list: RankingListR
</div>
) : null}
<div className="design-frame flex flex-col gap-6 pb-[43px]">
<AdBannersSlot variant="pc" />
<DownloadBanner />
<FriendLinksSlot />
</div>
</main>
<FloatingActions />
-9
View File
@@ -1,6 +1,5 @@
import Form from "next/form";
import { AdBannersSlot, AdIconsSlot } from "@/components/common/ads/slots";
import { EmptyState } from "@/components/common/empty-state";
import { TabLink } from "@/components/common/filter-link";
import { Pagination } from "@/components/common/pagination";
@@ -138,14 +137,6 @@ export function SearchPage({ list }: { list: SearchListResult }) {
/>
)}
<div className="hidden lg:block">
<AdIconsSlot variant="pc" />
<AdBannersSlot variant="pc" />
</div>
<div className="flex flex-col gap-[18px] lg:hidden">
<AdIconsSlot variant="h5" />
<AdBannersSlot variant="h5" />
</div>
</div>
<FloatingActions />
</main>
+19 -64
View File
@@ -24,32 +24,23 @@ 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;
/** 发布时间(接口原值,视频卡展示日期用) */
date?: string;
/** 覆盖默认详情/播放链接(如历史继续看到某集) */
href?: string;
}
| {
kind: "ad";
id: string;
title: string;
subtitle: string;
cover: string;
href?: string;
};
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;
/** 发布时间(接口原值,视频卡展示日期用) */
date?: string;
/** 覆盖默认详情/播放链接(如历史继续看到某集) */
href?: string;
};
export const PAGE_SIZE = 24;
export const TOTAL_PAGES = 11;
@@ -59,16 +50,8 @@ export function shortDramaSortType(sort: Sort): 1 | 2 {
}
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,
};
}
// 信息流广告(type=2)不展示:网页只保留后台 5 个站点广告位
if (item.type === 2) return null;
if (!item.id) return null;
const tags = pickPlayletCardTags(item);
@@ -124,34 +107,6 @@ export function buildShortDramaList(
};
}
/** @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) {
@@ -16,7 +16,7 @@ function RankLabel({ rank, className = "" }: { rank?: number; className?: string
}
/** 短剧卡元信息:集数 + 热度(文字行,不叠在封面上) */
function DramaMeta({ item, className }: { item: Extract<ShortDramaItem, { kind: "drama" }>; className: string }) {
function DramaMeta({ item, className }: { item: ShortDramaItem; className: string }) {
return (
<p className={`flex min-w-0 items-center gap-2 text-[#8E8C99] ${className}`}>
{item.episodes ? <span className="shrink-0 text-[#D9C59A]">共{item.episodes}</span> : null}
@@ -31,27 +31,7 @@ function DramaMeta({ item, className }: { item: Extract<ShortDramaItem, { kind:
);
}
function AdDramaCard({ item, compact }: { item: ShortDramaItem; compact?: boolean }) {
return (
<a
href={item.href || (compact ? "#h5-download" : "#download")}
target={item.href ? "_blank" : undefined}
rel={item.href ? "sponsored nofollow noopener noreferrer" : undefined}
className={`group flex flex-col ${compact ? "gap-1.5" : "w-[266px] gap-2.5"}`}
>
<span className={`${HL_COVER} aspect-[2/3]`}>
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt={item.title} className={HL_COVER_IMG} />
<span className="absolute top-0 left-0 rounded-br-md bg-white/85 px-1.5 py-0.5 text-[10px] leading-[14px] text-black">
推广
</span>
</span>
<span className={`truncate text-white ${compact ? "text-sm leading-5" : "media-card-title-md"}`}>{item.title}</span>
</a>
);
}
export function ShortDramaCard({ item, rank }: { item: ShortDramaItem; rank?: number }) {
if (item.kind === "ad") return <AdDramaCard item={item} />;
const href = item.href || dramaHref(item.id, item.slug);
return (
@@ -84,7 +64,6 @@ export function H5ShortDramaCard({
/** @deprecated 新卡片不在封面显示集数,保留参数兼容调用方 */
showEpisodes?: boolean;
}) {
if (item.kind === "ad") return <AdDramaCard item={item} compact />;
const href = item.href || dramaHref(item.id, item.slug);
return (
@@ -1,4 +1,3 @@
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";
@@ -25,7 +24,6 @@ export function ShortDramaPage({ list }: { list: ShortDramaListResult }) {
<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"
@@ -89,14 +87,11 @@ export function ShortDramaPage({ list }: { list: ShortDramaListResult }) {
)}
</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"
@@ -155,9 +150,7 @@ export function ShortDramaPage({ list }: { list: ShortDramaListResult }) {
</div>
) : null}
<div className="design-frame flex flex-col gap-6 pb-[43px]">
<AdBannersSlot variant="pc" />
<DownloadBanner />
<FriendLinksSlot />
</div>
</main>
<FloatingActions />
-9
View File
@@ -1,6 +1,5 @@
import Link from "next/link";
import { AdBannersSlot, AdIconsSlot } from "@/components/common/ads/slots";
import { EmptyState } from "@/components/common/empty-state";
import { Pagination } from "@/components/common/pagination";
import { buildPageHrefMap } from "@/components/common/pagination-map";
@@ -101,14 +100,6 @@ export function TagPage({ data }: { data: TagPageData }) {
<Pagination page={page} total={totalPages} hrefMap={hrefMap} className="max-lg:[&_form]:hidden" />
) : null}
<div className="hidden space-y-5 lg:block">
<AdIconsSlot variant="pc" />
<AdBannersSlot variant="pc" />
</div>
<div className="flex flex-col gap-[18px] lg:hidden">
<AdIconsSlot variant="h5" />
<AdBannersSlot variant="h5" />
</div>
</div>
<FloatingActions />
</main>
-7
View File
@@ -1,5 +1,4 @@
import { AdBannersSlot, AdIconsSlot, FriendLinksSlot, H5FriendLinksSlot } from "@/components/common/ads/slots";
import { ChannelHeader, H5ChannelHeader } from "@/components/common/channel-header";
import { FilterChipLink, FilterOptionLink } from "@/components/common/filter-link";
import { Pagination } from "@/components/common/pagination";
@@ -165,7 +164,6 @@ export function TopicPage({ data }: { data: TopicPageData }) {
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 />
@@ -181,14 +179,11 @@ export function TopicPage({ data }: { data: TopicPageData }) {
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} />
@@ -204,9 +199,7 @@ export function TopicPage({ data }: { data: TopicPageData }) {
<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 />
+5 -4
View File
@@ -2,7 +2,7 @@
import type { ReactNode } from "react";
import { AdBanners, AdIcons } from "@/components/common/ads";
import { AdBanners } from "@/components/common/ads";
import { BackButton } from "@/components/common/back-button";
import { HlAction, InfoList, SectionTitle, TagLinks, VideoRecommend, type InfoRow, type TagLinkItem } from "@/components/media-page/parts";
import { XgPlayer } from "@/components/player/xg-player";
@@ -30,8 +30,8 @@ export function H5VideoPlay({
infoRows,
tags,
onPlaySuccess,
adGrid,
bannerAds,
squareAds,
liked,
likeNum,
collected,
@@ -46,8 +46,9 @@ export function H5VideoPlay({
infoRows: InfoRow[];
tags: TagLinkItem[];
onPlaySuccess?: () => void;
adGrid?: ReactNode;
bannerAds?: ReactNode;
/** 播放页小图标广告(AList) */
squareAds?: ReactNode;
}) {
const title = detail.title || "视频";
const desc = detail.desc?.trim() || "";
@@ -91,7 +92,7 @@ export function H5VideoPlay({
) : null}
</article>
{adGrid ?? <AdIcons variant="h5" />}
{squareAds}
<VideoRecommend items={related} compact />
@@ -4,7 +4,7 @@ import { toast } from "@heroui/react";
import { useEffect, useState, type ReactNode } from "react";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { AdBanners, AdIcons } from "@/components/common/ads";
import { AdBanners } from "@/components/common/ads";
import { formatDay, formatDuration } from "@/components/common/media-meta";
import { PcPlayer } from "@/components/drama-play/pc-player";
import { FloatingActions } from "@/components/home/floating-actions";
@@ -56,16 +56,18 @@ export function VideoPlayPage({
src,
related = [],
promo,
adIcons,
adGrid,
topAds,
h5Ads,
bannerAds,
}: {
detail: VideoDetail;
src: string;
related?: ShortDramaItem[];
promo?: ReactNode;
adIcons?: ReactNode;
adGrid?: ReactNode;
/** 服务端渲染的播放页小图标(AList):PC 顶部 */
topAds?: ReactNode;
/** 服务端渲染的播放页小图标(AList):H5 正文下方 */
h5Ads?: ReactNode;
bannerAds?: ReactNode;
}) {
const [liked, setLiked] = useState(Boolean(detail.isLike));
@@ -226,6 +228,7 @@ export function VideoPlayPage({
className="relative mx-auto hidden w-[1700px] flex-col gap-8 pt-6 pb-14 [zoom:var(--page-scale,1)] lg:flex"
>
<PageBreadcrumb items={crumbs} />
{topAds}
<PcPlayer
src={src}
@@ -263,7 +266,6 @@ export function VideoPlayPage({
) : null}
</article>
{adIcons ?? <AdIcons variant="pc" labels={false} />}
<VideoRecommend items={related} />
@@ -283,8 +285,8 @@ export function VideoPlayPage({
onLike={liking ? () => undefined : toggleLike}
onCollect={collecting ? () => undefined : toggleCollect}
onShare={share}
adGrid={adGrid}
bannerAds={bannerAds}
squareAds={h5Ads}
/>
<div className="max-lg:hidden">
+3 -37
View File
@@ -1,5 +1,5 @@
import { pickVideoCardTags } from "@/components/common/card-tags";
import { genres, regions, sorts, type Genre, type Region, type ShortDramaItem, type Sort } from "@/components/short-drama/data";
import { sorts, type ShortDramaItem, type Sort } from "@/components/short-drama/data";
import { formatHeat } from "@/components/ranking/data";
import type { VideoCategoryListItem, VideoListItem, VideoListResp } from "@/lib/api/types";
@@ -13,16 +13,8 @@ export function videoSortType(sort: VideoSort): 1 | 2 {
}
export function mapVideoListItem(item: VideoListItem): 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.coverImg || "").trim(),
href: (item.link || "").trim() || undefined,
};
}
// 信息流广告(type=2)不展示:网页只保留后台 5 个站点广告位
if (item.type === 2) return null;
if (!item.id) return null;
const slug = (item.slug || "").trim();
@@ -77,29 +69,3 @@ export function buildVideoList(
};
}
/** @deprecated 本地 mock,供「更多」等未接接口页面临时使用 */
const adSlots = new Set([5, 11, 17, 24, 25]);
export function getVideos(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: VIDEO_PAGE_SIZE }, (_, index): ShortDramaItem => {
const id = `video-${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: "original",
cover,
};
});
}
+1 -22
View File
@@ -26,7 +26,7 @@ function RankLabel({ rank, className = "" }: { rank?: number; className?: string
}
/** 视频卡元信息:观看数 + 发布日期 */
function VideoMeta({ item, className }: { item: Extract<ShortDramaItem, { kind: "drama" }>; className: string }) {
function VideoMeta({ item, className }: { item: ShortDramaItem; className: string }) {
const day = formatDay(item.date);
return (
<p className={`flex min-w-0 items-center gap-2 text-[#8E8C99] ${className}`}>
@@ -42,27 +42,7 @@ function VideoMeta({ item, className }: { item: Extract<ShortDramaItem, { kind:
);
}
function AdVideoCard({ item, compact }: { item: ShortDramaItem; compact?: boolean }) {
return (
<a
href={item.href || (compact ? "#h5-download" : "#download")}
target={item.href ? "_blank" : undefined}
rel={item.href ? "sponsored nofollow noopener noreferrer" : undefined}
className={`group flex flex-col ${compact ? "gap-1.5" : "w-[324px] gap-2.5"}`}
>
<span className={`${HL_COVER} aspect-[16/10]`}>
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt={item.title} className={HL_COVER_IMG} />
<span className="absolute top-0 left-0 rounded-br-md bg-white/85 px-1.5 py-0.5 text-[10px] leading-[14px] text-black">
推广
</span>
</span>
<span className={`line-clamp-2 text-white ${compact ? "text-sm leading-5" : "media-card-title-md"}`}>{item.title}</span>
</a>
);
}
export function VideoCard({ item, rank }: { item: ShortDramaItem; rank?: number }) {
if (item.kind === "ad") return <AdVideoCard item={item} />;
const href = item.href || videoHref(item.id, item.slug);
return (
@@ -92,7 +72,6 @@ export function H5VideoCard({
/** @deprecated 新卡片不在封面显示集数,保留参数兼容调用方 */
showEpisodes?: boolean;
}) {
if (item.kind === "ad") return <AdVideoCard item={item} compact />;
const href = item.href || videoHref(item.id, item.slug);
return (
-7
View File
@@ -1,4 +1,3 @@
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";
@@ -27,7 +26,6 @@ export function VideoPage({ list }: { list: VideoListResult }) {
<SeoBreadcrumb items={[{ name: "首页", href: "/" }, { name: "视频" }]} />
<main data-component="video-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"
@@ -91,14 +89,11 @@ export function VideoPage({ list }: { list: VideoListResult }) {
)}
</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"
@@ -157,9 +152,7 @@ export function VideoPage({ list }: { list: VideoListResult }) {
</div>
) : null}
<div className="design-frame flex flex-col gap-6 pb-[43px]">
<AdBannersSlot variant="pc" />
<DownloadBanner />
<FriendLinksSlot />
</div>
</main>
<FloatingActions />
+13 -13
View File
@@ -625,21 +625,21 @@ export type AnnouncementItem = {
};
/** GET /api/app/home/base → AdvitiseData(广告位列表) */
/**
* home/base → advitise:网页只使用后台「站点」的 5 个广告位
* (接口还会返回 App 用的其它广告位,网页不读取)。
*/
export type AdvitiseData = {
homeList?: AdvertiseInfo[];
detailTopList?: AdvertiseInfo[];
nineGridList?: AdvertiseInfo[];
detailbannerList?: AdvertiseInfo[];
launchScreenList?: AdvertiseInfo[];
floatingList?: AdvertiseInfo[];
AList?: AdvertiseInfo[];
BList?: AdvertiseInfo[];
/** 位置 1「弹窗」,素材 600×800 */
dialogList?: AdvertiseInfo[];
postList?: AdvertiseInfo[];
mustAppList?: AdvertiseInfo[];
titleAppList?: AdvertiseInfo[];
friendList?: AdvertiseInfo[];
videoList?: AdvertiseInfo[];
/** 位置 2「9宫格」,小方块 */
nineGridList?: AdvertiseInfo[];
/** 位置 3「详情横幅」,素材 720×150 */
detailbannerList?: AdvertiseInfo[];
/** 位置 12「播放页小图标」,小方块 */
AList?: AdvertiseInfo[];
/** 位置 13「首页小图标」,小方块 */
BList?: AdvertiseInfo[];
};
/** GET /api/app/home/base → AnnouncementInfo(回家的路 / 公告地址) */
+24
View File
@@ -0,0 +1,24 @@
/**
* 全站弹窗共用的页面滚动锁(引用计数)。
* 各弹窗不再各自记录 / 恢复 body.overflow:多个弹窗同时打开、以任意顺序关闭时,
* 只有最后一个关闭才恢复滚动,避免「后关的弹窗把 hidden 当成原值恢复」导致全站无法滚动。
*/
let lockCount = 0;
let previousOverflow = "";
export function lockBodyScroll(): () => void {
if (typeof document === "undefined") return () => undefined;
if (lockCount === 0) {
previousOverflow = document.body.style.overflow === "hidden" ? "" : document.body.style.overflow;
document.body.style.overflow = "hidden";
}
lockCount += 1;
let released = false;
return () => {
if (released) return;
released = true;
lockCount = Math.max(0, lockCount - 1);
if (lockCount === 0) document.body.style.overflow = previousOverflow;
};
}