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
+61
View File
@@ -0,0 +1,61 @@
import { InfoPageLayout } from "@/components/info/info-page-layout";
const slogan = "每一刻,皆入戏。";
const sections: { title: string; paragraphs: string[]; slogan?: boolean }[] = [
{
title: "品牌介绍",
paragraphs: [
"AI爽剧网是面向短剧爱好者打造的在线内容平台,聚合丰富题材与优质作品,为用户提供清晰、便捷且沉浸的观看体验。",
"我们关注每一次真实的情绪共鸣,也重视创作者与观众之间的连接,让好故事更快被看见。",
],
},
{
title: "产品愿景",
paragraphs: ["以简洁可靠的产品体验连接内容、创作者与观众,持续优化搜索、追剧、收藏、观看记录和多端同步等核心能力。"],
slogan: true,
},
{
title: "联系我们",
paragraphs: [
"商务合作:business@qimengshorts.com",
"用户反馈:support@qimengshorts.com",
"服务时间:工作日 10:00–18:00",
],
},
];
export function AboutPage() {
return (
<InfoPageLayout data-component="about-page"
title="关于我们"
subtitle="了解AI爽剧网的品牌、产品与服务"
updatedAt="最后更新:2026年7月23日"
sections={sections.map((section) => ({
title: section.title,
content:
section.title === "联系我们" ? (
<p className="whitespace-pre-line">{section.paragraphs.join("\n")}</p>
) : (
<>
{section.paragraphs.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
{section.slogan ? <p>品牌主张:{slogan}</p> : null}
</>
),
}))}
mobile={sections.map((section) => (
<section key={section.title} className="flex flex-col gap-2 rounded-2xl bg-[#13111C] px-4 py-3.5">
<h2 className="text-base leading-[22px] font-bold text-[#FAF7FF]">{section.title}</h2>
{section.paragraphs.map((paragraph) => (
<p key={paragraph} className="text-xs leading-[18px] text-[#9E94AD]">
{paragraph}
</p>
))}
{section.slogan ? <p className="text-xs leading-[18px] font-bold text-[#F12B9E]">{slogan}</p> : null}
</section>
))}
/>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { connection } from "next/server";
import { getAdvitiseCached } from "@/lib/api/home";
import { AdDialogHost } from "./ad-dialog";
/** 服务端插槽:从 /api/app/home/base 拉取 advitise.dialogList(Suspense + connection) */
export async function AdDialogSlot() {
await connection();
const advitise = await getAdvitiseCached();
return <AdDialogHost advitise={advitise} />;
}
+305
View File
@@ -0,0 +1,305 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { getAgeGateOpen, subscribeAgeGate } from "@/components/age-gate/age-gate-dialog";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { adTrackProps } from "@/components/common/ads/pick";
import { useChromeless } from "@/components/layout/route-visibility";
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";
let queueIdle = true;
const listeners = new Set<() => void>();
function emit() {
listeners.forEach((listener) => listener());
}
function setQueueIdle(idle: boolean) {
if (queueIdle === idle) return;
queueIdle = idle;
emit();
}
export function subscribeAd(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** 当前是否正在展示广告弹窗 */
export const getAdOpen = () => !queueIdle;
/**
* 广告队列是否已结束(已全部关闭 / 无广告 / 已 dismiss)
* 供最新地址弹窗排序:年龄确认 → 广告队列 → 地址弹窗
*/
export function isAdQueueDone(signature: string) {
if (!signature) return true;
try {
return window.sessionStorage.getItem(DISMISSED_KEY) === signature;
} catch {
return true;
}
}
/** @deprecated 兼容旧调用:等价于队列空闲 */
export const hasAdShown = () => queueIdle;
const getAgeGateServerSnapshot = () => true;
function readSession(key: string) {
try {
return window.sessionStorage.getItem(key) || "";
} catch {
return "";
}
}
function writeSession(key: string, value: string) {
try {
window.sessionStorage.setItem(key, value);
} catch {
// sessionStorage 不可用时仍允许本页依次展示
}
}
function removeSession(key: string) {
try {
window.sessionStorage.removeItem(key);
} catch {}
}
function buildSignature(ads: AdvertiseInfo[]) {
return (
QUEUE_VERSION +
"|" +
ads
.map((item, index) => item.id || item.images || String(index))
.join("|")
);
}
function readProgress(signature: string) {
try {
const raw = readSession(PROGRESS_KEY);
if (!raw) return 0;
const progress = JSON.parse(raw) as { signature?: string; index?: number };
if (!progress || progress.signature !== signature) return 0;
const value = Number(progress.index);
if (!Number.isFinite(value) || value < 0) return 0;
return Math.floor(value);
} catch {
return 0;
}
}
function rememberProgress(signature: string, index: number) {
writeSession(PROGRESS_KEY, JSON.stringify({ signature, index }));
}
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) */
export function AdDialogHost({
items,
advitise,
}: {
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
}) {
const chromeless = useChromeless();
const ageGateOpen = useSyncExternalStore(subscribeAgeGate, getAgeGateOpen, getAgeGateServerSnapshot);
const ads = useMemo(() => pickDialogAds(items, advitise), [items, advitise]);
const signature = useMemo(() => buildSignature(ads), [ads]);
const [index, setIndex] = useState(-1);
const hasDisplayedRef = useRef(false);
const enabled = !chromeless && !ageGateOpen && ads.length > 0;
useEffect(() => {
let cancelled = false;
void (async () => {
await Promise.resolve();
if (cancelled) return;
if (!enabled) {
if (!ads.length) setQueueIdle(true);
return;
}
if (isAdQueueDone(signature)) {
setIndex(-1);
setQueueIdle(true);
return;
}
const start = readProgress(signature);
if (start >= ads.length) {
if (hasDisplayedRef.current) writeSession(DISMISSED_KEY, signature);
removeSession(PROGRESS_KEY);
setIndex(-1);
setQueueIdle(true);
return;
}
setIndex(start);
setQueueIdle(false);
})();
return () => {
cancelled = true;
};
}, [enabled, signature, ads.length]);
const finishQueue = useCallback(() => {
if (hasDisplayedRef.current) writeSession(DISMISSED_KEY, signature);
else removeSession(DISMISSED_KEY);
removeSession(PROGRESS_KEY);
setIndex(-1);
setQueueIdle(true);
}, [signature]);
const showNext = useCallback(() => {
setIndex((current) => {
const next = current + 1;
if (next >= ads.length) {
finishQueue();
return -1;
}
rememberProgress(signature, next);
return next;
});
}, [ads.length, finishQueue, signature]);
const skipBroken = useCallback(() => {
showNext();
}, [showNext]);
useEffect(() => {
if (index < 0 || index >= ads.length) return;
hasDisplayedRef.current = true;
rememberProgress(signature, index);
setQueueIdle(false);
}, [index, ads.length, signature]);
useEffect(() => {
if (index < 0) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") showNext();
};
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = overflow;
window.removeEventListener("keydown", onKeyDown);
};
}, [index, showNext]);
const ad = index >= 0 && index < ads.length ? ads[index] : null;
const adId = ad ? String(ad.advertiseCode || ad.id || "") : "";
const trackAttrs = ad
? adTrackProps(ad, {
slotKey: "dialog_ad",
slotName: "弹窗广告位",
adType: "interstitial",
})
: {};
useEffect(() => {
if (!ad?.images || !adId) return;
document.dispatchEvent(
new CustomEvent("dialog-ad:track", {
detail: {
eventType: "show",
element: Object.assign(document.createElement("div"), {
dataset: {
advertisingKey: "dialog_ad",
advertisingName: "弹窗广告",
advertisingId: adId,
dialogAdId: adId,
},
}),
},
}),
);
}, [ad, adId]);
if (!ad?.images) return 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]"
onError={skipBroken}
/>
);
return (
<div
data-component="ad-dialog"
className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto bg-black/70 px-4 py-10"
onClick={(event) => event.target === event.currentTarget && showNext()}
>
<div
role="dialog"
aria-modal="true"
aria-label={ad.title || "广告"}
className="my-auto flex flex-col items-center gap-9 lg:gap-6"
onClick={(event) => event.target === event.currentTarget && showNext()}
data-advertising-key="dialog_ad"
data-advertising-name="弹窗广告"
data-advertising-id={adId}
data-dialog-ad-id={adId}
>
{ad.link ? (
<a
href={ad.link}
target="_blank"
rel="noopener noreferrer sponsored"
className="block"
{...trackAttrs}
onClick={() => {
document.dispatchEvent(
new CustomEvent("dialog-ad:track", {
detail: {
eventType: "click",
element: Object.assign(document.createElement("div"), {
dataset: {
advertisingKey: "dialog_ad",
advertisingName: "弹窗广告",
advertisingId: adId,
dialogAdId: adId,
},
}),
},
}),
);
}}
>
{picture}
</a>
) : (
picture
)}
<button
type="button"
aria-label="关闭广告"
data-dialog-ad-close=""
onClick={showNext}
className="size-9 cursor-pointer"
>
<svg viewBox="0 0 36 36" className="size-full" aria-hidden="true">
<circle cx="18" cy="18" r="14" fill="none" stroke="white" strokeWidth="2" />
<path d="M13 13l10 10M23 13L13 23" stroke="white" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
</div>
);
}
@@ -0,0 +1,22 @@
import { connection } from "next/server";
import { getHomeBaseCached } from "@/lib/api/home";
import { AddressDialogHost } from "./address-dialog";
/** 服务端插槽:从 /api/app/home/base 拉取最新地址弹窗数据(Suspense + connection) */
export async function AddressDialogSlot() {
await connection();
let announcement: Awaited<ReturnType<typeof getHomeBaseCached>>["announcement"] | undefined;
let config: Awaited<ReturnType<typeof getHomeBaseCached>>["config"] | undefined;
try {
const base = await getHomeBaseCached();
announcement = base.announcement;
config = base.config;
} catch {
// 接口失败时仍渲染默认地址弹窗
}
return <AddressDialogHost announcement={announcement} config={config} />;
}
+374
View File
@@ -0,0 +1,374 @@
"use client";
import { usePathname } from "next/navigation";
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
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 type { AnnouncementInfo, ConfigInfo } from "@/lib/api/types";
/** 对齐 FOYA latestAddrPop(1041/1038):倒计时结束后才允许关闭,默认 3 秒 */
const CLOSE_DELAY_SECONDS = 3;
const SESSION_KEY = "hjsq-address-shown";
const ACCENT = "#FF2D55";
export type AddressDialogInfo = {
permanent: string;
latest: string;
telegram: string;
email: string;
mirrors: string[];
publishPage: string;
};
/** 从 home/base 的 announcement / config 组装地址弹窗文案(不改字段名映射,只做展示拼装) */
export function buildAddressDialogInfo(
announcement?: AnnouncementInfo | null,
config?: ConfigInfo | null,
): AddressDialogInfo | null {
const permanent = (announcement?.foreverUrlAddr || config?.foreverUrlAddr || "").trim();
const latest = (announcement?.newUrlAddr || config?.newUrlAddr || "").trim();
const telegram = (config?.tgGroup || config?.telegramAdd || config?.telegram || "").trim();
const email = (config?.gmailAddr || "").trim();
const mirrors = [
announcement?.newUrlAddr1,
announcement?.newUrlAddr2,
config?.newUrlAddr1,
config?.newUrlAddr2,
...(config?.useIngAddress ?? []),
]
.map((item) => item?.trim() || "")
.filter(Boolean)
.filter((item, index, list) => list.indexOf(item) === index);
const publishPage = (
announcement?.translationAddr ||
config?.translationAddr ||
config?.webAddr ||
"/way-home"
).trim();
if (!permanent && !latest && !mirrors.length && !telegram && !email) return null;
return {
permanent: permanent || latest,
latest: latest || permanent,
telegram,
email,
mirrors,
publishPage,
};
}
let dialogOpen = false;
const listeners = new Set<() => void>();
function setDialogOpen(open: boolean) {
dialogOpen = open;
listeners.forEach((listener) => listener());
}
export function openAddressDialog() {
setDialogOpen(true);
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
const getSnapshot = () => dialogOpen;
const getServerSnapshot = () => false;
const getBlockedServerSnapshot = () => true;
function markShown() {
try {
if (window.sessionStorage.getItem(SESSION_KEY)) return false;
window.sessionStorage.setItem(SESSION_KEY, "1");
} catch {}
return true;
}
const toUrl = (domain: string) => (domain.startsWith("http") ? domain : `https://${domain}`);
export function AddressDialogHost({
announcement,
config,
}: {
announcement?: AnnouncementInfo | null;
config?: ConfigInfo | null;
}) {
const info = useMemo(() => buildAddressDialogInfo(announcement, config), [announcement, config]);
const open = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const ageGateOpen = useSyncExternalStore(subscribeAgeGate, getAgeGateOpen, getBlockedServerSnapshot);
const adOpen = useSyncExternalStore(subscribeAd, getAdOpen, getBlockedServerSnapshot);
const chromeless = useChromeless();
const pathname = usePathname();
const close = useCallback(() => setDialogOpen(false), []);
useEffect(() => {
// 排序:年龄确认 → 广告队列结束 → 最新地址(仅首页,chromeless 除外)
if (pathname !== "/" || chromeless || ageGateOpen || adOpen || !hasAdShown() || !info) return;
if (markShown()) openAddressDialog();
}, [pathname, chromeless, ageGateOpen, adOpen, info]);
useEffect(() => {
if (!open) return;
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = overflow;
};
}, [open]);
if (!open || !info) return null;
return <AddressCard info={info} onClose={close} />;
}
function AddressCard({
info,
onClose,
}: {
info: AddressDialogInfo;
onClose: () => void;
}) {
// 对齐 latestAddrPop:倒计时期间锁定关闭,结束后才可关(不自动关掉弹窗)
const [seconds, setSeconds] = useState(CLOSE_DELAY_SECONDS);
const canClose = seconds <= 0;
useEffect(() => {
if (canClose) return;
const timer = window.setInterval(() => {
setSeconds((value) => (value <= 1 ? 0 : value - 1));
}, 1000);
return () => window.clearInterval(timer);
}, [canClose]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && canClose) onClose();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [canClose, onClose]);
const tryClose = () => {
if (!canClose) return;
onClose();
};
const publishHref = info.publishPage.startsWith("/") ? info.publishPage : toUrl(info.publishPage);
return (
<div
data-component="address-dialog"
className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto bg-black/70 px-4 py-10"
onClick={(event) => event.target === event.currentTarget && tryClose()}
data-close-locked={canClose ? undefined : "true"}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="address-dialog-title"
className="my-auto flex w-[300px] flex-col gap-3 rounded-[20px] bg-[#0B0914] px-5 py-6 text-sm/5 text-white/90 outline outline-1 outline-white/20 lg:w-[400px] lg:gap-[18px] lg:rounded-[40px] lg:p-9 lg:text-base/[22px]"
>
<div className="flex items-center justify-between">
<h2 id="address-dialog-title" className="text-base/[22px] font-medium lg:text-xl/7">
{canClose ? "现在可以关闭" : `${seconds}秒后会自动关闭`}
</h2>
<button
type="button"
aria-label="关闭"
disabled={!canClose}
onClick={tryClose}
className={`size-6 text-white ${canClose ? "cursor-pointer" : "cursor-not-allowed opacity-40"}`}
>
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
<button
type="button"
onClick={() => saveAddressImage(info)}
className="cursor-pointer flex items-center gap-1.5 self-end text-sm/5 transition hover:text-white lg:gap-3.5"
>
<svg viewBox="0 0 20 20" className="size-5" fill="none" aria-hidden="true">
<rect x="3.33" y="3.33" width="13.33" height="13.33" rx="2" stroke="white" strokeWidth="1.5" />
<circle cx="12.5" cy="7.5" r="1" fill="white" />
<path d="M3.75 14.5l4-4.25 3 3 1.75-1.75 3.75 3.75" stroke="white" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
保存地址为图片
</button>
<div className="flex flex-col gap-3">
<p>请花几秒钟记住以下信息!</p>
{info.permanent ? <p>永久地址: {info.permanent}</p> : null}
{info.latest ? <p>最新地址: {info.latest}</p> : null}
{info.telegram ? (
<p>
TG交流群:{" "}
<a
href={info.telegram}
target="_blank"
rel="noopener noreferrer"
className="text-[#FF2D55] hover:underline lg:font-medium"
>
点击加入官方TG群!
</a>
</p>
) : null}
{info.email ? (
<>
<p>
官方邮箱:{" "}
<a href={`mailto:${info.email}`} className="hover:underline">
{info.email}
</a>
</p>
<p className="text-sm/5">(发送任意邮件,自动回复最新地址)</p>
</>
) : null}
{info.mirrors.length > 0 ? (
<>
<p>
<span className="text-[#FF2D55]">由于封控原因,域名经常被和谐,</span>请右上角
<span className="text-[#FF2D55]">保存图片</span>以下为最新地址发布页
</p>
<div className="flex flex-wrap gap-1.5 lg:gap-[11px]">
{info.mirrors.map((domain, index) => (
<a
key={`${domain}-${index}`}
href={toUrl(domain)}
target="_blank"
rel="noopener noreferrer"
className="rounded bg-[#EF2385] px-3 py-2 text-xs/[18px] font-medium text-white transition hover:bg-[#F23D94]"
>
{domain}
</a>
))}
</div>
</>
) : null}
</div>
<div className="flex gap-4">
<a
href={publishHref}
target={info.publishPage.startsWith("/") ? undefined : "_blank"}
rel={info.publishPage.startsWith("/") ? undefined : "noopener noreferrer"}
aria-disabled={!canClose}
tabIndex={canClose ? 0 : -1}
onClick={(event) => {
if (!canClose) {
event.preventDefault();
return;
}
tryClose();
}}
className={`flex-1 rounded px-[18px] py-3 text-center text-sm/5 outline outline-1 -outline-offset-1 outline-white/30 transition ${
canClose ? "hover:bg-white/5" : "pointer-events-none opacity-40"
}`}
>
地址发布页
</a>
<button
type="button"
disabled={!canClose}
onClick={tryClose}
className={`flex-1 rounded bg-[#EF2385] px-[18px] py-3 text-sm/5 text-white transition ${
canClose ? "cursor-pointer hover:bg-[#F23D94]" : "cursor-not-allowed opacity-40"
}`}
>
立即关闭
</button>
</div>
</section>
</div>
);
}
type Segment = { text: string; color?: string };
function saveAddressImage(info: AddressDialogInfo) {
const width = 400;
const padding = 36;
const contentWidth = width - padding * 2;
const scale = 2;
const font = '16px "PingFang SC", "Microsoft YaHei", sans-serif';
const lines: Segment[][] = [
[{ text: "请花几秒钟记住以下信息!" }],
...(info.permanent ? [[{ text: `永久地址: ${info.permanent}` }]] : []),
...(info.latest ? [[{ text: `最新地址: ${info.latest}` }]] : []),
...(info.telegram
? [[{ text: "TG交流群: " }, { text: info.telegram, color: ACCENT }]]
: []),
...(info.email ? [[{ text: `官方邮箱: ${info.email}` }]] : []),
...(info.email ? [[{ text: "(发送任意邮件,自动回复最新地址)" }]] : []),
...(info.publishPage
? [[{ text: "地址发布页: " }, { text: info.publishPage, color: ACCENT }]]
: []),
...info.mirrors.map((domain, index) => [{ text: `备用地址${index + 1}: ${domain}` }]),
];
const measure = document.createElement("canvas").getContext("2d");
if (!measure) return;
measure.font = font;
const rows: Segment[][] = [];
for (const line of lines) {
let row: Segment[] = [];
let rowWidth = 0;
for (const segment of line) {
for (const char of segment.text) {
const charWidth = measure.measureText(char).width;
if (rowWidth + charWidth > contentWidth) {
rows.push(row);
row = [];
rowWidth = 0;
}
const last = row[row.length - 1];
if (last && last.color === segment.color) last.text += char;
else row.push({ text: char, color: segment.color });
rowWidth += charWidth;
}
}
rows.push(row);
}
const lineHeight = 22;
const rowGap = 12;
const titleHeight = 28;
const height = padding * 2 + titleHeight + 18 + rows.length * (lineHeight + rowGap) - rowGap;
const canvas = document.createElement("canvas");
canvas.width = width * scale;
canvas.height = height * scale;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.scale(scale, scale);
ctx.fillStyle = "#0B0914";
ctx.fillRect(0, 0, width, height);
ctx.textBaseline = "middle";
ctx.fillStyle = "#FFFFFF";
ctx.font = '500 20px "PingFang SC", "Microsoft YaHei", sans-serif';
ctx.fillText("海角社区 · 最新地址", padding, padding + titleHeight / 2);
ctx.font = font;
let y = padding + titleHeight + 18 + lineHeight / 2;
for (const row of rows) {
let x = padding;
for (const segment of row) {
ctx.fillStyle = segment.color ?? "rgba(255,255,255,0.9)";
ctx.fillText(segment.text, x, y);
x += ctx.measureText(segment.text).width;
}
y += lineHeight + rowGap;
}
const link = document.createElement("a");
link.download = "海角社区地址.png";
link.href = canvas.toDataURL("image/png");
link.click();
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { useEffect, useSyncExternalStore } from "react";
import { useChromeless } from "@/components/layout/route-visibility";
/** 对齐 jha-1043 eightPop.js:sessionStorage + 关闭/退出均离开站点 */
const SESSION_KEY = "ageConfirmed";
const EXIT_URL = "about:blank";
let confirmed: boolean | null = null;
const listeners = new Set<() => void>();
function emit() {
listeners.forEach((listener) => listener());
}
function readConfirmed() {
if (confirmed === null) {
try {
confirmed = window.sessionStorage.getItem(SESSION_KEY) === "1";
} catch {
confirmed = false;
}
}
return confirmed;
}
function confirmAge() {
confirmed = true;
try {
window.sessionStorage.setItem(SESSION_KEY, "1");
} catch {
// sessionStorage 不可用时仍允许本页确认并关闭
}
emit();
}
export function openAgeGateDialog() {
confirmed = false;
try {
window.sessionStorage.removeItem(SESSION_KEY);
} catch {}
emit();
}
export function subscribeAgeGate(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export const getAgeGateOpen = () => !readConfirmed();
const getServerSnapshot = () => false;
function exitSite() {
try {
window.location.replace(EXIT_URL);
} catch {
window.location.href = EXIT_URL;
}
}
export function AgeGateDialogHost() {
const chromeless = useChromeless();
const open = useSyncExternalStore(subscribeAgeGate, getAgeGateOpen, getServerSnapshot) && !chromeless;
useEffect(() => {
if (!open) return;
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = overflow;
};
}, [open]);
if (!open) return null;
return (
<div data-component="age-gate-dialog" className="fixed inset-0 z-[60] flex flex-col items-center overflow-y-auto bg-black/70 px-4 py-10">
<div className="my-auto flex flex-col items-center gap-9 lg:gap-6">
<section
role="dialog"
aria-modal="true"
aria-labelledby="age-gate-title"
aria-describedby="age-gate-desc"
className="flex w-[300px] flex-col items-center gap-[18px] rounded-[20px] bg-[#0B0914] px-5 py-6 outline outline-1 outline-white/20 lg:w-[440px] lg:gap-9 lg:rounded-[40px] lg:p-9"
>
<AdultIcon />
<div className="flex flex-col gap-[18px] text-white/90 lg:gap-6">
<h2
id="age-gate-title"
className="text-center text-lg/6 font-semibold lg:text-2xl/8 lg:font-medium"
>
这是个成人网站
</h2>
<p id="age-gate-desc" className="text-justify text-sm/5 lg:text-lg/6">
本网站包含有年龄限制的内容,包括裸体和露骨色情素材的内容。
点击继续即表示您确认您已年满 18 岁,或在您访问本网站时所在的司法管辖区已是成年人
<span className="hidden lg:inline">。</span>
</p>
</div>
<div className="flex w-full flex-col gap-3">
<button
type="button"
onClick={confirmAge}
className="cursor-pointer rounded-xl bg-[#E9019C] py-[13px] text-base/[22px] font-semibold text-white transition hover:bg-[#F21AAA]"
>
我已年满18岁-进入
</button>
<button
type="button"
onClick={exitSite}
className="cursor-pointer rounded-xl bg-white/10 py-[13px] text-base/[22px] text-white/90 transition hover:bg-white/15"
>
我未满18岁-退出
</button>
</div>
</section>
<button type="button" aria-label="退出" onClick={exitSite} className="cursor-pointer size-9">
<svg viewBox="0 0 44 44" className="size-full" aria-hidden="true">
<circle cx="22" cy="22" r="21" fill="none" stroke="white" strokeWidth="2" />
<path d="M16 16l12 12M28 16L16 28" stroke="white" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
</div>
);
}
function AdultIcon() {
return (
<svg viewBox="0 0 78 78" className="size-[78px] shrink-0 text-[#E9019C]" aria-hidden="true">
<circle cx="39" cy="39" r="35" fill="none" stroke="currentColor" strokeWidth="5" />
<text
x="39"
y="40"
fill="currentColor"
fontSize="26"
fontWeight="800"
textAnchor="middle"
dominantBaseline="central"
fontFamily="Arial, Helvetica, sans-serif"
>
18+
</text>
</svg>
);
}
@@ -0,0 +1,223 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef, type ReactNode } from "react";
import { resolveChannel } from "@/lib/analytics/channel";
import {
externalAd,
inferPage,
initializeTracker,
navigation,
pageClick,
pageView,
setPage,
track,
tracker,
type TrackerBoot,
} from "@/components/analytics/tracker";
const SDK_SRC = "/js/web-sdk-v1.2.0.js?v=1";
function loadScript(src: string): Promise<void> {
return new Promise((resolve) => {
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
if (existing) {
resolve();
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => resolve();
document.head.append(script);
});
}
function bootThirdParty() {
const google = (process.env.NEXT_PUBLIC_GTAG_ID || "").trim();
const yandexRaw = (process.env.NEXT_PUBLIC_YANDEX_METRIKA_ID || "").trim();
const yandex = yandexRaw ? Number(yandexRaw) : 0;
// layout 已注入 GA4 时勿重复加载
if (
google &&
!document.querySelector(`script[src*="googletagmanager.com/gtag/js?id=${google}"]`)
) {
window.dataLayer ||= [];
window.gtag ||= function gtag(...args: unknown[]) {
window.dataLayer!.push(args);
};
window.gtag("js", new Date());
window.gtag("config", google);
void loadScript(`https://www.googletagmanager.com/gtag/js?id=${google}`);
}
if (yandex) {
window.ym ||= Object.assign(
function ym(...args: unknown[]) {
(window.ym!.a ||= []).push(args);
},
{ a: [] as unknown[][], l: Date.now() },
);
window.ym(yandex, "init", {
ssr: true,
webvisor: true,
clickmap: true,
ecommerce: "dataLayer",
referrer: document.referrer,
url: location.href,
accurateTrackBounce: true,
trackLinks: true,
});
void loadScript(`https://mc.yandex.ru/metrika/tag.js?id=${yandex}`);
}
}
export function TrackerProvider({
boot: explicitBoot,
children,
}: {
boot?: TrackerBoot;
children?: ReactNode;
}) {
const pathname = usePathname();
const readyRef = useRef(false);
const lastPathRef = useRef("");
useEffect(() => {
let disposed = false;
const channel = resolveChannel();
const inferred = inferPage(pathname || location.pathname);
const boot: TrackerBoot = {
appId: explicitBoot?.appId || process.env.NEXT_PUBLIC_TRACKER_APP_ID || "JHA-1046",
encryptedConfig:
explicitBoot?.encryptedConfig ||
process.env.NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG ||
"",
appVersion: explicitBoot?.appVersion || "1.0.0",
pageKey: explicitBoot?.pageKey || inferred.key,
pageName: explicitBoot?.pageName || inferred.name,
userType: explicitBoot?.userType || "normal",
uid: explicitBoot?.uid || "",
channel: explicitBoot?.channel || channel,
};
void loadScript(SDK_SRC)
.then(() => initializeTracker(boot))
.then(() => {
if (disposed) return;
readyRef.current = true;
lastPathRef.current = pathname || location.pathname;
pageView();
bootThirdParty();
});
const click = (event: MouseEvent) => {
if (!(event.target instanceof Element)) return;
const target = event.target;
if (
!target.closest(
"[data-ad-close],[data-dialog-ad-close],[data-download-close],[data-login-close]",
)
) {
pageClick(event);
}
const anchor = target.closest<HTMLAnchorElement>(
"a[data-navigation-key],a[data-nav-key]",
);
if (anchor) navigation(anchor);
const ad = target.closest<HTMLElement>('[data-track-ad="external"][data-ad-id]');
if (ad) externalAd(ad, "click");
};
document.addEventListener("click", click, true);
const observer =
typeof IntersectionObserver === "undefined"
? null
: new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
externalAd(entry.target as HTMLElement, "show");
observer?.unobserve(entry.target);
}
}
},
{ threshold: 0.1 },
);
const observed = new WeakSet<Element>();
const observe = () =>
document
.querySelectorAll('[data-track-ad="external"][data-ad-id]:not([data-ad-clone])')
.forEach((node) => {
if (!observed.has(node)) {
observed.add(node);
if (observer) observer.observe(node);
else externalAd(node as HTMLElement, "show");
}
});
observe();
const mutations = new MutationObserver(observe);
mutations.observe(document.body, { childList: true, subtree: true });
const external = (event: Event) => {
const detail = (event as CustomEvent<{ element: HTMLElement; eventType: string }>).detail;
if (detail?.element) externalAd(detail.element, detail.eventType);
};
const advertising = (event: Event) => {
const detail = (event as CustomEvent<{ element: HTMLElement; eventType: string }>).detail;
const node = detail?.element;
if (!node) return;
void track("advertising", {
event_type: detail.eventType,
advertising_key: node.dataset.advertisingKey || "dialog_ad",
advertising_name: node.dataset.advertisingName || "弹窗广告",
advertising_id: node.dataset.advertisingId || node.dataset.dialogAdId || "",
});
};
document.addEventListener("external-ad:track", external);
document.addEventListener("dialog-ad:track", advertising);
const flush = () => {
void tracker.flush();
};
window.addEventListener("pagehide", flush);
return () => {
disposed = true;
observer?.disconnect();
mutations.disconnect();
document.removeEventListener("click", click, true);
document.removeEventListener("external-ad:track", external);
document.removeEventListener("dialog-ad:track", advertising);
window.removeEventListener("pagehide", flush);
};
// 仅随 boot 关键字段变化重建;pathname 由下方 effect 处理
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
explicitBoot?.appId,
explicitBoot?.encryptedConfig,
explicitBoot?.uid,
explicitBoot?.channel,
explicitBoot?.userType,
]);
useEffect(() => {
if (!readyRef.current) return;
const path = pathname || "";
if (!path || path === lastPathRef.current) return;
lastPathRef.current = path;
const next = inferPage(path);
setPage(next, true);
}, [pathname]);
return children;
}
export function useTracker() {
return tracker;
}
export default TrackerProvider;
+36
View File
@@ -0,0 +1,36 @@
import { connection } from "next/server";
import { getHomeBaseCached } from "@/lib/api/home";
import { TrackerProvider } from "./TrackerProvider";
/**
* 服务端拉取 reportSdkConfig,客户端初始化 Web SDK。
* 须放在 <Suspense> 内,并用 connection() 排除在 prerender shell 外。
*/
export async function TrackerHost() {
await connection();
let encryptedConfig =
process.env.NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG?.trim() ||
process.env.TRACKER_ENCRYPTED_CONFIG?.trim() ||
"";
try {
const base = await getHomeBaseCached();
// 接口字段在 config.reportSdkConfig
const fromApi = base.config?.reportSdkConfig?.trim();
if (fromApi) encryptedConfig = fromApi;
} catch {
// 接口失败时用环境变量兜底
}
return (
<TrackerProvider
boot={{
appId: process.env.NEXT_PUBLIC_TRACKER_APP_ID || "JHA-1046",
encryptedConfig,
appVersion: "1.0.0",
}}
/>
);
}
+570
View File
@@ -0,0 +1,570 @@
/** 数据上报字段(对齐 Web SDK / jha-1043 tracker) */
export type Fields = Record<string, unknown>;
export interface TrackerBoot {
appId?: string;
encryptedConfig?: unknown;
appVersion?: string;
uid?: string;
channel?: string;
pageKey?: string;
pageName?: string;
userType?: string;
}
interface WebSDK {
init(options: Fields): unknown;
track: Record<string, (payload: Fields) => unknown>;
getCommonFields?(keys: string[]): Promise<Fields>;
flush?(): unknown;
setUid?(uid: string): void;
setChannel?(channel: string): void;
setContext?(fields: Fields): void;
}
declare global {
interface Window {
WebSDK?: WebSDK;
__TRACKER_BOOT__?: TrackerBoot;
__TRACKER__?: typeof tracker;
tracker?: typeof tracker;
__trackerCommonFields?: Record<string, string>;
__CHANNEL_CODE__?: string;
dataLayer?: unknown[];
gtag?: (...args: unknown[]) => void;
ym?: ((...args: unknown[]) => void) & { a?: unknown[][]; l?: number };
}
}
const str = (value: unknown, fallback = "") =>
String(value ?? "").trim() || fallback;
const number = (value: unknown, fallback = 0) =>
Number.isFinite(Number(value)) ? Number(value) : fallback;
const commonKeys = [
"sid",
"device_id",
"device",
"device_fingerprint",
"fp_version",
];
const serverKeys = [
"app_id",
"uid",
"channel",
"sid",
"client_ts",
"device",
"device_id",
"device_brand",
"device_model",
"device_fingerprint",
"fp_version",
"user_agent",
"sdk_version",
"app_version",
];
let activeBoot: TrackerBoot = {};
let finishReady: () => void;
const ready = new Promise<void>((resolve) => {
finishReady = resolve;
});
let initializing = false;
let common: Record<string, string> = Object.fromEntries(
commonKeys.map((key) => [key, ""]),
);
let commonReady: Promise<void> = Promise.resolve();
let page = { key: "internal_other", name: "站内其他页面" };
let userType = "normal";
let drama: Fields | null = null;
const impressions = new Set<string>();
export function normalizeUID(value: unknown): string {
const uid = str(value);
return uid.length >= 4 &&
uid.length <= 32 &&
/^[A-Za-z0-9_]+$/.test(uid) &&
!["u_1001", "stress_user", "u_lab"].includes(uid.toLowerCase())
? uid
: "";
}
export function normalizeChannel(value: unknown): string {
const channel = str(value);
return channel.length <= 50 &&
/^[A-Za-z0-9_.-]+$/.test(channel) &&
!["0", "self", "natural", "official", "channel", "unknown", "-"].includes(
channel.toLowerCase(),
)
? channel
: "organic";
}
/** 按路径推断 page_key / page_name(对齐 jha-1043 tracker_page.go,适配本站路由) */
export function inferPage(path: string) {
const raw = (path || "/").split("?")[0] || "/";
const p = raw.length > 1 && raw.endsWith("/") ? raw.slice(0, -1) : raw;
if (p === "/" || p === "") return { key: "home", name: "首页" };
// 短剧:/short-drama/{sort} 列表;/short-drama/{slug} 详情;/short-drama/{slug}/{ep} 播放
if (/^\/short-drama(?:\/|$)/.test(p)) {
const parts = p.split("/").filter(Boolean); // short-drama, ...
if (parts.length >= 3 && /^\d+$/.test(parts[2] || "")) {
return { key: "play", name: "短剧播放页" };
}
// 排序段(hot/latest 及旧中文别名)视为列表,其余视为详情
const sortKeys = new Set(["hot", "latest", "热度优先", "最近更新"]);
if (parts.length === 2 && sortKeys.has(decodeURIComponent(parts[1] || ""))) {
return { key: "list", name: "短剧列表" };
}
if (parts.length === 1) return { key: "list", name: "短剧列表" };
return { key: "detail", name: "短剧详情" };
}
// 视频:列表 / 播放
if (/^\/video(?:\/|$)/.test(p)) {
const parts = p.split("/").filter(Boolean);
const sortKeys = new Set(["hot", "latest", "热度优先", "最近更新"]);
if (parts.length === 2 && sortKeys.has(decodeURIComponent(parts[1] || ""))) {
return { key: "list", name: "视频列表" };
}
if (parts.length === 1) return { key: "list", name: "视频列表" };
return { key: "play", name: "视频播放页" };
}
const rules: Array<[RegExp, string, string]> = [
[/^\/ranking(?:\/|$)/, "rank", "排行榜"],
[/^\/topic(?:\/|$)/, "topic", "专题详情"],
[/^\/more(?:\/|$)/, "topic", "专题详情"],
[/^\/tag(?:\/|$)/, "tag_list", "标签详情"],
[/^\/search(?:\/|$)/, "search", "搜索结果页"],
[/^\/history(?:\/|$)/, "history", "观看历史"],
[/^\/favorites(?:\/|$)/, "collect", "我的收藏"],
[/^\/likes(?:\/|$)/, "likes", "我的点赞"],
[/^\/profile(?:\/|$)/, "user_center", "个人中心"],
[/^\/settings\/profile(?:\/|$)/, "edit_profile", "编辑资料"],
[/^\/settings(?:\/|$)/, "setting", "设置"],
[/^\/way-home(?:\/|$)/, "home_road", "回家的路"],
[/^\/welfare(?:\/|$)/, "application", "应用推荐"],
[/^\/nav(?:\/|$)/, "nav", "官方导航"],
[/^\/about(?:\/|$)/, "about_us", "关于我们"],
[/^\/terms(?:\/|$)/, "user_agreement", "用户协议"],
[/^\/privacy(?:\/|$)/, "privacy_policy", "隐私政策"],
[/^\/help(?:\/|$)/, "help_center", "帮助中心"],
[/^\/feedback(?:\/|$)/, "feedback", "问题反馈"],
[/^\/richtext(?:\/|$)/, "rich_text", "富文本页"],
[/^\/collection(?:\/|$)/, "collect", "合集"],
];
const hit = rules.find(([re]) => re.test(p));
return hit
? { key: hit[1], name: hit[2] }
: { key: "internal_other", name: "站内其他页面" };
}
/** SPA 路由切换时更新当前页并上报 page_view */
export function setPage(next: { key: string; name: string }, reportView = true) {
page = { key: str(next.key, page.key), name: str(next.name, page.name) };
if (reportView) pageView();
}
export function initializeTracker(boot: TrackerBoot): Promise<void> {
const inferred = inferPage(location.pathname);
page = {
key: str(boot.pageKey, inferred.key),
name: str(boot.pageName, inferred.name),
};
userType = boot.userType === "vip" ? "vip" : "normal";
activeBoot = boot;
window.__TRACKER_BOOT__ = boot;
window.__TRACKER__ = tracker;
window.tracker = tracker;
if (!initializing) {
initializing = true;
const resolve = () => finishReady();
let attempts = 0;
const timer = setInterval(() => {
if (!window.WebSDK?.track && ++attempts < 100) return;
clearInterval(timer);
if (!window.WebSDK) {
resolve();
return;
}
Promise.resolve()
.then(() =>
window.WebSDK?.init({
appId: str(boot.appId),
uid: normalizeUID(boot.uid),
channel: normalizeChannel(boot.channel || window.__CHANNEL_CODE__),
encryptedConfig: str(boot.encryptedConfig),
appVersion: str(boot.appVersion, "1.0.0"),
disableAutoEvents: ["app_page_view", "page_click"],
}),
)
.catch(() => undefined)
.then(() => {
// Core events need only SDK initialization; device fingerprint loading must not stall them.
commonReady = (async () => {
try {
const fields = await window.WebSDK?.getCommonFields?.(
commonKeys.slice(),
);
common = Object.fromEntries(
commonKeys.map((key) => [key, str(fields?.[key])]),
);
} catch {
/* Empty common fields preserve the original degraded SDK contract. */
}
window.__trackerCommonFields = common;
window.dispatchEvent(
new CustomEvent("tracker:common-fields", { detail: common }),
);
})();
window.dispatchEvent(
new CustomEvent("tracker:ready", { detail: page }),
);
resolve();
});
}, 50);
}
return ready;
}
export async function track(method: string, payload: Fields): Promise<unknown> {
await ready;
try {
return await window.WebSDK?.track[method]?.(payload);
} catch {
return { sent: false, ok: false, reason: "exception" };
}
}
export function pageView(extra: Fields = {}) {
let referrer = { key: "direct", name: "直接访问" };
try {
const saved = sessionStorage.getItem("__tracker_last_page");
if (saved) referrer = JSON.parse(saved);
else if (document.referrer) {
const url = new URL(document.referrer);
referrer =
url.host === location.host
? inferPage(url.pathname)
: { key: "external", name: url.host };
}
} catch {
/* Storage is optional. */
}
const navigation = performance.getEntriesByType("navigation")[0] as
| PerformanceNavigationTiming
| undefined;
void track("appPageView", {
user_type: userType,
page_key: page.key,
page_name: page.name,
referrer_page_key: referrer.key,
referrer_page_name: referrer.name,
current_page_key: page.key,
current_page_name: page.name,
page_load_time: Math.max(
0,
Math.round(
navigation?.loadEventEnd ||
navigation?.domContentLoadedEventEnd ||
performance.now(),
),
),
recommend_trace_id: "",
...extra,
});
try {
sessionStorage.setItem("__tracker_last_page", JSON.stringify(page));
} catch {
/* Storage is optional. */
}
}
export function navigation(anchor: HTMLAnchorElement) {
const href = anchor.getAttribute("href");
if (!href || /^(#|javascript:)/i.test(href)) return;
let key = str(anchor.dataset.navigationKey || anchor.dataset.navKey);
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key)) {
const url = new URL(href, location.href);
const target = inferPage(url.pathname);
key =
url.origin !== location.origin
? "nav_external"
: target.key !== "internal_other"
? "nav_" + target.key
: "nav_" +
(url.pathname.split("/").filter(Boolean)[0] || "home")
.replace(/[^a-z0-9]+/gi, "_")
.slice(0, 48);
}
void track("navigation", {
navigation_key: key.toLowerCase(),
navigation_name: str(
anchor.dataset.navigationName || anchor.dataset.navName,
anchor.getAttribute("aria-label") ||
anchor.textContent?.replace(/\s+/g, " ") ||
key,
).slice(0, 255),
});
}
export function pageClick(event: MouseEvent) {
void track("pageClick", {
page_key: page.key,
page_name: page.name,
click_page_x: Math.trunc(event.clientX),
click_page_y: Math.trunc(event.clientY),
click_x_percent: innerWidth
? Math.round(
Math.max(0, Math.min(100, (event.clientX / innerWidth) * 100)),
)
: 0,
click_y_percent: innerHeight
? Math.round(
Math.max(0, Math.min(100, (event.clientY / innerHeight) * 100)),
)
: 0,
screen_width: screen.width,
screen_height: screen.height,
recommend_trace_id: "",
});
}
export function externalAd(node: HTMLElement, type: string) {
if (!["show", "click"].includes(type) || !node.dataset.adId) return;
const slots = Array.from(
document.querySelectorAll(
'[data-track-ad="external"][data-ad-id]:not([data-ad-clone])',
),
);
const slot = node.hasAttribute("data-ad-clone")
? slots.find(
(slot) => slot.getAttribute("data-ad-id") === node.dataset.adId,
)
: node;
const position = Math.max(1, slots.indexOf(slot as Element) + 1);
const interstitial = node.dataset.adType === "interstitial";
const payload = {
page_key: page.key,
page_name: page.name,
ad_slot_key: str(
node.dataset.adSlotKey,
`${page.key}_${interstitial ? "popup" : "content_banner"}_${position}`,
),
ad_slot_name: str(
node.dataset.adSlotName,
page.name + (interstitial ? "弹窗广告位" : `内容横幅第${position}位`),
),
ad_id: node.dataset.adId,
creative_id: str(node.dataset.creativeId),
ad_type: str(node.dataset.adType, "banner"),
};
const key = payload.ad_slot_key + "|" + payload.ad_id;
if (!impressions.has(key)) {
impressions.add(key);
void track("adImpression", payload);
}
if (type === "click" && node.dataset.adClickable === "1")
void track("adClick", payload);
}
export function setDramaContext(value: Fields | null) {
drama = value;
}
const behaviorNames: Record<string, string> = {
ep_view: "展示",
ep_play: "播放",
ep_pause: "暂停",
ep_forward: "快进",
ep_rewind: "快退",
ep_complete: "播放完成",
ep_share: "分享",
ep_fail: "播放失败",
};
const videoBehaviorNames: Record<string, string> = {
video_view: "展示",
video_play: "播放",
video_pause: "暂停",
video_forward: "快进",
video_rewind: "快退",
video_complete: "播放完成",
video_fail: "播放失败",
};
let videoCtx: Fields | null = null;
export function setVideoContext(value: Fields | null) {
videoCtx = value;
}
export function videoEvent(
behavior: string,
media?: HTMLVideoElement | null,
options: { played?: number; forceProgress?: number } = {},
) {
if (!videoCtx) return Promise.resolve();
const duration =
media && Number.isFinite(media.duration)
? Math.round(media.duration)
: number(videoCtx.video_duration);
const current = Math.round(media?.currentTime || 0);
return track("videoEvent", {
...videoCtx,
media_id: str(videoCtx.media_id, str(videoCtx.video_id)),
video_duration: Math.max(0, duration),
play_duration: Math.max(0, Math.round(options.played || 0)),
play_progress:
options.forceProgress ??
(behavior === "video_complete"
? 100
: duration > 0
? Math.round(Math.min(100, (current / duration) * 100))
: 0),
play_speed: Math.max(1, Math.round((media?.playbackRate || 1) * 100)),
video_behavior_key: behavior,
video_behavior_name: videoBehaviorNames[behavior] || behavior,
});
}
export function dramaEvent(
behavior: string,
video?: HTMLVideoElement | null,
options: { played?: number; forceProgress?: number } = {},
) {
if (!drama) return Promise.resolve();
const duration =
video && Number.isFinite(video.duration)
? Math.round(video.duration)
: number(drama.drama_duration);
const current = Math.round(video?.currentTime || 0);
return track("dramaEvent", {
...drama,
drama_duration: Math.max(0, duration),
play_duration: Math.max(0, Math.round(options.played || 0)),
play_progress:
options.forceProgress ??
(behavior === "ep_complete"
? 100
: duration > 0
? Math.round(Math.min(100, (current / duration) * 100))
: 0),
play_speed: Math.max(1, Math.round((video?.playbackRate || 1) * 100)),
play_behavior_key: behavior,
play_behavior_name: behaviorNames[behavior] || behavior,
});
}
export function dramaStatusChange(
episode: number,
videoID: string | number,
override?: string,
) {
if (!drama) return Promise.resolve();
const previous = number(drama.episode, 1);
const behavior =
override ||
(episode === previous + 1
? "ep_next"
: episode === previous - 1
? "ep_previous"
: "ep_switch");
if (!override && episode === previous && str(videoID) === str(drama.video_id))
return Promise.resolve();
const names: Record<string, string> = {
ep_auto_play: "自动播放",
ep_previous: "上一集",
ep_next: "下一集",
ep_switch: "切换剧集",
ep_unlock: "解锁",
};
return track("dramaStatusChange", {
media_id: drama.media_id,
drama_id: drama.drama_id,
drama_title: drama.drama_title,
episode: Math.max(1, episode),
video_id: str(videoID, str(drama.video_id)),
pre_episode: previous,
drama_behavior_key: behavior,
drama_behavior_name: names[behavior] || behavior,
});
}
/** Full per-request SDK snapshot for server-side drama events; header compatibility remains five fields. */
export async function serverContext(keys = serverKeys): Promise<Fields> {
await ready;
let fields: Fields = {};
try {
fields = (await window.WebSDK?.getCommonFields?.(serverKeys.slice())) || {};
} catch {
/* Cached device identifiers remain usable. */
}
const defaults: Fields = {
...common,
app_id: str(activeBoot.appId),
uid: normalizeUID(activeBoot.uid),
channel: normalizeChannel(activeBoot.channel || window.__CHANNEL_CODE__),
client_ts: Math.floor(Date.now() / 1000),
device_brand: "",
device_model: "",
user_agent: navigator.userAgent,
sdk_version: "1.2.0",
app_version: str(activeBoot.appVersion, "1.0.0"),
};
return Object.fromEntries(
keys.map((key) => [
key,
key === "client_ts"
? Math.floor(number(fields[key], number(defaults[key])))
: str(fields[key] ?? defaults[key]),
]),
);
}
export const tracker = {
init: initializeTracker,
pageView,
setPage,
inferPage,
dramaEvent,
dramaStatusChange,
dramaUnlock: () =>
dramaStatusChange(
number(drama?.episode, 1),
str(drama?.video_id),
"ep_unlock",
),
setDramaContext,
videoEvent,
setVideoContext,
getVideoContext: () => (videoCtx ? { ...videoCtx } : null),
externalAd,
advertising: (payload: Fields) => track("advertising", payload),
keywordSearch: (payload: Fields) => track("keywordSearch", payload),
keywordClick: (payload: Fields) => track("keywordClick", payload),
getDramaContext: () => ({ ...drama }),
getCommonFieldsSync: () => common,
getCommonFields: serverContext,
serverContext,
getCommonField: async (key: string) => {
await ready;
await commonReady;
return common[key] || "";
},
encodeSDKCommonHeader: (fields = common) =>
btoa(
unescape(
encodeURIComponent(
JSON.stringify(
Object.fromEntries(
commonKeys.map((key) => [key, str(fields[key])]),
),
),
),
),
),
getSDKCommonHeader: async () => {
await ready;
await commonReady;
return tracker.encodeSDKCommonHeader();
},
flush: async () => {
try {
await window.WebSDK?.flush?.();
} catch {
/* Analytics must never interrupt playback. */
}
},
setUid: (uid: unknown) => {
activeBoot.uid = normalizeUID(uid);
window.WebSDK?.setUid?.(activeBoot.uid);
},
setChannel: (channel: unknown) => {
activeBoot.channel = normalizeChannel(channel);
window.WebSDK?.setChannel?.(activeBoot.channel);
},
setContext: (context: Fields) => window.WebSDK?.setContext?.(context),
};
@@ -0,0 +1,168 @@
"use client";
import { useEffect, useRef } from "react";
import {
dramaEvent,
setDramaContext,
setVideoContext,
tracker,
videoEvent,
type Fields,
} from "@/components/analytics/tracker";
export function useDramaContext(context: Fields | null) {
const viewed = useRef("");
useEffect(() => {
setDramaContext(context);
const key = context ? `${String(context.drama_id)}:${String(context.video_id)}` : "";
if (context && viewed.current !== key) {
viewed.current = key;
void dramaEvent("ep_view");
}
return () => setDramaContext(null);
}, [context]);
}
export function useVideoContext(context: Fields | null) {
const viewed = useRef("");
useEffect(() => {
setVideoContext(context);
const key = context ? String(context.video_id) : "";
if (context && viewed.current !== key) {
viewed.current = key;
void videoEvent("video_view");
}
return () => setVideoContext(null);
}, [context]);
}
const reporters = new WeakMap<HTMLVideoElement, (behavior: string) => void>();
export function reportPlaybackAction(behavior: string, video?: HTMLVideoElement | null) {
const report = video && reporters.get(video);
if (report) report(behavior);
else void dramaEvent(behavior, video);
}
/** 墙钟播放时长(排除缓冲/seek),对齐原 SDK */
export function bindPlaybackAnalytics(
video: HTMLVideoElement,
enabled: boolean,
kind: "drama" | "video" = "drama",
): () => void {
if (!enabled) return () => undefined;
let started = 0;
let elapsed = 0;
let position = 0;
let seekFrom: number | null = null;
const settle = () => {
if (started) elapsed += Math.max(0, Date.now() - started);
started = 0;
};
const begin = () => {
if (!started) started = Date.now();
};
const mapBehavior = (behavior: string) => {
if (kind === "drama") return behavior;
const map: Record<string, string> = {
ep_play: "video_play",
ep_pause: "video_pause",
ep_forward: "video_forward",
ep_rewind: "video_rewind",
ep_complete: "video_complete",
ep_fail: "video_fail",
};
return map[behavior] || behavior;
};
const emit = (behavior: string, keep = false, forceProgress?: number) => {
const played = (elapsed + (started ? Math.max(0, Date.now() - started) : 0)) / 1000;
const key = mapBehavior(behavior);
if (kind === "video") void videoEvent(key, video, { played, forceProgress });
else void dramaEvent(key, video, { played, forceProgress });
if (!keep) {
elapsed = 0;
if (started) started = Date.now();
}
};
reporters.set(video, emit);
const events: Record<string, () => void> = {
play: () => emit("ep_play"),
playing: begin,
waiting: settle,
pause: () => {
const pending = started || elapsed > 0;
settle();
if (!video.ended && !video.seeking && pending) emit("ep_pause");
},
timeupdate: () => {
if (!video.seeking) position = video.currentTime;
},
seeking: () => {
if (seekFrom == null) {
settle();
seekFrom = position;
}
},
seeked: () => {
if (seekFrom == null) return;
const from = seekFrom;
seekFrom = null;
position = video.currentTime;
if (Math.abs(position - from) >= 0.5) emit(position > from ? "ep_forward" : "ep_rewind");
if (!video.paused && !video.ended) begin();
},
ended: () => {
settle();
emit("ep_complete", true);
emit("ep_play", false, 100);
},
error: () => {
settle();
emit("ep_fail");
},
};
Object.entries(events).forEach(([name, handler]) => video.addEventListener(name, handler));
const report = () => {
if (elapsed + (started ? Date.now() - started : 0) >= 500) {
settle();
emit("ep_play");
void tracker.flush();
setTimeout(() => void tracker.flush(), 50);
setTimeout(() => void tracker.flush(), 200);
}
};
const visibility = () => {
if (document.visibilityState === "hidden") report();
if (!video.paused && !video.ended) begin();
};
document.addEventListener("visibilitychange", visibility);
window.addEventListener("pagehide", report);
window.addEventListener("beforeunload", report);
return () => {
reporters.delete(video);
const pending = started || elapsed;
settle();
if (pending && !video.ended) emit("ep_pause");
Object.entries(events).forEach(([name, handler]) => video.removeEventListener(name, handler));
document.removeEventListener("visibilitychange", visibility);
window.removeEventListener("pagehide", report);
window.removeEventListener("beforeunload", report);
};
}
/** 从 xgplayer 实例拿到内部 video 并绑定埋点 */
export function bindPlayerAnalytics(
player: { media?: HTMLVideoElement | null; video?: HTMLVideoElement | null } | null | undefined,
kind: "drama" | "video" = "drama",
): () => void {
const video = player?.media || player?.video || null;
if (!video) return () => undefined;
return bindPlaybackAnalytics(video, true, kind);
}
@@ -0,0 +1,18 @@
import { connection } from "next/server";
import { buildServerForwardHeaders } from "@/lib/api/client-headers";
import { primeRequestForwardHeaders } from "@/lib/api/request-context";
/**
* 请求期注入上游透传头(UA / IP)。
* 必须放在 <Suspense> 内,禁止在 RootLayout 顶层直接 headers(),否则会 blocking-prerender-runtime。
*/
export async function RequestHeadersPrime() {
await connection();
try {
primeRequestForwardHeaders(await buildServerForwardHeaders());
} catch {
// 无请求上下文时跳过
}
return null;
}
@@ -0,0 +1,11 @@
"use client";
import { useEffect } from "react";
import { bindUnauthorizedHandler } from "@/lib/auth/handle-unauthorized";
/** 将 api 401 / 无权限接到清会话 + 重新登录 */
export function AuthUnauthorizedBridge() {
useEffect(() => bindUnauthorizedHandler(), []);
return null;
}
@@ -0,0 +1,52 @@
"use client";
import { useEffect, useState } from "react";
import { ensureGuestSession } from "@/lib/auth/session";
import { tracker } from "@/lib/tracker";
/**
* 仅在挂载后异步拉游客会话,不参与 SSR / 首屏阻塞。
* ensureGuestSession 本地有缓存时会同步返回并后台 refresh。
*/
export function GuestAuthBootstrap() {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
// 不 await:失败/超时也不拖住其它渲染
void ensureGuestSession()
.then((user) => {
if (cancelled || !user) return;
// 埋点 uid:SDK 要求非空时长度 ≥ 4
tracker.setUid(String(user.userId || ""));
if (process.env.NODE_ENV === "development") {
console.info("[guest-auth]", {
userId: user.userId,
nickName: user.nickName,
isRegister: user.isRegister,
loginType: user.loginType,
});
}
})
.catch((err: unknown) => {
if (cancelled) return;
const message = err instanceof Error ? err.message : "游客登录失败";
setError(message);
console.error("[guest-auth]", err);
});
return () => {
cancelled = true;
};
}, []);
if (process.env.NODE_ENV !== "development" || !error) return null;
return (
<div className="pointer-events-none fixed right-3 bottom-3 z-[60] max-w-xs rounded-lg bg-black/80 px-3 py-2 text-xs text-[#FF8FA8]">
游客登录失败:{error}
</div>
);
}
+385
View File
@@ -0,0 +1,385 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useId, useState, useSyncExternalStore, type FormEvent, type ReactNode } from "react";
import { useMockUser } from "@/components/profile/use-mock-user";
import {
ACCOUNT_RULE_HINT,
CREDENTIAL_MAX_LEN,
isValidCredential,
PASSWORD_RULE_HINT,
} from "@/lib/auth/credentials";
type Mode = "login" | "register";
let currentMode: Mode | null = null;
const listeners = new Set<() => void>();
function setDialogMode(mode: Mode | null) {
currentMode = mode;
listeners.forEach((listener) => listener());
}
export function openLoginDialog(mode: Mode = "login") {
setDialogMode(mode);
}
export function openRegisterDialog() {
setDialogMode("register");
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
const getSnapshot = () => currentMode;
const getServerSnapshot = () => null;
const desktopQuery = "(min-width: 1024px)";
function subscribeDesktop(callback: () => void) {
const media = window.matchMedia(desktopQuery);
media.addEventListener("change", callback);
return () => media.removeEventListener("change", callback);
}
const getDesktopSnapshot = () => window.matchMedia(desktopQuery).matches;
const getDesktopServerSnapshot = () => false;
const emptyValues = { account: "", password: "", confirmPassword: "" };
type Field = keyof typeof emptyValues;
type Errors = Partial<Record<Field, string>>;
function validate(mode: Mode, values: typeof emptyValues): Errors {
const errors: Errors = {};
if (!values.account) errors.account = "请输入账号";
else if (!isValidCredential(values.account)) errors.account = ACCOUNT_RULE_HINT;
if (!values.password) errors.password = "请输入密码";
else if (!isValidCredential(values.password))
errors.password = mode === "login" ? "密码错误,请重新输入" : PASSWORD_RULE_HINT;
if (mode === "register") {
if (!values.confirmPassword) errors.confirmPassword = "请再次输入密码";
else if (values.confirmPassword !== values.password) errors.confirmPassword = "两次输入的密码不一致";
}
return errors;
}
function mapAuthError(message: string, mode: Mode): Errors {
if (/账户不存在|账号不存在|用户不存在/.test(message)) return { account: message };
if (/已存在|重复|already exists/i.test(message)) return { account: "该账号已被注册" };
if (/数据库|database/i.test(message)) return { account: "注册服务暂时异常,请稍后重试" };
if (/密码|password/i.test(message)) return { password: message };
if (/参数/.test(message)) return { account: message };
return mode === "login" ? { password: message } : { account: message };
}
export function LoginDialogHost() {
const mode = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const isDesktop = useSyncExternalStore(subscribeDesktop, getDesktopSnapshot, getDesktopServerSnapshot);
const { login, register } = useMockUser();
const [values, setValues] = useState(emptyValues);
const [errors, setErrors] = useState<Errors>({});
const [submitting, setSubmitting] = useState(false);
const close = useCallback(() => {
if (submitting) return;
setValues(emptyValues);
setErrors({});
setDialogMode(null);
}, [submitting]);
useEffect(() => {
if (!mode) return;
const onKeyDown = (event: KeyboardEvent) => event.key === "Escape" && close();
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = overflow;
window.removeEventListener("keydown", onKeyDown);
};
}, [mode, close]);
if (!mode) return null;
const isLogin = mode === "login";
const placeholderClass = isLogin ? "" : "lg:placeholder:text-[#746E85]";
const switchMode = (next: Mode) => {
if (submitting) return;
setErrors({});
setValues((current) => ({ ...current, confirmPassword: "" }));
setDialogMode(next);
};
const update = (key: Field) => (value: string) => {
setValues((current) => ({ ...current, [key]: value }));
setErrors((current) => ({ ...current, [key]: undefined }));
};
const submit = async (event: FormEvent) => {
event.preventDefault();
const nextErrors = validate(mode, values);
setErrors(nextErrors);
if (Object.keys(nextErrors).length || submitting) return;
setSubmitting(true);
try {
if (isLogin) await login(values.account, values.password);
else await register(values.account, values.password);
if (process.env.NODE_ENV === "development") {
console.info(isLogin ? "[auth-login]" : "[auth-register]", { account: values.account });
}
setValues(emptyValues);
setErrors({});
setDialogMode(null);
} catch (error) {
const message = error instanceof Error ? error.message : isLogin ? "登录失败,请稍后重试" : "注册失败,请稍后重试";
setErrors(mapAuthError(message, mode));
} finally {
setSubmitting(false);
}
};
return (
<div
className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto bg-black/70 px-4 py-10"
onClick={(event) => event.target === event.currentTarget && close()}
>
<div className={`my-auto flex flex-col items-center lg:gap-6 ${isLogin ? "gap-[11px]" : "gap-[7px]"}`}>
<form
role="dialog"
aria-modal="true"
aria-labelledby="login-dialog-title"
onSubmit={submit}
className="flex w-[311px] flex-col items-center gap-4 rounded-3xl bg-[#0E0C17] px-[18px] py-6 lg:w-[456px] lg:gap-3.5 lg:bg-[#0A0915]/90 lg:px-8 lg:py-7 lg:shadow-[0_20px_50px_rgba(0,0,0,0.35)] lg:outline lg:outline-1 lg:-outline-offset-1 lg:outline-white/13"
>
<img src="/logo.svg" alt="海角社区" className="h-[33px] w-auto lg:h-12" />
{isLogin ? (
<div className="flex gap-4 self-stretch lg:hidden" role="tablist">
<TabButton active onClick={() => switchMode("login")}>
登录
</TabButton>
<TabButton active={false} onClick={() => switchMode("register")}>
注册
</TabButton>
</div>
) : null}
<div className="flex flex-col gap-4 self-stretch lg:gap-2">
<h2
id="login-dialog-title"
className={`text-xl leading-[30px] font-bold text-white lg:not-sr-only lg:text-[30px] lg:leading-9 lg:font-semibold ${isLogin ? "sr-only" : ""}`}
>
{isLogin ? "登录" : "创建账号"}
</h2>
{isLogin ? (
<p className="text-xs leading-[18px] text-[#A8A1B8] lg:text-sm lg:leading-5 lg:text-[#9D97AE]">
登录后同步收藏、历史与会员权益
</p>
) : (
<p className="text-xs leading-[18px] text-[#9E94AD] lg:text-sm lg:leading-5 lg:text-[#A8A1B6]">
<span className="lg:hidden">注册后同步收藏、历史与会员权益</span>
<span className="max-lg:hidden">注册成功即可同步收藏与追剧记录</span>
</p>
)}
</div>
<AuthField
label="账号"
icon={<UserIcon />}
placeholder="请输入账号"
value={values.account}
onChange={update("account")}
autoComplete="username"
placeholderClass={placeholderClass}
error={errors.account}
/>
<AuthField
label="密码"
icon={<LockIcon />}
placeholder="请输入密码"
value={values.password}
onChange={update("password")}
autoComplete={isLogin ? "current-password" : "new-password"}
secret
placeholderClass={placeholderClass}
error={errors.password}
/>
{isLogin ? null : (
<AuthField
label="确认密码"
icon={<LockIcon />}
placeholder={isDesktop ? "请再次输入密码" : "确认密码"}
value={values.confirmPassword}
onChange={update("confirmPassword")}
autoComplete="new-password"
secret
placeholderClass={placeholderClass}
error={errors.confirmPassword}
/>
)}
<button
type="submit"
disabled={submitting}
className="cursor-pointer h-11 self-stretch rounded-xl bg-[#F12B9E] text-base leading-[22px] font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-wait disabled:opacity-70 lg:h-12 lg:text-sm lg:leading-5 lg:font-semibold"
>
{submitting ? (
isLogin ? "登录中…" : "注册中…"
) : isLogin ? (
<>
<span className="lg:hidden">登录</span>
<span className="max-lg:hidden">立即登录</span>
</>
) : (
"立即注册"
)}
</button>
<p className="flex h-[22px] items-center justify-center gap-2.5 self-stretch text-xs leading-[18px] lg:h-6 lg:text-sm lg:leading-5">
<span className="text-[#9790A6] max-lg:hidden">{isLogin ? "还没有账号?" : "已有账号?"}</span>
<button
type="button"
onClick={() => switchMode(isLogin ? "register" : "login")}
className="cursor-pointer font-bold text-[#F12B9E] hover:opacity-80 lg:font-semibold"
>
<span className="lg:hidden">{isLogin ? "没有账号?注册账号" : "已有账号? 去登录"}</span>
<span className="max-lg:hidden">{isLogin ? "立即注册" : "去登录"}</span>
</button>
</p>
<p
className={`self-stretch text-center text-xs leading-[18px] lg:hidden ${isLogin ? "text-[#7A738A]" : "text-[#9E94AD]"}`}
>
{isLogin ? "登录" : "注册"}即代表同意
<Link href="/terms" onClick={close} className="text-[#A8A1B8]">
《用户协议》
</Link>
和
<Link href="/privacy" onClick={close} className="text-[#A8A1B8]">
《隐私政策》
</Link>
</p>
</form>
<button type="button" aria-label="关闭" onClick={close} className="cursor-pointer size-11 lg:size-9">
<svg viewBox="0 0 44 44" className="size-full" aria-hidden="true">
<circle cx="22" cy="22" r="21" fill="none" stroke="white" strokeWidth="2" />
<path d="M16 16l12 12M28 16L16 28" stroke="white" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
</div>
);
}
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={`cursor-pointer text-xl leading-[30px] ${active ? "font-bold text-white" : "text-[#656565]"}`}
>
{children}
</button>
);
}
function AuthField({
label,
icon,
placeholder,
value,
onChange,
autoComplete,
secret = false,
placeholderClass = "",
error,
}: {
label: string;
icon: ReactNode;
placeholder: string;
value: string;
onChange: (value: string) => void;
autoComplete: string;
secret?: boolean;
placeholderClass?: string;
error?: string;
}) {
const [visible, setVisible] = useState(false);
const errorId = useId();
const outlineClass = error
? "outline outline-1 -outline-offset-1 outline-[#FF4A5C]"
: "focus-within:outline focus-within:outline-1 focus-within:-outline-offset-1 focus-within:outline-[#F12B9E]/60 lg:outline lg:outline-1 lg:-outline-offset-1 lg:outline-white/10 lg:focus-within:outline-[#F12B9E]/60";
return (
<div data-component="login-dialog" className="flex flex-col gap-1.5 self-stretch">
<label className="flex flex-col gap-2">
<span className="sr-only text-sm leading-5 font-semibold text-[#E0DDE8] lg:not-sr-only">{label}</span>
<span className={`flex h-[50px] items-center gap-2 rounded-xl bg-white/7 px-3.5 lg:h-12 ${outlineClass}`}>
{icon}
<input
type={secret && !visible ? "password" : "text"}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
maxLength={CREDENTIAL_MAX_LEN}
autoComplete={autoComplete}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
className={`min-w-0 flex-1 bg-transparent text-sm leading-5 text-white outline-none placeholder:text-[#7A738A] ${placeholderClass}`}
/>
{secret ? (
<button
type="button"
aria-label={visible ? "隐藏密码" : "显示密码"}
onClick={() => setVisible((current) => !current)}
className="cursor-pointer flex size-5 shrink-0 items-center justify-center"
>
<svg viewBox="0 0 20 16" className="h-[13.33px] w-[17.5px]" aria-hidden="true">
<path
d="M1 8s3.3-6 9-6 9 6 9 6-3.3 6-9 6-9-6-9-6z"
fill="none"
stroke="#A8A1B8"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<circle cx="10" cy="8" r="2.6" fill="#A8A1B8" />
{visible ? null : <path d="M3 15L17 1" stroke="#A8A1B8" strokeWidth="1.6" strokeLinecap="round" />}
</svg>
</button>
) : null}
</span>
</label>
{error ? (
<p id={errorId} role="alert" className="text-xs leading-[18px] text-[#FF5C6E]">
{error}
</p>
) : null}
</div>
);
}
function UserIcon() {
return (
<svg viewBox="0 0 20 20" className="size-5 shrink-0" aria-hidden="true">
<circle cx="10" cy="7" r="3.25" fill="none" stroke="#A8A1B8" strokeWidth="1.5" />
<path d="M3.75 16.5c.9-2.9 3.3-4.5 6.25-4.5s5.35 1.6 6.25 4.5" fill="none" stroke="#A8A1B8" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function LockIcon() {
return (
<svg viewBox="0 0 20 20" className="size-5 shrink-0" aria-hidden="true">
<rect x="4" y="8.5" width="12" height="8.5" rx="2" fill="none" stroke="#A8A1B8" strokeWidth="1.5" />
<path d="M7 8.5V6.5a3 3 0 0 1 6 0v2" fill="none" stroke="#A8A1B8" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
@@ -0,0 +1,212 @@
"use client";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
import { EmptyState } from "@/components/common/empty-state";
import { Pagination } from "@/components/common/pagination";
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 { H5VideoCard, VideoCard } from "@/components/video/video-card";
type CollectionPageProps<T extends string, S extends string> = {
title: string;
subtitle: string;
breadcrumb: string;
types: readonly T[];
mobileTypes?: readonly T[];
scopes: readonly S[];
defaultScope: S;
totalPages: number;
getItems: (type: T, scope: S, page: number) => ShortDramaItem[];
clearable?: boolean;
showMobileScopes?: boolean;
showPcScopes?: boolean;
showVideoScopes?: boolean;
showMobileEpisodes?: boolean;
emptyText: string;
};
type FilterPillsProps<T extends string> = {
options: readonly T[];
value: T;
onChange: (value: T) => void;
className?: string;
itemClassName: string;
};
function FilterPills<T extends string>({ options, value, onChange, className = "", itemClassName }: FilterPillsProps<T>) {
return (
<div className={`items-center ${className}`}>
{options.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === value}
onClick={() => onChange(option)}
className={`cursor-pointer flex items-center justify-center rounded-2xl transition-colors ${itemClassName} ${
option === value ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
}`}
>
{option}
</button>
))}
</div>
);
}
export function CollectionPage<T extends string, S extends string>({
title,
subtitle,
breadcrumb,
types,
mobileTypes = types,
scopes,
defaultScope,
totalPages,
getItems,
clearable = false,
showMobileScopes = true,
showPcScopes = true,
showVideoScopes = true,
showMobileEpisodes = true,
emptyText,
}: CollectionPageProps<T, S>) {
const router = useRouter();
const [type, setType] = useState<T>(types[0]);
const [scope, setScope] = useState<S>(defaultScope);
const [page, setPage] = useState(1);
const [cleared, setCleared] = useState(false);
const topRef = useRef<HTMLDivElement>(null);
const items = cleared ? [] : getItems(type, scope, page);
const isVideo = type === "视频";
const PcCard = isVideo ? VideoCard : ShortDramaCard;
const MobileCard = isVideo ? H5VideoCard : H5ShortDramaCard;
const changeType = (next: T) => {
setType(next);
setPage(1);
};
const changeScope = (next: S) => {
setScope(next);
setPage(1);
};
const changePage = (next: number) => {
setPage(next);
topRef.current?.scrollIntoView({ behavior: "smooth" });
};
const goBack = () => {
if (window.history.length > 1) router.back();
else router.push("/profile");
};
return (
<main data-component="collection-page" className="flex-1 bg-[#07060D] lg:bg-[#070611]">
<div className="relative flex h-12 items-center justify-center px-4 lg:hidden">
<button type="button" aria-label="返回" onClick={goBack} className="cursor-pointer absolute left-4 size-6">
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
<path d="M15 5l-7 7 7 7" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<h1 className="text-base leading-[22px] font-bold text-white">{title}</h1>
{clearable ? (
<button
type="button"
onClick={() => setCleared(true)}
disabled={cleared}
className="cursor-pointer absolute right-4 text-xs leading-[18px] font-bold text-[#F12B9E] disabled:opacity-40"
>
清空
</button>
) : null}
</div>
<div
ref={topRef}
data-pc-scale
className="mx-auto flex w-full scroll-mt-24 flex-col gap-3 px-4 py-3 lg:w-[1700px] lg:gap-5 lg:px-0 lg:py-[30px] lg:[zoom:var(--page-scale,1)]"
>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: "个人中心", href: "/profile" },
{ name: breadcrumb },
]}
/>
<div className="hidden items-center lg:flex">
<div className="flex flex-1 flex-col gap-px">
<h2 className="text-[30px] leading-9 font-semibold text-white">{title}</h2>
<p className="text-sm leading-5 text-[#9790A6]">{subtitle}</p>
</div>
{clearable ? (
<button
type="button"
onClick={() => setCleared(true)}
disabled={cleared}
className="cursor-pointer h-9 w-24 rounded-[18px] bg-white/6 text-xs leading-[18px] font-semibold text-[#B5AEC1] transition-colors hover:text-white disabled:opacity-40"
>
清空记录
</button>
) : null}
</div>
<div className="flex flex-col gap-3 lg:gap-2.5">
<FilterPills
options={mobileTypes}
value={type}
onChange={changeType}
className="flex gap-3 lg:hidden"
itemClassName="h-8 px-4 text-sm leading-5"
/>
<FilterPills
options={types}
value={type}
onChange={changeType}
className="hidden gap-[9px] lg:flex"
itemClassName="h-8 w-[88px] px-4 text-xs leading-[18px]"
/>
{isVideo && !showVideoScopes ? null : (
<FilterPills
options={scopes}
value={scope}
onChange={changeScope}
className={`flex gap-2 lg:gap-[9px] ${showMobileScopes ? "" : "max-lg:hidden"} ${showPcScopes ? "" : "lg:hidden"}`}
itemClassName="h-6 px-3 text-xs leading-[18px] lg:h-8 lg:px-4"
/>
)}
</div>
{items.length === 0 ? (
<EmptyState
title={emptyText}
description="去首页发现更多好剧吧"
onReload={null}
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
/>
) : (
<>
<div
className={`hidden justify-between gap-x-5 gap-y-6 lg:grid ${
isVideo ? "grid-cols-[repeat(auto-fill,324px)]" : "grid-cols-[repeat(auto-fill,266px)]"
}`}
>
{items.map((item) => (
<PcCard key={item.id} item={item} />
))}
</div>
<div className="grid grid-cols-2 gap-x-[7px] gap-y-3 lg:hidden">
{items.map((item) => (
<MobileCard key={item.id} item={item} showEpisodes={showMobileEpisodes} />
))}
</div>
<Pagination page={page} total={totalPages} onChange={changePage} showJump={false} />
</>
)}
</div>
</main>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
/** 恒定线速度:px/s,图标多少只影响时长 */
const SCROLL_PX_PER_SEC = 36;
type AdIconMarqueeProps = {
children: ReactNode;
className?: string;
/** 段内间距,需与尾部 spacer 对齐以无缝衔接 */
gapClassName?: string;
/** 段尾 spacer 宽度,应等于 gap(gap-2→w-2,gap-3→w-3) */
spacerClassName?: string;
/** 内容签名:列表变化时重算时长 */
contentKey?: string;
};
/** Icon 广告从右向左无缝自动滚动(内容复制两段,translateX -50%) */
export function AdIconMarquee({
children,
className = "",
gapClassName = "gap-3",
spacerClassName = "w-3",
contentKey = "",
}: AdIconMarqueeProps) {
const segmentRef = useRef<HTMLDivElement>(null);
const [durationSec, setDurationSec] = useState(20);
useLayoutEffect(() => {
const el = segmentRef.current;
if (!el) return;
const update = () => {
const width = el.getBoundingClientRect().width;
if (width <= 0) return;
setDurationSec(Math.max(width / SCROLL_PX_PER_SEC, 8));
};
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, [contentKey]);
return (
<div
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` }}
>
<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>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
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} />;
}
+94
View File
@@ -0,0 +1,94 @@
import { DecryptedImage } from "@/components/common/decrypted-image";
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { adHref, adTrackProps, pickBannerAds } from "./pick";
type AdBannersProps = {
variant?: "pc" | "h5";
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
className?: string;
};
/** 公共 Banner 广告:对接 advitise.detailbannerList;无数据不渲染 */
export function AdBanners({
variant = "pc",
items,
advitise,
className = "",
}: AdBannersProps) {
const isH5 = variant === "h5";
const apiList = pickBannerAds(items, advitise);
if (!apiList.length) return null;
if (isH5) {
return (
<div
data-component="ad-banners"
data-variant="h5"
className={`flex flex-col gap-[18px] ${className}`}
>
{apiList.map((ad, index) => {
const href = adHref(ad.link, "#h5-download");
const external = Boolean(ad.link?.trim());
return (
<a
key={ad.id || `banner-${index}`}
href={href}
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className="block h-10 overflow-hidden rounded-xl"
{...adTrackProps(ad, {
slotKey: `content_banner_${index + 1}`,
slotName: `内容横幅第${index + 1}位`,
adType: "banner",
})}
>
<DecryptedImage
src={ad.images || null}
fallback={IMG_PLACEHOLDER}
alt={ad.title || "推广"}
className="h-full w-full object-cover"
/>
</a>
);
})}
</div>
);
}
return (
<div
data-component="ad-banners"
data-variant="pc"
className={`grid grid-cols-2 gap-5 ${className}`}
>
{apiList.map((ad, index) => {
const href = adHref(ad.link);
const external = Boolean(ad.link?.trim());
return (
<a
key={ad.id || `banner-${index}`}
href={href}
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
className="block h-[97px] overflow-hidden rounded-xl"
{...adTrackProps(ad, {
slotKey: `content_banner_${index + 1}`,
slotName: `内容横幅第${index + 1}位`,
adType: "banner",
})}
>
<DecryptedImage
src={ad.images || null}
fallback={IMG_PLACEHOLDER}
alt={ad.title || "推广"}
className="h-full w-full object-cover"
/>
</a>
);
})}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
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, adTrackProps, 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}
{...adTrackProps(ad, {
slotKey: `icon_grid_${index + 1}`,
slotName: `九宫格第${index + 1}位`,
adType: "icon",
})}
>
<DecryptedImage
src={ad.images || null}
fallback={IMG_PLACEHOLDER}
alt={ad.title || ""}
className={imgClass}
/>
{showLabels ? <span className={titleClass}>{ad.title || "广告"}</span> : null}
</a>
);
})}
</AdIconMarquee>
);
}
+4
View File
@@ -0,0 +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";
+56
View File
@@ -0,0 +1,56 @@
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
export function hasAdVisual(item: AdvertiseInfo) {
return Boolean(item.images?.trim() || item.title?.trim());
}
/** Icon:统一对接 advitise.nineGridList(可显式传 items 覆盖) */
export function pickIconAds(
items?: AdvertiseInfo[] | null,
advitise?: AdvitiseData | null,
_variant: "pc" | "h5" = "pc",
) {
void _variant;
const source = items ?? advitise?.nineGridList ?? [];
return source.filter(hasAdVisual);
}
/** Banner:detailbannerList(需有图) */
export function pickBannerAds(items?: AdvertiseInfo[] | null, advitise?: AdvitiseData | null) {
const source = items ?? advitise?.detailbannerList ?? [];
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;
return value.startsWith("http") || value.startsWith("/") ? value : `https://${value}`;
}
export function adContentKey(list: AdvertiseInfo[]) {
return list.map((item) => item.id || item.title || item.images || "").join("|") || "mock";
}
/** 外链广告埋点属性(对齐 hkdj / jha-1043 data-track-ad) */
export function adTrackProps(
ad: AdvertiseInfo,
options?: { slotKey?: string; slotName?: string; adType?: string },
) {
const adId = String(ad.advertiseCode || ad.id || "").trim();
if (!adId) return {};
return {
"data-track-ad": "external" as const,
"data-ad-id": adId,
"data-ad-clickable": "1",
"data-ad-type": options?.adType || "banner",
"data-ad-slot-key": options?.slotKey || ad.advertiseLocationCode || undefined,
"data-ad-slot-name": options?.slotName || ad.advertiseLocationName || ad.title || undefined,
"data-creative-id": String(ad.id || ""),
};
}
+43
View File
@@ -0,0 +1,43 @@
import { getAdvitiseCached } from "@/lib/api/home";
import { AdBanners } from "./ad-banners";
import { AdIcons } from "./ad-icons";
import { FriendLinks, H5FriendLinks } from "../friend-links";
/** 服务端:Icon 广告(home/base → advitise) */
export async function AdIconsSlot({
variant = "pc",
labels = true,
className = "",
}: {
variant?: "pc" | "h5";
labels?: boolean;
className?: string;
}) {
const advitise = await getAdvitiseCached();
return <AdIcons variant={variant} advitise={advitise} labels={labels} className={className} />;
}
/** 服务端:Banner 广告 */
export async function AdBannersSlot({
variant = "pc",
className = "",
}: {
variant?: "pc" | "h5";
className?: string;
}) {
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} />;
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { useRouter } from "next/navigation";
export function BackButton({ fallback = "/", className = "" }: { fallback?: string; className?: string }) {
const router = useRouter();
const goBack = () => {
if (window.history.length > 1) router.back();
else router.push(fallback);
};
return (
<button data-component="back-button" type="button" aria-label="返回" onClick={goBack} className={`cursor-pointer size-6 ${className}`}>
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
<path d="M15 5l-7 7 7 7" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import Link from "next/link";
import { useLayoutEffect, useRef, useState } from "react";
import { tagPageHref, type TagContentKind } from "@/components/tag/href";
import { cardTagCanLink, normalizeCardTags, type CardTagItem } from "./pick-card-tags";
/**
* 卡片左下角标签:按容器宽度尽量完整展示;
* 多个放不下时只显示 1 个(可截断),避免「激… · 爆… · 仙…」。
* 跳转优先 slug,无 slug 采用 id。
*
* 注意:空态与有标签态必须使用相同根节点(div),避免 SSR/水合 span↔div 不一致。
*/
export function CardTags({
tags,
kind,
className = "",
}: {
tags?: Array<CardTagItem | string> | string | null;
/** 短剧 / 视频标签库,决定跳转后查哪类内容 */
kind?: TagContentKind;
className?: string;
}) {
const list = normalizeCardTags(tags);
const listKey = list.map((tag) => `${tag.slug ?? ""}:${tag.id ?? ""}:${tag.name}`).join("\0");
const containerRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(() => Math.max(1, list.length ? 1 : 0));
const linkKind = kind ?? "playlet";
useLayoutEffect(() => {
const container = containerRef.current;
const measure = measureRef.current;
if (!list.length) {
setVisibleCount(0);
return;
}
if (!container || !measure || list.length <= 1) {
setVisibleCount(1);
return;
}
const fit = () => {
const maxWidth = container.clientWidth;
if (maxWidth <= 0) {
setVisibleCount(1);
return;
}
const items = measure.querySelectorAll<HTMLElement>("[data-measure-tag]");
if (!items.length) {
setVisibleCount(1);
return;
}
const origin = items[0].offsetLeft;
let count = 0;
for (let i = 0; i < items.length; i++) {
const right = items[i].offsetLeft + items[i].offsetWidth - origin;
if (right > maxWidth + 0.5) break;
count = i + 1;
}
setVisibleCount(Math.max(1, count));
};
fit();
const observer = new ResizeObserver(fit);
observer.observe(container);
return () => observer.disconnect();
}, [listKey, list.length]);
const visible = list.slice(0, Math.max(visibleCount, list.length ? 1 : 0));
const single = visible.length === 1;
return (
<div className={`relative min-w-0 flex-1 ${className}`}>
{list.length ? (
<>
<div
ref={measureRef}
className="pointer-events-none absolute top-0 left-0 flex items-center gap-1 whitespace-nowrap opacity-0"
aria-hidden="true"
>
{list.map((tag, index) => (
<span key={`${tag.slug ?? tag.id ?? tag.name}-${index}`} data-measure-tag className="flex items-center gap-1">
{index > 0 ? <span className="text-current/40">·</span> : null}
<span>{tag.name}</span>
</span>
))}
</div>
<div ref={containerRef} className="flex min-w-0 items-center gap-1 overflow-hidden">
{visible.map((tag, index) => (
<span
key={`${tag.slug ?? tag.id ?? tag.name}-${index}`}
className={`flex items-center gap-1 ${single ? "min-w-0" : "shrink-0"}`}
>
{index > 0 ? <span className="shrink-0 text-current/40">·</span> : null}
{cardTagCanLink(tag) ? (
<Link
href={tagPageHref(tag, linkKind)}
className={`transition-colors hover:text-[#F12B9E] ${
single ? "min-w-0 truncate" : "whitespace-nowrap"
}`}
>
{tag.name}
</Link>
) : (
<span className={single ? "min-w-0 truncate" : "whitespace-nowrap"}>{tag.name}</span>
)}
</span>
))}
</div>
</>
) : null}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
/** 服务端安全导出:纯函数与 Client 组件拆分,避免 pickCardTags 被标成 client */
export {
CARD_TAG_MAX,
cardTagCanLink,
normalizeCardTags,
pickCardTags,
pickPlayletCardTags,
pickVideoCardTags,
type CardTagItem,
} from "./pick-card-tags";
export { CardTags } from "./card-tags-client";
+27
View File
@@ -0,0 +1,27 @@
type ChannelHeaderProps = {
eyebrow?: string;
title: string;
subtitle?: string;
};
/** PC 标题:与 H5 同页时仅 H5 使用 h1,避免重复;传空字符串可隐藏副标题 */
export function ChannelHeader({ eyebrow = "CHANNEL", title, subtitle = "高能反转,短集连看" }: ChannelHeaderProps) {
return (
<header data-component="channel-header" className="flex flex-col gap-[7px]">
<p className="text-xs leading-[18px] font-semibold text-[#F12B9E]">{eyebrow}</p>
<p className="text-[34px] leading-10 font-semibold text-white">{title}</p>
{subtitle ? <p className="text-xs leading-[18px] text-[#9994AD]">{subtitle}</p> : null}
</header>
);
}
/** H5 频道页唯一 h1(移动优先);传空字符串可隐藏副标题 */
export function H5ChannelHeader({ eyebrow = "CHANNEL", title, subtitle = "高能反转,短集连看" }: ChannelHeaderProps) {
return (
<header className="flex flex-col gap-1.5">
<p className="text-xs leading-[18px] font-semibold text-[#F12B9E]">{eyebrow}</p>
<h1 className="text-lg leading-6 font-semibold text-white">{title}</h1>
{subtitle ? <p className="text-xs leading-[18px] text-[#9994AD]">{subtitle}</p> : null}
</header>
);
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { useSyncExternalStore, type ReactNode } from "react";
const subscribe = () => () => {};
export function ClientOnly({ children, fallback = null }: { children: ReactNode; fallback?: ReactNode }) {
const mounted = useSyncExternalStore(
subscribe,
() => true,
() => false,
);
return mounted ? children : fallback;
}
@@ -0,0 +1,284 @@
"use client";
import { useState, type FormEvent } from "react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { useMockUser } from "@/components/profile/use-mock-user";
import type { CommentInfo } from "@/lib/api/types";
import { COMMENT_MAX_LENGTH, COMMENT_SELF_NAME, COMMENT_SORTS, formatCommentTime, type CommentTarget } from "./data";
import { CommentReplyPreview } from "./comment-reply-preview";
import { HeartIcon } from "./heart-icon";
import { InlineReplyBox } from "./inline-reply-box";
import { useComments } from "./use-comments";
const fallbackAvatar = "/images/default_avatar.svg";
const panelStyles = {
detail: {
section: "rounded-2xl bg-[rgba(24,23,46,0.88)] outline-[#383657]",
item: "py-4",
more: "mt-4 h-[43px] rounded-[7px]",
},
play: {
section: "rounded-xl bg-[rgba(19,15,22,0.91)] outline-[rgba(199,145,89,0.7)]",
item: "py-3",
more: "mt-3 h-8 rounded-lg",
},
};
type ReplyTarget = {
id: number;
userName: string;
};
function CommentComposer({
onSubmit,
posting,
error,
}: {
onSubmit: (content: string) => Promise<boolean>;
posting: boolean;
error: string | null;
}) {
const { user } = useMockUser();
const avatar = user?.avatar || fallbackAvatar;
const displayName = user?.name || COMMENT_SELF_NAME;
const [text, setText] = useState("");
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const content = text.trim();
if (!content || posting) return;
const ok = await onSubmit(content);
if (ok) setText("");
};
return (
<div className="mt-2 flex flex-col gap-2">
<form onSubmit={submit} className="flex h-[76px] items-center gap-4 rounded-[10px] bg-[#211F38] px-4 py-3">
<DecryptedImage
src={avatar}
fallback={fallbackAvatar}
alt=""
className="size-9 shrink-0 rounded-full object-cover"
/>
<label className="flex h-[52px] min-w-0 flex-1 items-center gap-3 rounded-lg bg-[#110F1F] px-3 py-2.5 outline outline-1 -outline-offset-1 outline-[#383657] focus-within:outline-[#F12B9E]">
<input
value={text}
maxLength={COMMENT_MAX_LENGTH}
disabled={posting}
onChange={(event) => setText(event.target.value)}
placeholder="说说你对这部剧的看法…"
aria-label="评论内容"
className="min-w-0 flex-1 bg-transparent text-xs leading-[18px] text-white outline-none placeholder:text-[#948CA8] disabled:opacity-60"
/>
<span className="flex shrink-0 gap-2 text-xs leading-[18px]">
<span className="text-[#F12B9E]">已登录</span>
<span className="text-[#948CA8]">{displayName}</span>
<span className="text-[#736E87]">
{text.length} / {COMMENT_MAX_LENGTH}
</span>
</span>
</label>
<button
type="submit"
disabled={!text.trim() || posting}
className="cursor-pointer h-[52px] w-[120px] shrink-0 rounded-[10px] bg-[#F12B9E] text-xs leading-[18px] font-semibold text-white transition-colors enabled:hover:bg-[#F23DA8] disabled:opacity-60"
>
{posting ? "发布中…" : "发表评论"}
</button>
</form>
{error && !posting ? <p className="text-xs leading-[18px] text-[#FF8FA8]">{error}</p> : null}
</div>
);
}
/** PC 评论面板(详情 / 播放页)— GET /api/app/comment/list */
export function CommentPanel({
variant = "detail",
target,
}: {
variant?: keyof typeof panelStyles;
target: CommentTarget | null;
}) {
const {
sort,
setSort,
comments,
total,
hasMore,
loading,
loadingMore,
error,
loadMore,
toggleLike,
post,
posting,
postError,
reload,
} = useComments({ target });
const styles = panelStyles[variant];
const [replyTo, setReplyTo] = useState<ReplyTarget | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
const startReply = (comment: CommentInfo) => {
const id = Number(comment.id);
if (!Number.isInteger(id) || id < 1) return;
setReplyTo({ id, userName: (comment.userName || "").trim() || "用户" });
};
const toggleExpanded = (commentId: number) => {
setExpandedIds((current) => {
const next = new Set(current);
if (next.has(commentId)) next.delete(commentId);
else next.add(commentId);
return next;
});
};
const submitRoot = async (content: string) => post(content);
const submitReply = async (content: string) => {
if (!replyTo) return false;
const ok = await post(content, { parentsId: replyTo.id });
if (ok) setReplyTo(null);
return ok;
};
return (
<section
data-component="comment-panel"
className={`flex w-[1196px] shrink-0 flex-col p-6 outline outline-1 -outline-offset-1 ${styles.section}`}
>
<div className="flex h-10 items-center justify-between">
<h2 className="text-[22px] leading-[30px] text-white">评论 ({total})</h2>
<div className="flex h-8 items-center gap-3 rounded-[7px] bg-[#110F1F] px-2 text-xs leading-[18px]">
{COMMENT_SORTS.map((item) => (
<button
key={item}
type="button"
aria-pressed={sort === item}
onClick={() => setSort(item)}
className={`cursor-pointer ${sort === item ? "text-white" : "text-[#948CA8] hover:text-white"}`}
>
{item}
</button>
))}
</div>
</div>
<CommentComposer
onSubmit={submitRoot}
posting={posting && !replyTo}
error={!replyTo ? postError : null}
/>
{loading ? (
<p className="py-8 text-center text-sm text-[#948CA8]">评论加载中…</p>
) : error ? (
<div className="flex flex-col items-center gap-3 py-8">
<p className="text-sm text-[#948CA8]">{error}</p>
<button
type="button"
onClick={reload}
className="cursor-pointer rounded-lg bg-[#211F38] px-4 py-2 text-xs text-white hover:opacity-90"
>
重试
</button>
</div>
) : comments.length === 0 ? (
<p className="py-8 text-center text-sm text-[#948CA8]">暂无评论</p>
) : (
<ul>
{comments.map((comment) => {
const liked = Boolean(comment.isLike);
const commentId = Number(comment.id) || 0;
const expanded = expandedIds.has(commentId);
const replies = comment.subComment ?? [];
const isReplying = replyTo?.id === commentId;
return (
<li key={comment.id} className={`flex gap-4 border-b border-[#2E2B47] ${styles.item}`}>
<DecryptedImage
src={comment.avatar}
fallback="/images/default_avatar.svg"
alt=""
className="size-12 shrink-0 rounded-full object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<p className="text-sm leading-[21px] text-[#948CA8]">{comment.userName}</p>
<p className="text-base leading-6 text-white">{comment.text}</p>
<div className="flex items-center gap-2 text-xs leading-[18px] text-[#948CA8]">
<span className="font-semibold">{formatCommentTime(comment.createdAt)}</span>
<button
type="button"
aria-label={liked ? "取消点赞" : "点赞"}
aria-pressed={liked}
onClick={() => void toggleLike(comment)}
className="cursor-pointer flex items-center gap-2 font-semibold"
>
<HeartIcon filled={liked} />
{comment.countLike ?? 0}
</button>
<button
type="button"
onClick={() => (isReplying ? setReplyTo(null) : startReply(comment))}
className={`cursor-pointer hover:text-white ${isReplying ? "text-[#F12B9E]" : ""}`}
>
回复
</button>
</div>
{isReplying && replyTo ? (
<InlineReplyBox
userName={replyTo.userName}
posting={posting}
error={postError}
onSubmit={submitReply}
onCancel={() => setReplyTo(null)}
/>
) : null}
{expanded && replies.length > 1 ? (
<div className="flex flex-col gap-2 rounded-lg bg-[#211F38] px-3 py-2.5 text-xs leading-[18px] text-[#EBE8F5]">
{replies.map((reply) => (
<p key={reply.id}>
{reply.replyUserName
? `${reply.userName || ""} 回复 ${reply.replyUserName}:${reply.text || ""}`
: `${reply.userName || ""}:${reply.text || ""}`}
</p>
))}
<button
type="button"
onClick={() => toggleExpanded(commentId)}
className="cursor-pointer self-start text-[#F12B9E]"
>
收起回复
</button>
</div>
) : (
<CommentReplyPreview
comment={comment}
className="flex flex-col gap-1.5 rounded-lg bg-[#211F38] px-3 py-2.5 text-xs leading-[18px]"
textClassName="text-[#EBE8F5]"
onMore={replies.length > 1 ? () => toggleExpanded(commentId) : undefined}
/>
)}
</div>
</li>
);
})}
</ul>
)}
{!loading && !error && comments.length > 0 ? (
<button
type="button"
disabled={!hasMore || loadingMore}
onClick={loadMore}
className={`cursor-pointer ${styles.more} bg-[#211F38] text-xs leading-[18px] text-[#948CA8] enabled:hover:text-white disabled:opacity-60`}
>
{loadingMore ? "加载中…" : hasMore ? "加载更多" : "没有更多了"}
</button>
) : null}
</section>
);
}
@@ -0,0 +1,35 @@
import type { CommentInfo } from "@/lib/api/types";
/** 直接展示接口 subComment / commentCount,不做字段改名 */
export function CommentReplyPreview({
comment,
className = "",
textClassName = "",
onMore,
}: {
comment: CommentInfo;
className?: string;
textClassName?: string;
onMore?: () => void;
}) {
const first = comment.subComment?.[0];
const more = Number(comment.commentCount) || comment.subComment?.length || 0;
if (!first && more < 1) return null;
const replyLabel = first
? first.replyUserName
? `${first.userName || ""} 回复 ${first.replyUserName}:${first.text || ""}`
: `${first.userName || ""}:${first.text || ""}`
: `共 ${more} 条回复`;
return (
<div data-component="comment-reply-preview" className={className}>
<p className={textClassName}>{replyLabel}</p>
{more > 0 && onMore ? (
<button type="button" onClick={onMore} className="cursor-pointer self-start text-[#F12B9E]">
查看更多 {more} 条回复 ›
</button>
) : null}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { COMMENT_OBJECT_TYPE, type CommentInfo, type CommentObjectType } from "@/lib/api/types";
export type { CommentInfo };
export type CommentTarget = {
objectId: number;
objectType: CommentObjectType;
};
export const COMMENT_SORTS = ["热门", "最新"] as const;
export type CommentSort = (typeof COMMENT_SORTS)[number];
export const COMMENT_SELF_NAME = "剧迷1024";
export const COMMENT_MAX_LENGTH = 200;
export const COMMENT_PAGE_SIZE = 6;
/** 列表请求超时,避免上游卡住拖住 UI */
export const COMMENT_FETCH_TIMEOUT_MS = 8000;
export function sortToOrder(sort: CommentSort): 1 | 2 {
return sort === "热门" ? 1 : 2;
}
/** 过滤无效评论,保留接口原字段 */
export function filterCommentList(list: CommentInfo[] | null | undefined): CommentInfo[] {
return (list ?? []).filter((item) => {
const id = Number(item?.id);
return Number.isInteger(id) && id > 0;
});
}
/** 本地乐观插入用,字段名与 CommentInfo 一致 */
export function createSelfComment(
text: string,
options?: { userName?: string; avatar?: string },
): CommentInfo {
return {
id: -Date.now(),
userName: (options?.userName || "").trim() || COMMENT_SELF_NAME,
avatar: (options?.avatar || "").trim() || "/images/default_avatar.svg",
text,
createdAt: new Date().toISOString(),
countLike: 0,
isLike: false,
commentCount: 0,
subComment: [],
};
}
export function formatCommentCount(value: number) {
return value >= 10000 ? `${(value / 10000).toFixed(1)}w` : String(value);
}
/** 仅做展示格式化,不改字段名;空则回退原串 */
export function formatCommentTime(value?: string | null) {
const raw = (value || "").trim();
if (!raw) return "";
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return raw;
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
export function commentTarget(
objectId: number | string | null | undefined,
objectType: CommentObjectType,
): CommentTarget | null {
const id = Number(objectId);
if (!Number.isInteger(id) || id < 1) return null;
return { objectId: id, objectType };
}
export function playletCommentTarget(playletId: number | string | null | undefined) {
return commentTarget(playletId, COMMENT_OBJECT_TYPE.playlet);
}
export function playletChapterCommentTarget(chapterId: number | string | null | undefined) {
return commentTarget(chapterId, COMMENT_OBJECT_TYPE.playletChapter);
}
export function videoCommentTarget(videoId: number | string | null | undefined) {
return commentTarget(videoId, COMMENT_OBJECT_TYPE.video);
}
@@ -0,0 +1,204 @@
"use client";
import { useState, type FormEvent, type ReactNode } from "react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { COMMENT_MAX_LENGTH, COMMENT_SORTS, formatCommentTime, type CommentTarget } from "./data";
import { CommentReplyPreview } from "./comment-reply-preview";
import { HeartIcon } from "./heart-icon";
import { InlineReplyBox } from "./inline-reply-box";
import { useComments } from "./use-comments";
function SheetHead({ title, onClose, children }: { title: ReactNode; onClose: () => void; children?: ReactNode }) {
return (
<div className="flex items-center gap-3">
<button
type="button"
aria-label="收起"
onClick={onClose}
className="cursor-pointer flex size-6 items-center justify-center text-white"
>
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
<h2 className="min-w-0 flex-1 text-center text-base leading-[22px] font-medium text-white">{title}</h2>
{children ?? <span className="size-6" />}
</div>
);
}
/** H5 短剧播放页评论底部弹层 */
export function H5CommentSheet({ onClose, target }: { onClose: () => void; target: CommentTarget | null }) {
const {
sort,
setSort,
comments,
total,
hasMore,
loading,
loadingMore,
error,
loadMore,
toggleLike,
post,
posting,
postError,
reload,
} = useComments({ target });
const [text, setText] = useState("");
const [replyTo, setReplyTo] = useState<{ id: number; userName: string } | null>(null);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const content = text.trim();
if (!content || posting || replyTo) return;
const ok = await post(content);
if (ok) setText("");
};
const submitReply = async (content: string) => {
if (!replyTo) return false;
const ok = await post(content, { parentsId: replyTo.id });
if (ok) setReplyTo(null);
return ok;
};
return (
<div data-component="h5-comment-sheet" className="flex h-[70vh] flex-col">
<div className="px-4 pt-[18px] pb-3">
<SheetHead title={`评论 ${total}`} onClose={onClose}>
<div className="flex gap-3 text-xs leading-[18px]">
{COMMENT_SORTS.map((item) => (
<button
key={item}
type="button"
aria-pressed={sort === item}
onClick={() => setSort(item)}
className={`cursor-pointer ${sort === item ? "text-white" : "text-white/35"}`}
>
{item}
</button>
))}
</div>
</SheetHead>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4">
{loading ? (
<p className="py-10 text-center text-sm text-white/50">评论加载中…</p>
) : error ? (
<div className="flex flex-col items-center gap-3 py-10">
<p className="text-sm text-white/50">{error}</p>
<button
type="button"
onClick={reload}
className="cursor-pointer rounded-md bg-white/10 px-4 py-2 text-xs text-white"
>
重试
</button>
</div>
) : comments.length === 0 ? (
<p className="py-10 text-center text-sm text-white/50">暂无评论</p>
) : (
<ul>
{comments.map((comment) => {
const liked = Boolean(comment.isLike);
const commentId = Number(comment.id) || 0;
const isReplying = replyTo?.id === commentId;
return (
<li key={comment.id} className="flex gap-3 border-b border-white/5 py-3">
<DecryptedImage
src={comment.avatar}
fallback="/images/default_avatar.svg"
alt=""
className="size-8 shrink-0 rounded-full object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<p className="text-xs leading-[18px] text-white/50">{comment.userName}</p>
<p className="text-sm leading-5 text-white">{comment.text}</p>
<div className="flex items-center gap-3 text-[11px] leading-4 text-white/35">
<span>{formatCommentTime(comment.createdAt)}</span>
<button
type="button"
aria-pressed={liked}
onClick={() => void toggleLike(comment)}
className="cursor-pointer flex items-center gap-1"
>
<HeartIcon filled={liked} />
{comment.countLike ?? 0}
</button>
<button
type="button"
onClick={() => {
if (isReplying) {
setReplyTo(null);
return;
}
if (!Number.isInteger(commentId) || commentId < 1) return;
setReplyTo({
id: commentId,
userName: (comment.userName || "").trim() || "用户",
});
}}
className={`cursor-pointer ${isReplying ? "text-[#F12B9E]" : "hover:text-white"}`}
>
回复
</button>
</div>
{isReplying && replyTo ? (
<InlineReplyBox
compact
userName={replyTo.userName}
posting={posting}
error={postError}
onSubmit={submitReply}
onCancel={() => setReplyTo(null)}
/>
) : null}
<CommentReplyPreview
comment={comment}
className="rounded-md bg-white/5 px-2.5 py-2 text-xs leading-[18px]"
textClassName="text-white/70"
/>
</div>
</li>
);
})}
</ul>
)}
{!loading && !error && comments.length > 0 ? (
<button
type="button"
disabled={!hasMore || loadingMore}
onClick={loadMore}
className="cursor-pointer my-3 h-9 w-full rounded-md bg-white/5 text-xs text-white/50 disabled:opacity-50"
>
{loadingMore ? "加载中…" : hasMore ? "加载更多" : "没有更多了"}
</button>
) : null}
</div>
<div className="border-t border-white/5 px-4 py-2.5">
{!replyTo && postError ? <p className="mb-2 text-xs text-[#FF8FA8]">{postError}</p> : null}
<form onSubmit={submit} className="flex items-center gap-2.5">
<input
value={text}
maxLength={COMMENT_MAX_LENGTH}
disabled={posting || Boolean(replyTo)}
onChange={(event) => setText(event.target.value)}
placeholder="发表评论…"
aria-label="评论内容"
className="h-9 min-w-0 flex-1 rounded-full bg-white/5 px-4 text-sm text-white outline-none placeholder:text-white/35 disabled:opacity-60"
/>
<button
type="submit"
disabled={!text.trim() || posting || Boolean(replyTo)}
className="cursor-pointer h-9 shrink-0 rounded-full bg-[#F12B9E] px-4 text-sm text-white disabled:opacity-50"
>
{posting && !replyTo ? "发送中" : "发送"}
</button>
</form>
</div>
</div>
);
}
@@ -0,0 +1,209 @@
"use client";
import { useState, type FormEvent, type ReactNode } from "react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { COMMENT_MAX_LENGTH, COMMENT_SORTS, formatCommentCount, formatCommentTime } from "./data";
import { CommentReplyPreview } from "./comment-reply-preview";
import { InlineReplyBox } from "./inline-reply-box";
import type { UseCommentsResult } from "./use-comments";
type H5CommentsTabProps = {
/** 由页面 useComments 托管,便于 Tab 标题同步总数 */
state: UseCommentsResult;
footerExtra?: ReactNode;
};
/** H5 视频播放页「评论」Tab */
export function H5CommentsTab({ state, footerExtra }: H5CommentsTabProps) {
const {
sort,
setSort,
comments,
total,
hasMore,
loading,
loadingMore,
error,
loadMore,
toggleLike,
post,
posting,
postError,
reload,
} = state;
const [text, setText] = useState("");
const [replyTo, setReplyTo] = useState<{ id: number; userName: string } | null>(null);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const content = text.trim();
if (!content || posting || replyTo) return;
const ok = await post(content);
if (ok) setText("");
};
const submitReply = async (content: string) => {
if (!replyTo) return false;
const ok = await post(content, { parentsId: replyTo.id });
if (ok) setReplyTo(null);
return ok;
};
return (
<div data-component="h5-comments-tab" className="flex flex-col">
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex items-center justify-between">
<h2 className="text-base leading-[22px] font-medium text-white">{total}条评论</h2>
<div className="flex rounded-full bg-white/5 p-0.5">
{COMMENT_SORTS.map((item) => (
<button
key={item}
type="button"
aria-pressed={sort === item}
onClick={() => setSort(item)}
className={`cursor-pointer rounded-full px-2.5 py-1 text-[10px] leading-4 ${
sort === item ? "bg-white/10 text-white" : "text-white/65"
}`}
>
{item === "最新" ? "最新" : "最热"}
</button>
))}
</div>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-white/50">评论加载中…</p>
) : error ? (
<div className="flex flex-col items-center gap-3 py-8">
<p className="text-sm text-white/50">{error}</p>
<button
type="button"
onClick={reload}
className="cursor-pointer rounded-[3px] bg-white/10 px-4 py-2 text-xs text-white"
>
重试
</button>
</div>
) : comments.length === 0 ? (
<p className="py-8 text-center text-sm text-white/50">暂无评论</p>
) : (
<ul className="flex flex-col gap-5">
{comments.map((comment) => {
const liked = Boolean(comment.isLike);
const commentId = Number(comment.id) || 0;
const isReplying = replyTo?.id === commentId;
return (
<li key={comment.id} className="flex gap-3">
<DecryptedImage
src={comment.avatar}
fallback="/images/default_avatar.svg"
alt=""
className="size-10 shrink-0 rounded-full object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
<p className="text-sm leading-5 text-white/65">{comment.userName}</p>
<p className="text-sm leading-5 break-words text-white">{comment.text}</p>
<div className="flex items-center justify-between text-xs leading-[18px]">
<div className="flex items-center gap-3">
<span className="text-white/65">{formatCommentTime(comment.createdAt)}</span>
<button
type="button"
className={`cursor-pointer ${isReplying ? "text-[#F12B9E]" : "text-white"}`}
onClick={() => {
if (isReplying) {
setReplyTo(null);
return;
}
if (!Number.isInteger(commentId) || commentId < 1) return;
setReplyTo({
id: commentId,
userName: (comment.userName || "").trim() || "用户",
});
}}
>
回复
</button>
</div>
<button
type="button"
aria-pressed={liked}
onClick={() => void toggleLike(comment)}
className="cursor-pointer flex items-center gap-1 text-white/65"
>
<svg viewBox="0 0 18 18" className="size-[18px]" aria-hidden="true">
<path
d="M9 15.5c-.22 0-.45-.08-.62-.23C5.97 13.25 2.25 10.65 2.25 7.2c0-2.25 1.6-4.05 3.6-4.05 1.24 0 2.37.68 3.15 1.7.78-1.02 1.9-1.7 3.15-1.7 2 0 3.6 1.8 3.6 4.05 0 3.45-3.72 6.05-6.13 8.07-.17.15-.4.23-.62.23Z"
fill={liked ? "#F52C56" : "none"}
stroke="#F52C56"
strokeWidth="1.4"
/>
</svg>
{formatCommentCount(Number(comment.countLike) || 0)}
</button>
</div>
{isReplying && replyTo ? (
<InlineReplyBox
compact
userName={replyTo.userName}
posting={posting}
error={postError}
onSubmit={submitReply}
onCancel={() => setReplyTo(null)}
/>
) : null}
<CommentReplyPreview
comment={comment}
className="flex flex-col gap-1 rounded-[3px] bg-white/5 px-3 py-1.5 text-xs leading-[18px]"
textClassName="text-white/80"
/>
</div>
</li>
);
})}
</ul>
)}
{!loading && !error && comments.length > 0 ? (
<button
type="button"
disabled={!hasMore || loadingMore}
onClick={loadMore}
className="cursor-pointer h-9 w-full rounded-[3px] bg-white/5 text-xs text-white/50 disabled:opacity-50"
>
{loadingMore ? "加载中…" : hasMore ? "加载更多" : "没有更多了"}
</button>
) : null}
</div>
<div className="sticky bottom-0 z-10 bg-[#1A1A1A]">
{!replyTo && postError ? <p className="px-4 pt-2 text-xs text-[#FF8FA8]">{postError}</p> : null}
<form onSubmit={submit} className="flex h-[58px] items-center px-4 py-1.5">
<label className="flex h-full flex-1 items-center gap-2 rounded-[3px] bg-white/5 px-3 py-1.5">
<input
value={text}
maxLength={COMMENT_MAX_LENGTH}
disabled={posting || Boolean(replyTo)}
onChange={(event) => setText(event.target.value)}
placeholder={posting && !replyTo ? "发布中…" : "发表评论…"}
enterKeyHint="send"
className="min-w-0 flex-1 bg-transparent text-sm leading-5 text-white outline-none placeholder:text-white/35 disabled:opacity-60"
/>
<svg viewBox="0 0 24 24" className="size-6 shrink-0" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="rgba(255,255,255,0.65)" strokeWidth="1.5" />
<path
d="M8.5 14a4 4 0 0 0 7 0"
stroke="rgba(255,255,255,0.65)"
strokeWidth="1.5"
strokeLinecap="round"
/>
<circle cx="9" cy="10" r="1" fill="rgba(255,255,255,0.65)" />
<circle cx="15" cy="10" r="1" fill="rgba(255,255,255,0.65)" />
</svg>
</label>
</form>
</div>
{footerExtra}
</div>
);
}
@@ -0,0 +1,12 @@
export function HeartIcon({ filled, className = "size-[18px]" }: { filled: boolean; className?: string }) {
return (
<svg viewBox="0 0 14 14" fill="none" className={className} aria-hidden="true">
<path
d="M7 11.5c-.2 0-.4-.07-.55-.2C4.6 9.8 2.5 8.2 2.5 5.9 2.5 4.5 3.5 3.5 4.8 3.5c.9 0 1.7.5 2.2 1.2.5-.7 1.3-1.2 2.2-1.2 1.3 0 2.3 1 2.3 2.4 0 2.3-2.1 3.9-3.95 5.4-.15.13-.35.2-.55.2Z"
fill={filled ? "#F12B9E" : "none"}
stroke={filled ? "#F12B9E" : "rgba(255,255,255,0.55)"}
strokeWidth="1.5"
/>
</svg>
);
}
+25
View File
@@ -0,0 +1,25 @@
export {
COMMENT_FETCH_TIMEOUT_MS,
COMMENT_MAX_LENGTH,
COMMENT_PAGE_SIZE,
COMMENT_SELF_NAME,
COMMENT_SORTS,
commentTarget,
createSelfComment,
filterCommentList,
formatCommentCount,
formatCommentTime,
playletChapterCommentTarget,
playletCommentTarget,
sortToOrder,
videoCommentTarget,
type CommentSort,
type CommentTarget,
} from "./data";
export { CommentPanel } from "./comment-panel";
export { CommentReplyPreview } from "./comment-reply-preview";
export { H5CommentSheet } from "./h5-comment-sheet";
export { H5CommentsTab } from "./h5-comments-tab";
export { HeartIcon } from "./heart-icon";
export { useComments, type UseCommentsOptions, type UseCommentsResult } from "./use-comments";
export type { CommentInfo } from "@/lib/api/types";
@@ -0,0 +1,102 @@
"use client";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { COMMENT_MAX_LENGTH } from "./data";
/** 挂在单条评论下方的回复输入框 */
export function InlineReplyBox({
userName,
posting,
error,
onSubmit,
onCancel,
compact = false,
}: {
userName: string;
posting: boolean;
error?: string | null;
onSubmit: (content: string) => Promise<boolean>;
onCancel: () => void;
compact?: boolean;
}) {
const [text, setText] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const content = text.trim();
if (!content || posting) return;
const ok = await onSubmit(content);
if (ok) setText("");
};
return (
<div
data-component="inline-reply-box"
className={`flex flex-col gap-2 ${
compact
? "mt-1 rounded-md bg-white/[0.06] p-2.5 outline outline-1 -outline-offset-1 outline-white/15"
: "mt-2 rounded-xl bg-[#211F38] p-3 outline outline-1 -outline-offset-1 outline-[#4A4668]"
}`}
>
<div
className={`flex items-center justify-between leading-[18px] ${
compact ? "text-[11px] text-white/55" : "text-xs text-[#948CA8]"
}`}
>
<span>
回复 <span className="font-medium text-[#F12B9E]">@{userName}</span>
</span>
<button
type="button"
onClick={onCancel}
className="cursor-pointer text-white/50 transition-colors hover:text-white"
>
取消
</button>
</div>
<form onSubmit={submit} className="flex items-center gap-2">
<label
className={`flex min-w-0 flex-1 items-center gap-2 transition-[outline-color] ${
compact
? "h-10 rounded-[3px] bg-[#0F0F0F] px-3 outline outline-1 -outline-offset-1 outline-white/20 focus-within:outline-[#F12B9E]"
: "h-11 rounded-lg bg-[#110F1F] px-3 outline outline-1 -outline-offset-1 outline-[#5A5678] focus-within:outline-[#F12B9E]"
}`}
>
<input
ref={inputRef}
value={text}
maxLength={COMMENT_MAX_LENGTH}
disabled={posting}
onChange={(event) => setText(event.target.value)}
placeholder={`回复 @${userName}…`}
aria-label={`回复 ${userName}`}
className={`min-w-0 flex-1 bg-transparent text-white outline-none placeholder:text-white/40 disabled:opacity-60 ${
compact ? "text-sm leading-5" : "text-sm leading-5"
}`}
/>
<span className="shrink-0 text-[10px] leading-4 text-white/30">
{text.length}/{COMMENT_MAX_LENGTH}
</span>
</label>
<button
type="submit"
disabled={!text.trim() || posting}
className={`cursor-pointer shrink-0 font-medium text-white transition-colors enabled:hover:bg-[#F23DA8] disabled:opacity-45 ${
compact
? "h-10 rounded-[3px] bg-[#F12B9E] px-3.5 text-xs"
: "h-11 rounded-lg bg-[#F12B9E] px-4 text-sm"
}`}
>
{posting ? "发送中" : "发送"}
</button>
</form>
{error ? <p className="text-xs leading-[18px] text-[#FF8FA8]">{error}</p> : null}
</div>
);
}
@@ -0,0 +1,315 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "@heroui/react";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { getCommentList, sendComment, setCommentLike } from "@/lib/api/comment";
import type { CommentInfo } from "@/lib/api/types";
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import {
COMMENT_FETCH_TIMEOUT_MS,
COMMENT_PAGE_SIZE,
filterCommentList,
sortToOrder,
type CommentSort,
type CommentTarget,
} from "./data";
export type UseCommentsOptions = {
target: CommentTarget | null;
initialSort?: CommentSort;
pageSize?: number;
};
export type UseCommentsResult = {
sort: CommentSort;
setSort: (sort: CommentSort) => void;
comments: CommentInfo[];
total: number;
hasMore: boolean;
loading: boolean;
loadingMore: boolean;
posting: boolean;
error: string | null;
postError: string | null;
loadMore: () => void;
toggleLike: (comment: CommentInfo) => Promise<void>;
post: (text: string, options?: { parentsId?: number }) => Promise<boolean>;
reload: () => void;
};
function formatCommentError(err: unknown, fallback = "评论加载失败") {
if (err instanceof DOMException && err.name === "TimeoutError") return "加载超时,请重试";
if (err instanceof Error) {
if (/aborted|timeout|超时/i.test(err.message) || err.name === "AbortError") return "加载超时,请重试";
return err.message || fallback;
}
return fallback;
}
function abortAfter(ms: number) {
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
return AbortSignal.timeout(ms);
}
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
function mergeSignals(a: AbortSignal, b: AbortSignal) {
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.any === "function") {
return AbortSignal.any([a, b]);
}
const controller = new AbortController();
const onAbort = () => controller.abort();
if (a.aborted || b.aborted) {
controller.abort();
return controller.signal;
}
a.addEventListener("abort", onAbort, { once: true });
b.addEventListener("abort", onAbort, { once: true });
return controller.signal;
}
async function resolveAuthToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
function patchComment(list: CommentInfo[], id: number, patch: Partial<CommentInfo>): CommentInfo[] {
return list.map((item) => (Number(item.id) === id ? { ...item, ...patch } : item));
}
export function useComments({
target,
initialSort = "热门",
pageSize = COMMENT_PAGE_SIZE,
}: UseCommentsOptions): UseCommentsResult {
const [sort, setSort] = useState<CommentSort>(initialSort);
const [comments, setComments] = useState<CommentInfo[]>([]);
const [total, setTotal] = useState(0);
const [pageNum, setPageNum] = useState(1);
const [loading, setLoading] = useState(Boolean(target));
const [loadingMore, setLoadingMore] = useState(false);
const [posting, setPosting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [postError, setPostError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const fetchSeq = useRef(0);
const likingIds = useRef(new Set<number>());
const objectId = target?.objectId ?? 0;
const objectType = target?.objectType;
const enabled = Boolean(target && objectId > 0 && objectType != null);
useEffect(() => {
const controller = new AbortController();
let cancelled = false;
void (async () => {
await Promise.resolve();
if (cancelled) return;
if (!enabled || objectType == null) {
setComments([]);
setTotal(0);
setLoading(false);
setError(null);
return;
}
const seq = ++fetchSeq.current;
setLoading(true);
setError(null);
setPageNum(1);
likingIds.current.clear();
try {
const data = await getCommentList(
{
objectId,
objectType,
pageNum: 1,
pageSize,
sortOrder: sortToOrder(sort),
},
{
token: getAuthToken(),
signal: mergeSignals(controller.signal, abortAfter(COMMENT_FETCH_TIMEOUT_MS)),
},
);
if (cancelled || seq !== fetchSeq.current) return;
setComments(filterCommentList(data?.list));
setTotal(Number(data?.totalCount) || 0);
setPageNum(1);
} catch (err: unknown) {
if (cancelled || seq !== fetchSeq.current || controller.signal.aborted) return;
setComments([]);
setTotal(0);
setError(formatCommentError(err));
} finally {
if (!cancelled && seq === fetchSeq.current) setLoading(false);
}
})();
return () => {
cancelled = true;
controller.abort();
};
}, [enabled, objectId, objectType, sort, pageSize, reloadKey]);
const hasMore = comments.length < total;
const loadMore = useCallback(() => {
if (!enabled || objectType == null || loading || loadingMore || !hasMore) return;
const nextPage = pageNum + 1;
const controller = new AbortController();
setLoadingMore(true);
void getCommentList(
{
objectId,
objectType,
pageNum: nextPage,
pageSize,
sortOrder: sortToOrder(sort),
},
{
token: getAuthToken(),
signal: mergeSignals(controller.signal, abortAfter(COMMENT_FETCH_TIMEOUT_MS)),
},
)
.then((data) => {
const mapped = filterCommentList(data?.list);
setComments((current) => {
const seen = new Set(current.map((item) => Number(item.id)));
return [...current, ...mapped.filter((item) => !seen.has(Number(item.id)))];
});
setTotal(Number(data?.totalCount) || total);
setPageNum(nextPage);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
setError(formatCommentError(err, "加载更多失败"));
})
.finally(() => setLoadingMore(false));
}, [enabled, objectId, objectType, loading, loadingMore, hasMore, pageNum, pageSize, sort, total]);
const toggleLike = useCallback(async (comment: CommentInfo) => {
const commentId = Number(comment.id);
if (!Number.isInteger(commentId) || commentId < 1) return;
if (likingIds.current.has(commentId)) return;
const token = await resolveAuthToken();
if (!token) {
openLoginDialog();
return;
}
const nextIsLike = !Boolean(comment.isLike);
const nextCountLike = Math.max(0, (Number(comment.countLike) || 0) + (nextIsLike ? 1 : -1));
const snapshot = { isLike: comment.isLike, countLike: comment.countLike };
likingIds.current.add(commentId);
setComments((current) => patchComment(current, commentId, { isLike: nextIsLike, countLike: nextCountLike }));
try {
const result = await setCommentLike(
{ commentId, isLike: nextIsLike },
token,
abortAfter(COMMENT_FETCH_TIMEOUT_MS),
);
setComments((current) =>
patchComment(current, commentId, {
isLike: result?.isLike ?? nextIsLike,
countLike: typeof result?.countLike === "number" ? result.countLike : nextCountLike,
}),
);
} catch (err: unknown) {
setComments((current) => patchComment(current, commentId, snapshot));
handleUnauthorized(err);
} finally {
likingIds.current.delete(commentId);
}
}, []);
const post = useCallback(
async (text: string, options?: { parentsId?: number }) => {
const trimmed = text.trim();
if (!trimmed || posting) return false;
if (!enabled || objectType == null) {
setPostError("无法发布评论");
return false;
}
setPostError(null);
const token = await resolveAuthToken();
if (!token) {
openLoginDialog();
return false;
}
setPosting(true);
try {
await sendComment(
{
objectId,
objectType,
text: trimmed,
parentsId: options?.parentsId ?? 0,
},
token,
abortAfter(COMMENT_FETCH_TIMEOUT_MS),
);
// 审核通过前不写入列表(对齐 1045)
toast.success("发布成功,评论审核后才会展示");
return true;
} catch (err: unknown) {
if (handleUnauthorized(err)) {
setPostError("登录已过期或没有权限,请重新登录");
return false;
}
const message = formatCommentError(err, "发表失败,请稍后重试");
setPostError(message);
toast.danger(message);
return false;
} finally {
setPosting(false);
}
},
[enabled, objectId, objectType, posting],
);
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
return {
sort,
setSort,
comments,
total,
hasMore,
loading,
loadingMore,
posting,
error,
postError,
loadMore,
toggleLike,
post,
reload,
};
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useRef, useState, type ImgHTMLAttributes } from "react";
import { useDecryptedSrc } from "@/hooks/use-decrypted-src";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
type DecryptedImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
src?: string | null;
fallback?: string;
/** 首屏/关键图:立即解密,不懒加载 */
priority?: boolean;
/**
* 视口外不发起解密请求。默认:非 priority 时开启。
* 解密图无法靠原生 loading=lazy 省流量,必须在进视口后再 fetch。
*/
lazy?: boolean;
/** IntersectionObserver rootMargin,提前预取(默认上下 320px) */
lazyRootMargin?: string;
};
export function DecryptedImage({
src,
fallback = IMG_PLACEHOLDER,
priority = false,
lazy,
lazyRootMargin = "320px 0px",
alt = "",
onError,
loading,
decoding,
fetchPriority,
...props
}: DecryptedImageProps) {
const enableLazy = lazy ?? !priority;
const imgRef = useRef<HTMLImageElement>(null);
const [inView, setInView] = useState(!enableLazy);
useEffect(() => {
if (!enableLazy || inView) return;
const node = imgRef.current;
if (!node) return;
if (typeof IntersectionObserver === "undefined") {
setInView(true);
return;
}
const io = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting || entry.intersectionRatio > 0)) {
setInView(true);
io.disconnect();
}
},
{ root: null, rootMargin: lazyRootMargin, threshold: 0 },
);
io.observe(node);
return () => io.disconnect();
}, [enableLazy, inView, lazyRootMargin]);
const resolved = useDecryptedSrc(src, { priority, fallback, enabled: inView });
return (
<img
{...props}
ref={imgRef}
alt={alt}
src={resolved}
loading={loading ?? (priority ? "eager" : "lazy")}
decoding={decoding ?? (priority ? "sync" : "async")}
fetchPriority={fetchPriority ?? (priority ? "high" : "auto")}
onError={(event) => {
if (event.currentTarget.src !== fallback) {
event.currentTarget.src = fallback;
}
onError?.(event);
}}
/>
);
}
+85
View File
@@ -0,0 +1,85 @@
import Link from "next/link";
import { useId, type ReactNode } from "react";
import { ReloadButton } from "./reload-button";
const actionClass =
"flex h-10 w-[108px] items-center justify-center rounded-[22px] text-sm leading-5 font-semibold transition-opacity hover:opacity-90 lg:h-11 lg:w-[120px]";
type EmptyStateProps = {
title?: string;
description?: string;
showHome?: boolean;
/** undefined=默认刷新;null=不显示刷新;函数=自定义 */
onReload?: (() => void) | null;
actions?: ReactNode;
className?: string;
};
export function EmptyState({
title = "这里还没有内容",
description = "暂时没有找到相关短剧,换个分类或返回首页看看吧",
showHome = true,
onReload,
actions,
className = "px-4 pt-14 pb-12 lg:px-[110px] lg:pt-32 lg:pb-24",
}: EmptyStateProps) {
const showReload = onReload !== null;
const hasActions = actions !== undefined || showHome || showReload;
return (
<section
data-component="empty-state"
className={`flex flex-col items-center justify-center gap-4 bg-[radial-gradient(ellipse_50%_50%_at_50%_50%,#2E1233_0%,#07060E_100%)] text-center lg:gap-5 ${className}`}
>
<EmptyIllustration />
<div className="flex flex-col items-center gap-2 lg:gap-2.5">
<h2 className="text-lg leading-[26px] font-semibold text-white lg:text-2xl lg:leading-[30px]">{title}</h2>
{description ? (
<p className="max-w-[300px] text-xs leading-[18px] text-[#948CA8] lg:max-w-none lg:text-sm lg:leading-5">
{description}
</p>
) : null}
</div>
{hasActions ? (
<div className="flex gap-3">
{actions ?? (
<>
{showHome ? (
<Link href="/" className={`${actionClass} bg-[#F12B9E] text-white`}>
返回首页
</Link>
) : null}
{showReload ? <ReloadButton onReload={onReload ?? undefined} /> : null}
</>
)}
</div>
) : null}
</section>
);
}
function EmptyIllustration() {
const glowId = useId();
return (
<svg viewBox="0 0 220 180" className="h-36 w-44 lg:h-[180px] lg:w-[220px]" aria-hidden="true">
<defs>
<radialGradient id={glowId}>
<stop offset="0" stopColor="#F12B9E" stopOpacity="0.28" />
<stop offset="1" stopColor="#F12B9E" stopOpacity="0" />
</radialGradient>
</defs>
<circle cx="110" cy="84" r="80" fill={`url(#${glowId})`} />
<rect x="45" y="63" width="130" height="82" rx="17" fill="#211F38" stroke="#F12B9E" strokeOpacity="0.65" strokeWidth="2" />
<rect x="78" y="84" width="64" height="8" rx="4" fill="#F12B9E" fillOpacity="0.75" />
<rect x="72" y="106" width="76" height="6" rx="3" fill="#6B6385" />
<rect x="87" y="120" width="46" height="6" rx="3" fill="#4D476B" />
<g fill="#F12B9E" fillOpacity="0.8">
<circle cx="32" cy="44" r="4" />
<circle cx="186" cy="58" r="4" />
<circle cx="178" cy="138" r="4" />
</g>
</svg>
);
}
+65
View File
@@ -0,0 +1,65 @@
import Link from "next/link";
export function FilterChipLink({
label,
href,
active,
}: {
label: string;
href: string;
active?: boolean;
}) {
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`shrink-0 whitespace-nowrap rounded-[14px] px-3 py-1.5 text-xs leading-[18px] transition-colors ${
active ? "bg-[#F12B9E] text-white" : "bg-[#26223A] text-[#B7B2C7] hover:text-white"
}`}
>
{label}
</Link>
);
}
export function FilterOptionLink({
label,
href,
active,
}: {
label: string;
href: string;
active?: boolean;
}) {
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`shrink-0 whitespace-nowrap text-xs leading-[18px] ${active ? "text-[#F12B9E]" : "text-[#9994A3]/84 hover:text-white"}`}
>
{label}
</Link>
);
}
export function TabLink({
label,
href,
active,
}: {
label: string;
href: string;
active?: boolean;
}) {
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`rounded-2xl px-4 py-[7px] text-xs leading-[18px] transition-colors ${
active ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
}`}
>
{label}
</Link>
);
}
@@ -0,0 +1 @@
export { FriendLinksSlot, H5FriendLinksSlot } from "./ads/slots";
+75
View File
@@ -0,0 +1,75 @@
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>
);
}
@@ -0,0 +1,6 @@
import { AdIconsSlot } from "./ads/slots";
/** @deprecated 使用 AdIconsSlot */
export async function H5AdGridSlot({ className = "" }: { className?: string }) {
return <AdIconsSlot variant="h5" className={className} />;
}
+16
View File
@@ -0,0 +1,16 @@
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} />;
}
@@ -0,0 +1,6 @@
import { AdBannersSlot } from "./ads/slots";
/** @deprecated 使用 AdBannersSlot */
export async function H5BannerAdsSlot({ className = "" }: { className?: string }) {
return <AdBannersSlot variant="h5" className={className} />;
}
+16
View File
@@ -0,0 +1,16 @@
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} />;
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { BackButton } from "@/components/common/back-button";
type H5PageHeaderProps = {
title: string;
/** 无历史记录时的回退路径 */
fallback?: string;
/** 右侧插槽;默认占位与返回同宽,保证标题居中 */
right?: ReactNode;
className?: string;
};
/**
* H5 页顶栏:返回 + 居中标题,滚动时固定在视口顶部(仅 lg 以下)。
*/
export function H5PageHeader({ title, fallback = "/", right, className = "" }: H5PageHeaderProps) {
return (
<>
<header
data-component="h5-page-header"
className={`fixed inset-x-0 top-0 z-20 flex h-12 items-center bg-black px-4 lg:hidden ${className}`}
>
<BackButton fallback={fallback} />
<h1 className="min-w-0 flex-1 truncate text-center text-lg leading-6 font-semibold text-white">{title}</h1>
<div className="flex size-6 shrink-0 items-center justify-center">{right ?? null}</div>
</header>
{/* 占位,避免 fixed 顶栏遮挡内容 */}
<div className="h-12 shrink-0 lg:hidden" aria-hidden="true" />
</>
);
}
+62
View File
@@ -0,0 +1,62 @@
/** 媒体卡片统一 hover:粉光 + 封面微放大 + 标题变粉
* 注意:阴影在根节点,根节点不能 overflow-hidden,否则光晕会被裁切;圆角裁切只放封面容器。
*
* PC:标题 / 描述 / 标签 / 热度 / 集数用 media-card-* 字号 × --page-unscale,
* 抵消 design-frame zoom,窗口缩小时视觉大小不变(见 globals.css)。
*/
export const MEDIA_CARD_ROOT =
"group rounded-lg bg-[#110F1F] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]";
export const MEDIA_CARD_COVER =
"absolute inset-0 h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-105";
export const MEDIA_CARD_TITLE = "transition-colors duration-300 group-hover:text-[#F12B9E]";
/** PC 卡片标题:视觉 24px(抵消 page-scale);shrink-0 避免 flex+truncate 纵向裁切字形 */
export const MEDIA_CARD_TITLE_PC = `media-card-title block min-h-[1.2em] shrink-0 truncate font-medium text-white ${MEDIA_CARD_TITLE}`;
/** PC 卡片标题:视觉 20px(抵消 page-scale) */
export const MEDIA_CARD_TITLE_PC_MD = `media-card-title-md block min-h-[1.2em] shrink-0 truncate font-medium text-white ${MEDIA_CARD_TITLE}`;
/** PC 大卡标题:视觉 32px(抵消 page-scale) */
export const MEDIA_CARD_TITLE_PC_LG = "media-card-title-lg block min-h-[1.2em] shrink-0 truncate font-semibold text-[#F2F2FA]";
/** PC 大卡标题(可换行):视觉 32px */
export const MEDIA_CARD_TITLE_PC_LG_CLAMP = "media-card-title-lg shrink-0 line-clamp-2 font-semibold text-[#F2F2FA]";
/** H5 卡片标题:18px */
export const MEDIA_CARD_TITLE_H5 = `block min-h-[1.2em] shrink-0 truncate text-lg leading-6 font-medium text-white ${MEDIA_CARD_TITLE}`;
/** PC 封面角标「N集」:视觉 14px(抵消 page-scale) */
export const MEDIA_CARD_EPISODES = "media-card-episodes";
/** H5 封面角标「N集」 */
export const MEDIA_CARD_EPISODES_H5 = "text-xs leading-[18px]";
/** PC 描述:视觉 14px(抵消 page-scale) */
export const MEDIA_CARD_DESC = "media-card-desc block min-h-[1.2em] shrink-0 truncate";
/** PC 描述:视觉 16px(抵消 page-scale) */
export const MEDIA_CARD_DESC_MD = "media-card-desc-md shrink-0";
/** H5 描述:12px */
export const MEDIA_CARD_DESC_SM = "media-card-desc-sm block min-h-[1.2em] shrink-0 truncate";
/** PC 标签 / 热度:视觉 14px(抵消 page-scale) */
export const MEDIA_CARD_META = "media-card-meta shrink-0";
/** PC 标签 / 热度:视觉 16px(抵消 page-scale) */
export const MEDIA_CARD_META_MD = "media-card-desc-md";
/** H5 标签 / 热度:12px */
export const MEDIA_CARD_META_H5 = "media-card-meta-sm";
/** H5 标题+描述区 */
export const MEDIA_CARD_INFO_H5 = "flex min-w-0 flex-col gap-0.5 px-2 pt-1.5";
/** H5 标签热度行:紧凑、可截断标签 */
export const MEDIA_CARD_FOOTER_H5 = `flex min-w-0 items-center justify-between gap-1.5 px-2 pt-0.5 pb-1.5 ${MEDIA_CARD_META_H5}`;
/** @deprecated 保留空串兼容旧引用 */
export const MEDIA_CARD_CONTAINER = "";
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { Spinner } from "@heroui/react";
type PageLoadingProps = {
label?: string;
className?: string;
};
export function PageLoading({ label = "加载中…", className = "" }: PageLoadingProps) {
return (
<div data-component="page-loading"
role="status"
aria-live="polite"
className={`flex min-h-[240px] flex-col items-center justify-center gap-3 text-sm leading-5 text-white/55 ${className}`}
>
<Spinner size="lg" color="accent" className="text-[#F12B9E]" />
<span>{label}</span>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { navigatePageHref } from "./pagination-map";
type PaginationJumpProps = {
total: number;
onJump?: (page: number) => void;
/** 可序列化页码 → 地址,供 RSC 父级使用 */
hrefMap?: Record<number, string>;
};
export function PaginationJump({ total, onJump, hrefMap }: PaginationJumpProps) {
const [jump, setJump] = useState("");
const submitJump = () => {
const target = Number(jump);
if (!Number.isInteger(target) || target < 1 || target > total) return;
const href = hrefMap?.[target];
if (href) navigatePageHref(href);
else onJump?.(target);
setJump("");
};
return (
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
submitJump();
}}
>
<span className="text-sm leading-5 text-white/55">前往</span>
<input
type="number"
min={1}
max={total}
value={jump}
onChange={(event) => setJump(event.target.value)}
aria-label="页码"
className="h-8 w-16 rounded-2xl bg-[#211F38] text-center text-xs font-semibold text-[#948CA8]/90 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<span className="text-sm leading-5 text-white/55">页</span>
<button type="submit" className="cursor-pointer h-8 w-20 rounded-2xl bg-[#F12B9E] text-xs font-semibold text-white">
确定
</button>
</form>
);
}
+15
View File
@@ -0,0 +1,15 @@
/** 供列表页生成可序列化页码 → href(可在 RSC 中调用) */
export function buildPageHrefMap(total: number, hrefForPage: (page: number) => string): Record<number, string> {
const map: Record<number, string> = {};
for (let i = 1; i <= total; i++) map[i] = hrefForPage(i);
return map;
}
/**
* 分页硬跳转:避开 design-frame CSS zoom 下 Next 软导航静默失败。
* 仅在客户端调用。
*/
export function navigatePageHref(href: string) {
if (typeof window === "undefined") return;
window.location.assign(href);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import type { MouseEvent, ReactNode } from "react";
import { navigatePageHref } from "./pagination-map";
import { PaginationJump } from "./pagination-jump";
type PaginationBase = {
page: number;
total: number;
className?: string;
showJump?: boolean;
};
type PaginationCallbackProps = PaginationBase & {
onChange: (page: number) => void;
hrefMap?: never;
};
type PaginationLinkProps = PaginationBase & {
/** 页码 → href,须为可序列化对象(RSC 不可传函数给 Client) */
hrefMap: Record<number, string>;
onChange?: never;
};
export type PaginationProps = PaginationCallbackProps | PaginationLinkProps;
const itemClass = "flex h-8 min-w-9 items-center justify-center rounded-2xl px-2 text-xs leading-[18px]";
function pageItems(page: number, total: number): (number | "gap")[] {
if (total <= 7) return Array.from({ length: total }, (_, index) => index + 1);
if (page <= 3) return [1, 2, 3, "gap", total];
if (page >= total - 2) return [1, "gap", total - 2, total - 1, total];
return [1, "gap", page - 1, page, page + 1, "gap", total];
}
function Chevron({ direction }: { direction: "left" | "right" }) {
return (
<svg viewBox="0 0 14 14" fill="none" className={`size-3.5 ${direction === "left" ? "rotate-180" : ""}`} aria-hidden="true">
<path d="M5.5 4l3 3-3 3" stroke="#8F8F9F" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function PageControl({
disabled,
href,
onClick,
ariaLabel,
children,
}: {
disabled: boolean;
href?: string;
onClick?: () => void;
ariaLabel: string;
children: ReactNode;
}) {
const className = `${itemClass} bg-[#211F38] ${disabled ? "pointer-events-none opacity-40" : "cursor-pointer"}`;
if (href && !disabled) {
return (
<a
href={href}
aria-label={ariaLabel}
className={className}
onClick={(event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
navigatePageHref(href);
}}
>
{children}
</a>
);
}
return (
<button type="button" aria-label={ariaLabel} disabled={disabled} onClick={onClick} className={className}>
{children}
</button>
);
}
function PageNumber({
item,
current,
href,
onClick,
}: {
item: number;
current: number;
href?: string;
onClick?: () => void;
}) {
const active = item === current;
const className = `${itemClass} ${active ? "bg-[#F12B9E] font-semibold text-white" : "bg-[#211F38] text-[#948CA8] hover:text-white"} ${href || onClick ? "cursor-pointer" : ""}`;
if (href && !active) {
return (
<a
href={href}
className={className}
onClick={(event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
navigatePageHref(href);
}}
>
{item}
</a>
);
}
if (href && active) {
return (
<span aria-current="page" className={className}>
{item}
</span>
);
}
return (
<button type="button" aria-current={active ? "page" : undefined} onClick={onClick} className={className}>
{item}
</button>
);
}
export function Pagination({ page, total, className = "", showJump = true, ...rest }: PaginationProps) {
const hrefMap = "hrefMap" in rest ? rest.hrefMap : undefined;
const onChange = "onChange" in rest ? rest.onChange : undefined;
return (
<nav
data-component="pagination"
className={`relative z-20 flex flex-wrap items-center justify-center gap-2 ${className}`}
aria-label="分页"
>
<PageControl
disabled={page === 1}
href={hrefMap && page > 1 ? hrefMap[page - 1] : undefined}
onClick={onChange ? () => onChange(page - 1) : undefined}
ariaLabel="上一页"
>
<Chevron direction="left" />
</PageControl>
{pageItems(page, total).map((item, index) =>
item === "gap" ? (
<span key={`gap-${index}`} className="text-xs text-[#948CA8]">
…
</span>
) : (
<PageNumber
key={item}
item={item}
current={page}
href={hrefMap?.[item]}
onClick={onChange ? () => onChange(item) : undefined}
/>
),
)}
<PageControl
disabled={page === total}
href={hrefMap && page < total ? hrefMap[page + 1] : undefined}
onClick={onChange ? () => onChange(page + 1) : undefined}
ariaLabel="下一页"
>
<Chevron direction="right" />
</PageControl>
{showJump ? <PaginationJump total={total} onJump={onChange} hrefMap={hrefMap} /> : null}
</nav>
);
}
+185
View File
@@ -0,0 +1,185 @@
export const CARD_TAG_MAX = 3;
export type CardTagItem = {
/** 标签 ID;无 slug 时用于跳转 /tag/{kind}/{id} */
id?: number;
/** 标签 slug;有则优先跳转 /tag/{kind}/{slug} */
slug?: string;
name: string;
};
/**
* 按 index 对齐 ids / names / slugs;缺则空着,不自行补展示文案。
* 短剧:pickPlayletCardTags(item) → tagIds + tagNames + tagSlugs
* 视频:pickVideoCardTags(item) → tags + tagsName + tagSlugs(字段名与短剧不同)
*/
export function pickCardTags(
ids?: Array<number | string | null | undefined> | null,
names?: Array<string | null | undefined> | null,
extras?: Array<string[] | undefined | null>,
slugs?: Array<string | null | undefined> | null,
): CardTagItem[] {
const seen = new Set<string>();
const out: CardTagItem[] = [];
const idList = ids ?? [];
const nameList = names ?? [];
const slugList = slugs ?? [];
const len = Math.max(idList.length, nameList.length, slugList.length);
for (let i = 0; i < len; i++) {
const id = coerceTagId(idList[i]);
const slug = String(slugList[i] ?? "").trim() || undefined;
// 展示文案只用名称(或 slug);禁止把数字 id 当成标签名
const name = String(nameList[i] ?? "").trim() || slug || "";
if (!name) continue;
const key = slug ? `slug:${slug}` : id != null ? `id:${id}` : `name:${name}`;
if (seen.has(key) || seen.has(`name:${name}`)) continue;
seen.add(key);
seen.add(`name:${name}`);
out.push({ id, slug, name });
if (out.length >= CARD_TAG_MAX) return out;
}
for (const list of extras ?? []) {
for (const raw of list ?? []) {
const name = String(raw || "").trim();
if (!name || seen.has(`name:${name}`)) continue;
seen.add(`name:${name}`);
out.push({ name });
if (out.length >= CARD_TAG_MAX) return out;
}
}
return out;
}
/**
* 短剧列表/卡片标签:
* 仅用 tagIds + tagNames + tagSlugs(按 index 对齐);跳转优先 slug,无 slug 用 id。
* 缺字段则空着,不用 themeNames / anchor 等补位。
*/
export function pickPlayletCardTags(item: {
tagIds?: Array<number | string | null | undefined> | null;
tagNames?: Array<string | null | undefined> | null;
tagSlugs?: Array<string | null | undefined> | null;
}): CardTagItem[] {
return pickCardTags(item.tagIds, flattenTagNames(item.tagNames), undefined, flattenTagNames(item.tagSlugs));
}
function coerceTagId(raw: unknown): number | undefined {
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) return Math.floor(raw);
if (typeof raw === "string" && /^\d+$/.test(raw.trim())) {
const id = Number(raw.trim());
return id > 0 ? id : undefined;
}
return undefined;
}
function flattenTagNames(
list?: Array<string | null | undefined | Array<string | null | undefined>> | null,
): string[] {
if (!list?.length) return [];
const out: string[] = [];
for (const item of list) {
if (Array.isArray(item)) {
for (const nested of item) {
const name = String(nested ?? "").trim();
if (name) out.push(name);
}
continue;
}
const name = String(item ?? "").trim();
if (name) out.push(name);
}
return out;
}
function isVideoTagObject(
tag: unknown,
): tag is { id?: number; name?: string; tagName?: string; slug?: string } {
return tag != null && typeof tag === "object" && !Array.isArray(tag);
}
/**
* 视频列表/卡片标签(与短剧字段名不同,见 swagger video/list):
* - tags → 标签 id(短剧为 tagIds)
* - tagsName → 标签名称(短剧为 tagNames)
* - tagSlugs → 标签 slug(与短剧同名);跳转优先 slug,无 slug 用 id
* 详情态 tags 可能为对象数组(含 name / slug)。
*/
export function pickVideoCardTags(item: {
tags?: Array<
| number
| string
| { id?: number; name?: string; tagName?: string; slug?: string }
| Array<number | string | null | undefined>
| null
| undefined
> | null;
/** swagger:标签名称 tagsName */
tagsName?: Array<string | null | undefined | Array<string | null | undefined>> | null;
/** swagger:标签 slug tagSlugs */
tagSlugs?: Array<string | null | undefined | Array<string | null | undefined>> | null;
}): CardTagItem[] {
const rawTags = item.tags ?? [];
// 详情态:tags 为对象数组
if (rawTags.some(isVideoTagObject)) {
return normalizeCardTags(
rawTags.filter(isVideoTagObject).map((tag) => ({
id: tag.id,
slug: tag.slug,
name: String(tag.name || tag.tagName || "").trim(),
})),
);
}
const ids: number[] = [];
for (const tag of rawTags) {
if (Array.isArray(tag)) {
for (const nested of tag) {
const id = coerceTagId(nested);
if (id != null) ids.push(id);
}
continue;
}
const id = coerceTagId(tag);
if (id != null) ids.push(id);
}
return pickCardTags(ids, flattenTagNames(item.tagsName), undefined, flattenTagNames(item.tagSlugs));
}
export function normalizeCardTags(
tags?: Array<CardTagItem | string> | string | null,
): CardTagItem[] {
const list = Array.isArray(tags) ? tags : tags ? [tags] : [];
const out: CardTagItem[] = [];
const seen = new Set<string>();
for (const item of list) {
if (typeof item === "string") {
const name = item.trim();
if (!name || seen.has(`name:${name}`)) continue;
seen.add(`name:${name}`);
out.push({ name });
} else {
const name = String(item?.name || "").trim();
const slug = String(item?.slug || "").trim() || undefined;
const id =
typeof item?.id === "number" && Number.isFinite(item.id) && item.id > 0 ? item.id : undefined;
const label = name || slug || "";
if (!label) continue;
const key = slug ? `slug:${slug}` : id != null ? `id:${id}` : `name:${label}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ id, slug, name: label });
}
if (out.length >= CARD_TAG_MAX) break;
}
return out;
}
/** 可跳转:有 slug 或 id */
export function cardTagCanLink(tag: CardTagItem) {
return Boolean(tag.slug?.trim() || tag.id != null);
}
+38
View File
@@ -0,0 +1,38 @@
import { rankNumberFont } from "@/lib/fonts";
export function RankNumber({ rank, className = "text-[28px] leading-7" }: { rank?: number; className?: string }) {
if (rank === undefined) return null;
return (
<span
data-component="rank-number"
className={`${rankNumberFont.className} shrink-0 font-extrabold text-[#F12B9E] italic tabular-nums ${className}`}
>
{String(rank).padStart(2, "0")}
</span>
);
}
const rankBadgeBgs = [
"/images/rank_num1.png",
"/images/rank_num2.png",
"/images/rank_num3.png",
] as const;
const rankBadgeFallback = "/images/rank_num4.png";
export function CoverRankBadge({ rank, className = "" }: { rank?: number; className?: string }) {
if (rank === undefined) return null;
const bg = rankBadgeBgs[rank - 1] ?? rankBadgeFallback;
const textClass = rank <= 1 ? "text-xl leading-7 font-medium" : "text-lg leading-6 font-semibold";
return (
<span
data-component="cover-rank-badge"
className={`absolute bottom-0 left-0 flex h-[42px] w-[54px] items-center justify-center bg-left-bottom bg-no-repeat text-white tabular-nums ${textClass} ${className}`}
style={{ backgroundImage: `url(${bg})`, backgroundSize: "100% 100%" }}
>
{rank}
</span>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
const actionClass =
"flex h-10 w-[108px] items-center justify-center rounded-[22px] text-sm leading-5 font-semibold transition-opacity hover:opacity-90 lg:h-11 lg:w-[120px]";
export function ReloadButton({ onReload, className = "" }: { onReload?: () => void; className?: string }) {
return (
<button
type="button"
onClick={onReload ?? (() => window.location.reload())}
className={`cursor-pointer ${actionClass} bg-[#211F38] text-[#D1CCE0] outline outline-1 -outline-offset-1 outline-[#4D476B] ${className}`}
>
重新加载
</button>
);
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { Toast } from "@heroui/react";
/** 全局 Toast 宿主:深色主题 + 顶部居中(适配站点视觉) */
export function ToastHost() {
return <Toast.Provider placement="top" width={360} maxVisibleToasts={3} />;
}
@@ -0,0 +1,17 @@
import { connection } from "next/server";
import { getHomeBaseCached } from "@/lib/api/home";
import { HomeBaseDebug } from "./home-base-debug";
/** 调试插槽:拉取 home/base 并在浏览器控制台打印 */
export async function HomeBaseDebugSlot() {
await connection();
let data: Awaited<ReturnType<typeof getHomeBaseCached>> = {};
try {
data = await getHomeBaseCached();
} catch {
data = {};
}
return <HomeBaseDebug data={data} />;
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { useEffect } from "react";
import type { HomeBaseResp } from "@/lib/api/types";
/** 浏览器控制台打印 /api/app/home/base 返回数据(调试用) */
export function HomeBaseDebug({ data }: { data: HomeBaseResp }) {
useEffect(() => {
console.info("[home/base]", data);
}, [data]);
return null;
}
@@ -0,0 +1,17 @@
import { connection } from "next/server";
import { getHomeBaseCached } from "@/lib/api/home";
import { DownloadDialogHost } from "./download-dialog";
/** 服务端插槽:从 /api/app/home/base 拉取 config.appAddr(Suspense + connection 排除 prerender) */
export async function DownloadDialogSlot() {
await connection();
let appAddr: string | undefined;
try {
appAddr = (await getHomeBaseCached()).config?.appAddr;
} catch {
appAddr = undefined;
}
return <DownloadDialogHost appAddr={appAddr} />;
}
+280
View File
@@ -0,0 +1,280 @@
"use client";
import { QRCodeSVG } from "qrcode.react";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type MouseEvent } from "react";
type Platform = "iOS" | "Android";
/** 全站下载地址:仅使用 /api/app/home/base → config.appAddr */
let appDownloadAddr = "";
const addrListeners = new Set<() => void>();
function notifyAddr() {
addrListeners.forEach((listener) => listener());
}
export function setAppDownloadAddr(appAddr?: string | null) {
const next = appAddr?.trim() || "";
if (next === appDownloadAddr) return;
appDownloadAddr = next;
notifyAddr();
}
/** 当前下载链接(config.appAddr),无配置时为空字符串 */
export function getAppDownloadHref() {
return appDownloadAddr;
}
/** 兼容旧调用:iOS / Android 均指向同一 appAddr */
export const downloadLinks = {
get iOS() {
return appDownloadAddr;
},
get Android() {
return appDownloadAddr;
},
};
const features = [
{ title: "追剧更稳定", text: "App 内播放、切集和续看更稳定,适合长时间追剧。" },
{ title: "记录自动同步", text: "观看历史、收藏和追剧记录与 Web 账号同步。" },
{ title: "更新及时提醒", text: "关注的剧集更新后提醒,减少回访流失。" },
];
let dialogOpen = false;
const listeners = new Set<() => void>();
function setDialogOpen(open: boolean) {
dialogOpen = open;
listeners.forEach((listener) => listener());
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
const getSnapshot = () => dialogOpen;
const getServerSnapshot = () => false;
function detectPlatform(): Platform {
return /iPhone|iPad|iPod|Macintosh/i.test(navigator.userAgent) ? "iOS" : "Android";
}
function copyWithTextarea(text: string) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand("copy");
textarea.remove();
return copied;
}
function useCopy() {
const [copied, setCopied] = useState(false);
const timer = useRef<number>(undefined);
useEffect(() => () => window.clearTimeout(timer.current), []);
const copy = useCallback(async (text: string) => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
} catch {
if (!copyWithTextarea(text)) return;
}
setCopied(true);
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), 1500);
}, []);
return { copied, copy };
}
/** 订阅 appAddr,保证二维码 / 按钮随接口更新重渲染(SSR / hydrate 快照为空,避免 hydration mismatch) */
export function useAppDownloadHref() {
return useSyncExternalStore(
(listener) => {
addrListeners.add(listener);
return () => addrListeners.delete(listener);
},
getAppDownloadHref,
() => "",
);
}
/** 打开下载落地页:仅当有 config.appAddr */
export function openAppDownload() {
const href = getAppDownloadHref();
if (!href) return;
window.open(href, "_blank", "noopener,noreferrer");
}
/** 下载入口:直接打开 config.appAddr 落地页,不再弹窗 */
export function openDownloadDialog(event?: MouseEvent) {
event?.preventDefault();
openAppDownload();
}
/** 下载弹窗:使用 home/base → config.appAddr */
export function DownloadDialogHost({ appAddr }: { appAddr?: string | null }) {
const open = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const close = useCallback(() => setDialogOpen(false), []);
useEffect(() => {
setAppDownloadAddr(appAddr);
}, [appAddr]);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => event.key === "Escape" && close();
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = overflow;
window.removeEventListener("keydown", onKeyDown);
};
}, [open, close]);
if (!open) return null;
return (
<div
data-component="download-dialog-host"
className="fixed inset-0 z-[60] flex flex-col items-center overflow-y-auto bg-[#04030B]/82 px-4 py-10"
onClick={(event) => event.target === event.currentTarget && close()}
>
<DownloadPanel onClose={close} />
</div>
);
}
function DownloadPanel({ onClose }: { onClose: () => void }) {
const [platform, setPlatform] = useState<Platform>(detectPlatform);
const { copied, copy } = useCopy();
const link = useAppDownloadHref();
return (
<>
<div className="my-auto flex flex-col items-center gap-[11px] lg:hidden">
<section
role="dialog"
aria-modal="true"
aria-label="下载海角社区 App"
className="flex w-80 flex-col items-center gap-[18px] rounded-[18px] bg-[#121118] p-6 text-center shadow-[0_14px_32px_rgba(0,0,0,0.45)]"
>
<img src="/logo.svg" alt="海角社区" className="h-[65px] w-auto" />
<p className="text-sm leading-5 text-[#BEB9CB]">扫码下载「海角社区 App」</p>
<p className="text-xs leading-[18px] text-[#8E889C]">海量短剧,随时畅看</p>
{link ? <QrBox link={link} className="size-[180px] rounded-[10px] p-2" /> : null}
<button
type="button"
onClick={() => copy(link)}
disabled={!link}
className="cursor-pointer text-xs leading-[18px] break-all text-[#F12B9E] disabled:opacity-50"
>
{copied ? "下载链接已复制" : link || "暂无下载地址"}
</button>
<p className="bg-[linear-gradient(54deg,#FF5693_0%,#B421D5_58%,#76DFF2_100%)] bg-clip-text text-lg leading-[25px] font-bold tracking-[0.2em] text-transparent">
海角社区
</p>
</section>
<CloseCircle onClick={onClose} />
</div>
<section
role="dialog"
aria-modal="true"
aria-labelledby="download-dialog-title"
className="relative my-auto hidden h-[500px] w-[760px] flex-col overflow-hidden rounded-3xl bg-[linear-gradient(90deg,#2B1233_0%,#101F41_100%)] shadow-[0_24px_50px_rgba(0,0,0,0.55)] outline outline-1 -outline-offset-1 outline-white/14 lg:flex"
>
<header className="flex h-[92px] shrink-0 items-start gap-[17px] border-b border-white/12 px-7 pt-[22px]">
<img src="/logo.svg" alt="海角社区" className="h-[58px] w-32" />
<div className="flex flex-col gap-2">
<h2 id="download-dialog-title" className="text-2xl leading-[30px] font-semibold text-white">
下载海角社区 App
</h2>
<p className="text-sm leading-5 text-[#A49DB4]">扫码安装,继续追剧不丢进度</p>
</div>
<button
type="button"
aria-label="关闭"
onClick={onClose}
className="absolute top-6 right-6 flex size-9 cursor-pointer items-center justify-center rounded-full bg-white/8 text-xl leading-[26px] font-semibold text-[#BCB7C8] outline outline-1 -outline-offset-1 outline-white/18 transition-colors hover:bg-white/14"
>
×
</button>
</header>
<div className="flex gap-6 px-7 pt-6">
<ul className="flex w-[440px] flex-col gap-3.5">
{features.map((feature) => (
<li
key={feature.title}
className="flex h-[102px] flex-col gap-[11px] rounded-2xl bg-white/5 px-[18px] pt-[15px] outline outline-1 -outline-offset-1 outline-white/12"
>
<h3 className="text-lg leading-6 font-semibold text-white">{feature.title}</h3>
<p className="text-sm leading-5 text-[#A6A0B5]">{feature.text}</p>
</li>
))}
</ul>
<div className="flex h-[334px] w-[236px] flex-col items-center rounded-[18px] bg-white/6 pt-4 outline outline-1 -outline-offset-1 outline-white/14">
<div className="flex gap-2 self-stretch px-[18px]" role="tablist" aria-label="选择系统">
{(["iOS", "Android"] as Platform[]).map((item) => (
<button
key={item}
type="button"
role="tab"
aria-selected={platform === item}
onClick={() => setPlatform(item)}
className={`h-[30px] cursor-pointer rounded-[15px] text-xs leading-[18px] font-semibold text-white transition-colors ${
item === "iOS" ? "w-14" : "w-[76px]"
} ${
platform === item ? "bg-[#F12B9E]" : "bg-white/7 outline outline-1 -outline-offset-1 outline-white/16 hover:bg-white/12"
}`}
>
{item}
</button>
))}
</div>
{link ? <QrBox link={link} className="mt-3 size-[184px] rounded-[14px] p-2.5" /> : null}
<button
type="button"
onClick={() => copy(link)}
disabled={!link}
className="mt-3 h-[42px] w-[204px] cursor-pointer rounded-[21px] bg-[#F12B9E] text-sm leading-5 font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
>
{copied ? "已复制" : link ? "复制下载链接" : "暂无下载地址"}
</button>
<p className="mt-2.5 text-xs leading-[18px] text-[#918A9E]">二维码与下载链接指向同一地址</p>
</div>
</div>
</section>
</>
);
}
function QrBox({ link, className }: { link: string; className: string }) {
return (
<div data-component="download-dialog" className={`shrink-0 bg-white ${className}`}>
<QRCodeSVG value={link} size={164} level="M" className="size-full" aria-hidden />
</div>
);
}
function CloseCircle({ onClick }: { onClick: () => void }) {
return (
<button type="button" aria-label="关闭" onClick={onClick} className="size-11 cursor-pointer">
<svg viewBox="0 0 44 44" className="size-full" aria-hidden="true">
<circle cx="22" cy="22" r="21" fill="none" stroke="white" strokeWidth="2" />
<path d="M16 16l12 12M28 16L16 28" stroke="white" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
);
}
@@ -0,0 +1,32 @@
"use client";
import type { ReactNode } from "react";
import { openAppDownload, useAppDownloadHref } from "@/components/download/download-dialog";
export function DownloadTrigger({
className,
children,
ariaLabel,
}: {
className?: string;
children: ReactNode;
ariaLabel?: string;
}) {
const href = useAppDownloadHref();
const classes = `cursor-pointer ${className ?? ""}`;
if (href) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" aria-label={ariaLabel} className={classes}>
{children}
</a>
);
}
return (
<button type="button" aria-label={ariaLabel} onClick={() => openAppDownload()} className={classes}>
{children}
</button>
);
}
+253
View File
@@ -0,0 +1,253 @@
import type {
PlayletChapterInfo,
PlayletData,
PlayletDetailInfo,
PlayletGuessListResp,
PlayletRecommendListResp,
QueryPlayletChapterInfoResp,
QueryPlayletInfoResp,
} from "@/lib/api/types";
import { resolvePlayURL } from "@/lib/media/playback";
export type EpisodeState = "free" | "locked";
/** 详情页数据:直接使用接口 playletInfo + chapterList 字段名 */
export type DramaDetail = PlayletDetailInfo & {
chapterList?: PlayletChapterInfo[];
};
/** 猜你喜欢:直接使用接口短剧字段 */
export type RelatedDrama = PlayletData;
export type EpisodeRange = { label: string; start: number; end: number };
/** 本地 mock 不再使用 picsum;无封面时留空 */
const cover = (..._args: [string, number, number]) => {
void _args;
return "";
};
const introText = "第二季两极反转,熟悉的人物重新回归,人物关系与隐藏秘密再次升级。";
export function getDramaDetail(id: string): DramaDetail {
const numericId = Number(id) || 1;
return {
id: numericId,
title: "禁忌的喘息第二季",
cover: cover(`haijiao-detail-${id}`, 720, 960),
themeNames: ["都市剧情", "情感纠葛", "悬疑反转"],
anchor: "王朝阳",
totalEpisode: 68,
updateStatus: 2,
countBrowse: 3333000,
desc: introText.repeat(10),
chapterList: Array.from({ length: 3 }, (_, index) => ({
id: index + 1,
currentEpisode: index + 1,
payType: 0,
isOfficialOpen: true,
title: String(index + 1),
})),
};
}
export function totalEpisodeOf(detail: DramaDetail) {
const fromInfo = Number(detail.totalEpisode) || 0;
const fromChapters = Math.max(0, ...(detail.chapterList ?? []).map((item) => Number(item.currentEpisode) || 0));
return Math.max(fromInfo, fromChapters, 1);
}
export function updateStatusLabel(status?: number) {
if (status === 2) return "已完结";
if (status === 1) return "连载中";
return "更新中";
}
export function yearFromOnlineAt(onlineAt?: string) {
if (!onlineAt) return "年份未知";
const year = new Date(onlineAt).getFullYear();
return Number.isFinite(year) ? String(year) : "年份未知";
}
export function formatPlays(count?: number | null) {
const value = Number(count) || 0;
if (value >= 10000) {
const wan = value / 10000;
const text = wan >= 100 ? String(Math.round(wan)) : wan.toFixed(1).replace(/\.0$/, "");
return `${text}万次播放`;
}
return `${value}次播放`;
}
/** 是否可播:优先 chapter.isOfficialOpen;无章节信息时按开放集数回退 */
export function getEpisodeState(episode: number, detail: DramaDetail): EpisodeState {
const chapter = detail.chapterList?.find((item) => Number(item.currentEpisode) === episode);
if (chapter) return chapter.isOfficialOpen ? "free" : "locked";
const openCount = (detail.chapterList ?? []).filter((item) => item.isOfficialOpen).length;
return episode <= openCount ? "free" : "locked";
}
/** 猜你喜欢侧栏展示条数(详情 / 播放) */
export const RELATED_DISPLAY_COUNT = 8;
/** 更多好剧推荐条数(三列网格 3×3) */
export const RECOMMEND_DISPLAY_COUNT = 9;
/** 猜你喜欢:过滤广告后保留接口原字段 */
export function mapPlayletGuessList(data: PlayletGuessListResp | null | undefined): RelatedDrama[] {
return (data?.list ?? [])
.filter((item): item is RelatedDrama => item.type !== 2 && Boolean(item.id))
.slice(0, RELATED_DISPLAY_COUNT);
}
/** 推荐短剧:过滤广告后保留接口原字段;excludeId 排除当前短剧 */
export function mapPlayletRecommendList(
data: PlayletRecommendListResp | null | undefined,
options?: { excludeId?: number | string | null; limit?: number },
): RelatedDrama[] {
const excludeId = options?.excludeId != null ? Number(options.excludeId) : NaN;
const limit = options?.limit ?? RECOMMEND_DISPLAY_COUNT;
return (data?.list ?? [])
.filter(
(item): item is RelatedDrama =>
item.type !== 2 &&
Boolean(item.id) &&
!(Number.isFinite(excludeId) && Number(item.id) === excludeId),
)
.slice(0, limit);
}
/** 相对媒资路径拼接 CDN(优先 mediaAppUsCdn);图片等非播放场景用 */
export function resolveMediaUrl(path?: string | null, cdn?: string | null) {
const raw = (path || "").trim();
if (!raw) return "";
if (/^https?:\/\//i.test(raw)) return raw;
if (raw.startsWith("//")) return `https:${raw}`;
const base = (cdn || "").trim().replace(/\/$/, "");
if (!base) return raw;
return `${base}/${raw.replace(/^\//, "")}`;
}
export { resolvePlayURL } from "@/lib/media/playback";
export type DramaPlayView = {
detail: DramaDetail;
episode: number;
/** 当前剧集标题(来自 chapter/info → chapterInfo.title) */
chapterTitle?: string;
src: string;
related: RelatedDrama[];
};
export function mapPlayletChapterPlay(
data: QueryPlayletChapterInfoResp | null | undefined,
fallback: DramaDetail,
episode: number,
): DramaPlayView | null {
if (!data?.chapterInfo) return null;
const detailFromApi = data.playletInfo
? mapPlayletInfo({
playletInfo: data.playletInfo as PlayletDetailInfo,
chapterList: data.chapterList,
isLike: data.isLike,
isFavorite: data.isFavorite,
})
: null;
const detail: DramaDetail = detailFromApi
? {
...detailFromApi,
chapterList: data.chapterList?.length ? data.chapterList : detailFromApi.chapterList,
isLike: data.isLike ?? detailFromApi.isLike,
isFavorite: data.isFavorite ?? detailFromApi.isFavorite,
}
: {
...fallback,
chapterList: data.chapterList?.length ? data.chapterList : fallback.chapterList,
isLike: data.isLike ?? fallback.isLike,
isFavorite: data.isFavorite ?? fallback.isFavorite,
};
const currentEpisode = Number(data.chapterInfo.currentEpisode) || episode;
// 播放地址走同域 /h5/m3u8/(加密流 + /enkey),不要拼 CDN 直链
const src = resolvePlayURL(data.chapterInfo.videoUrl);
const related = (data.hotPlayletList ?? [])
.filter((item): item is RelatedDrama => Boolean(item.id))
.slice(0, RELATED_DISPLAY_COUNT);
return {
detail,
episode: currentEpisode,
chapterTitle: data.chapterInfo.title?.trim() || undefined,
src,
related,
};
}
/** 透传接口字段,仅补齐 chapterList / 根级 isLike、isFavorite */
export function mapPlayletInfo(data: QueryPlayletInfoResp | null | undefined): DramaDetail | null {
const info = data?.playletInfo;
if (!info?.id) return null;
return {
...info,
isLike: Boolean(data?.isLike ?? info.isLike),
isFavorite: Boolean(data?.isFavorite ?? info.isFavorite),
chapterList: data?.chapterList ?? [],
};
}
export function getEpisodeRanges(total: number, size: number): EpisodeRange[] {
const count = Math.max(1, Math.floor(total / size));
return Array.from({ length: count }, (_, index) => {
const start = index * size + 1;
const end = index === count - 1 ? total : start + size - 1;
return { label: `${start}–${end}`, start, end };
});
}
export function getEpisodeChunks(total: number, size: number): EpisodeRange[] {
return Array.from({ length: Math.ceil(total / size) }, (_, index) => {
const start = index * size + 1;
const end = Math.min(total, start + size - 1);
return { label: `${start}–${end}`, start, end };
});
}
export function playHref(id: string | number, episode: number, slug?: string | null) {
const key = (slug?.trim() || String(id)).trim();
return `/short-drama/${key}/${episode}`;
}
export function dramaDetailHref(detail: Pick<DramaDetail, "id" | "slug">) {
return `/short-drama/${(detail.slug?.trim() || String(detail.id)).trim()}`;
}
export function parseEpisode(value: string, detail: DramaDetail) {
const episode = Number(value);
return Number.isInteger(episode) && episode >= 1 && episode <= totalEpisodeOf(detail) ? episode : null;
}
export const sampleVideo = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8";
const related: [string, string, string][] = [
["十载梦碎,寂悔无用", "权谋交锋,爱意藏在局中", "精选"],
["风禾尽起", "逆风翻盘,人生重新开场", "精选"],
["陆沉渊,你老婆是神仙", "从校服到婚纱的双向奔赴", "甜宠"],
["重生后:我带儿女华丽逆转", "一剑问长生,一念渡相思", "仙侠"],
["八零飒妻脸红红硬汉老公追着宠", "迷雾深处,每个人都有秘密", "悬疑"],
["七零开挂:甩掉极品全家桶", "破镜重圆,再赴炽热爱意", "都市"],
["小祖宗千金归来,我是他的掌中娇", "权谋交锋,爱意藏在局中", "精选"],
["代号:杀神", "逆风翻盘,人生重新开场", "精选"],
];
export const relatedDramas: RelatedDrama[] = related.map(([title, desc, tag], index) => ({
id: index + 1,
title,
desc,
tagNames: [tag],
heatValue: 258000 - index * 12000,
totalEpisode: 60,
cover: cover(`haijiao-related-${index + 1}`, 216, 288),
}));
@@ -0,0 +1,418 @@
"use client";
import { toast } from "@heroui/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { AdBanners, AdIcons } from "@/components/common/ads";
import { BackButton } from "@/components/common/back-button";
import { cardTagCanLink, pickPlayletCardTags } from "@/components/common/card-tags";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { openDownloadDialog } from "@/components/download/download-dialog";
import { FloatingActions } from "@/components/home/floating-actions";
import { tagPageHref } from "@/components/tag/href";
import { favoritePlaylet, getPlayletInfo, likePlaylet } from "@/lib/api/playlet-actions";
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { CommentPanel, playletCommentTarget } from "@/components/common/comments";
import { PageBreadcrumb } from "@/components/seo/page-breadcrumb";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import {
formatPlays,
playHref,
totalEpisodeOf,
yearFromOnlineAt,
type DramaDetail,
type RelatedDrama,
} from "./data";
import { EpisodePanel, H5EpisodeStrip, H5MorePlaylets } from "./episodes";
import { RelatedAside } from "./related-aside";
async function ensurePlayletAuthToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
function resolvePlayletLikeFavorite(data: Awaited<ReturnType<typeof getPlayletInfo>> | null | undefined) {
const info = data?.playletInfo;
return {
isLike: Boolean(data?.isLike ?? info?.isLike),
isFavorite: Boolean(data?.isFavorite ?? info?.isFavorite),
displayCountLike: Number(info?.displayCountLike) || 0,
countCollect: Number(info?.countCollect) || 0,
};
}
type ActionProps = {
icon: string;
label: string;
active?: boolean;
onClick?: () => void;
href?: string;
};
function DetailAction({ icon, label, active = false, onClick, href }: ActionProps) {
const className = `flex items-center gap-2.5 text-base leading-[22px] transition-opacity hover:opacity-85 ${
active ? "text-[#F12B9E]" : "text-white/90"
}`;
const content: ReactNode = (
<>
<span
className={`flex size-9 items-center justify-center rounded-full ${active ? "bg-[#F12B9E]" : "bg-[#F12B9E]/10"}`}
>
<img src={icon} alt="" className="max-h-[21px] max-w-[22px]" />
</span>
{label}
</>
);
return href ? (
<Link href={href} className={className}>
{content}
</Link>
) : (
<button type="button" aria-pressed={active} onClick={onClick} className={`cursor-pointer ${className}`}>
{content}
</button>
);
}
export function DramaDetailPage({
detail,
related = [],
relatedAside,
promo,
adIcons,
adGrid,
bannerAds,
}: {
detail: DramaDetail;
related?: RelatedDrama[];
/** 服务端渲染的热门推荐侧栏 */
relatedAside?: ReactNode;
/** 服务端渲染的 PC banner */
promo?: ReactNode;
/** 服务端渲染的 PC icon */
adIcons?: ReactNode;
/** 服务端渲染的 H5 icon */
adGrid?: ReactNode;
/** 服务端渲染的 H5 banner */
bannerAds?: ReactNode;
}) {
const [liked, setLiked] = useState(Boolean(detail.isLike));
const [liking, setLiking] = useState(false);
const [collected, setCollected] = useState(Boolean(detail.isFavorite));
const [collecting, setCollecting] = useState(false);
const router = useRouter();
const tags = pickPlayletCardTags(detail);
const totalEpisode = totalEpisodeOf(detail);
const playEpisode = (episode: number) => router.push(playHref(detail.id, episode, detail.slug));
const likeFavoriteKey = `${detail.id}:${detail.isLike}:${detail.isFavorite}`;
const [likeFavoriteSync, setLikeFavoriteSync] = useState(likeFavoriteKey);
if (likeFavoriteSync !== likeFavoriteKey) {
setLikeFavoriteSync(likeFavoriteKey);
setLiked(Boolean(detail.isLike));
setCollected(Boolean(detail.isFavorite));
}
// SSR 详情走 "use cache" 无登录态;客户端带 token 再拉一次,纠正已点赞 / 已收藏
useEffect(() => {
let cancelled = false;
void (async () => {
const token = await ensurePlayletAuthToken();
if (!token || cancelled) return;
try {
const data = await getPlayletInfo(
detail.slug?.trim() ? { slug: detail.slug.trim() } : { id: detail.id },
token,
);
if (cancelled) return;
const status = resolvePlayletLikeFavorite(data);
setLiked(status.isLike);
setCollected(status.isFavorite);
} catch (error) {
console.warn("[drama-detail/status]", error);
}
})();
return () => {
cancelled = true;
};
}, [detail.id, detail.slug]);
const share = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
toast.success("链接已复制");
} catch {
toast.danger("复制失败,请稍后重试");
}
};
const toggleLike = async () => {
if (liking) return;
const playletId = Number(detail.id);
if (!Number.isInteger(playletId) || playletId < 1) return;
const token = await ensurePlayletAuthToken();
if (!token) {
openLoginDialog();
return;
}
const next = !liked;
setLiked(next);
setLiking(true);
try {
const result = await likePlaylet({ id: playletId, isLike: next }, token);
setLiked(Boolean(result?.isLike ?? next));
} catch (error) {
setLiked(!next);
handleUnauthorized(error);
} finally {
setLiking(false);
}
};
const toggleCollect = async () => {
if (collecting) return;
const playletId = Number(detail.id);
if (!Number.isInteger(playletId) || playletId < 1) return;
const token = await ensurePlayletAuthToken();
if (!token) {
openLoginDialog();
return;
}
const next = !collected;
setCollected(next);
setCollecting(true);
try {
const result = await favoritePlaylet({ id: playletId, isFavorite: next }, token);
setCollected(Boolean(result?.isFavorite ?? next));
} catch (error) {
setCollected(!next);
handleUnauthorized(error);
} finally {
setCollecting(false);
}
};
return (
<main data-component="drama-detail-page" className="relative flex-1 bg-[#0E0E0E] lg:bg-[#060611]">
<div className="pointer-events-none absolute inset-x-0 top-0 h-[544px] overflow-hidden lg:hidden" aria-hidden="true">
<DecryptedImage src={detail.cover} fallback={IMG_PLACEHOLDER} alt="" priority className="size-full object-cover" />
<div className="absolute inset-0 bg-black/80" />
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.1)_0%,rgba(102,102,102,0)_40%,rgba(14,14,14,0.55)_70%,#0E0E0E_100%)]" />
</div>
<BackButton fallback="/short-drama/hot" className="absolute top-3 left-4 z-10 lg:hidden" />
<div
data-pc-scale
className="relative hidden flex-col gap-8 pt-8 pb-12 lg:mx-auto lg:flex lg:w-[1700px] lg:[zoom:var(--page-scale,1)]"
>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "短剧详情" },
]}
/>
<PageBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "短剧详情" },
]}
/>
<section className="flex h-[544px] gap-10 overflow-hidden rounded-[20px] bg-[rgba(14,13,29,0.92)] p-8 outline outline-1 -outline-offset-1 outline-[rgba(59,56,92,0.1)]">
<DecryptedImage
src={detail.cover}
fallback={IMG_PLACEHOLDER}
alt={detail.title}
priority
className="h-[480px] w-[360px] shrink-0 rounded-xl object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-[18px]">
<div className="flex gap-[9px] text-xs leading-[18px]">
<span className="flex h-[26px] items-center rounded-[13px] bg-[#F12B9E] px-[11px] text-white">全站免费</span>
{tags.map((tag) =>
cardTagCanLink(tag) ? (
<Link
key={tag.slug ?? tag.id ?? tag.name}
href={tagPageHref(tag, "playlet")}
className="flex h-[26px] items-center rounded-[13px] bg-[#171624] px-[11px] text-[#9E96AD] hover:text-white"
>
{tag.name}
</Link>
) : (
<span
key={tag.name}
className="flex h-[26px] items-center rounded-[13px] bg-[#171624] px-[11px] text-[#9E96AD]"
>
{tag.name}
</span>
),
)}
</div>
<p className="text-[40px] leading-[47px] text-white">{detail.title}</p>
<p className="text-sm whitespace-pre text-[#9E96AD]">
{yearFromOnlineAt(detail.onlineAt)} {detail.themeNames?.[0] || "地区未知"} 共 {totalEpisode} 集
</p>
<p className="line-clamp-6 text-sm leading-[22px] text-[#9E96AD]">简介:{detail.desc}</p>
<div className="flex h-[52px] items-center gap-6">
<Link
href={playHref(detail.id, 1, detail.slug)}
className="flex h-[45px] w-36 items-center justify-center rounded-[23px] bg-[#F12B9E] text-base leading-[22px] font-semibold text-white transition-colors hover:bg-[#F23DA8]"
>
立即播放
</Link>
<div className="flex w-[490px] items-center justify-between">
<DetailAction
icon="/images/icon_like.svg"
label={liked ? "已点赞" : "点赞"}
active={liked}
onClick={liking ? undefined : toggleLike}
/>
<span className="h-[19px] w-0.5 bg-white/10" />
<DetailAction
icon="/images/icon_collect.svg"
label={collected ? "已收藏" : "收藏"}
active={collected}
onClick={collecting ? undefined : toggleCollect}
/>
<span className="h-[19px] w-0.5 bg-white/10" />
<DetailAction icon="/images/icon_share.svg" label="分享" onClick={share} />
<span className="h-[19px] w-0.5 bg-white/10" />
<DetailAction icon="/images/icon_feedback.svg" label="反馈" href="/feedback" />
</div>
</div>
<div className="flex h-[88px] shrink-0 items-center justify-between rounded-xl bg-[linear-gradient(90deg,#301438_0%,#171A40_100%)] px-5">
<div className="flex flex-col">
<p className="text-sm leading-5 text-white">App 内继续看,不丢进度</p>
<p className="text-xs leading-[18px] text-[#9E96AD]">同步观看记录、收藏、金币余额和更新提醒。</p>
</div>
<button
type="button"
onClick={() => openDownloadDialog()}
className="cursor-pointer h-[38px] w-[111px] rounded-[19px] bg-[linear-gradient(90deg,#F22BA1_0%,#4A59FF_100%)] text-base leading-[22px] text-white transition-opacity hover:opacity-90"
>
下载
</button>
</div>
</div>
</section>
{adIcons ?? <AdIcons variant="pc" />}
<EpisodePanel detail={detail} current={1} onSelect={playEpisode} />
<div className="flex items-start gap-6">
<CommentPanel target={playletCommentTarget(detail.id)} />
{relatedAside ?? <RelatedAside items={related} />}
</div>
{promo ?? <AdBanners variant="pc" />}
</div>
<div className="relative flex flex-col gap-[18px] px-4 pt-[60px] pb-8 lg:hidden">
<section className="flex items-center gap-3">
<DecryptedImage
src={detail.cover}
fallback={IMG_PLACEHOLDER}
alt={detail.title}
className="h-[106px] w-[76px] shrink-0 rounded-md object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<h1 className="line-clamp-2 text-base leading-[22px] font-medium text-white">{detail.title}</h1>
<p className="flex flex-wrap items-start gap-1.5 text-xs leading-[18px] text-white/35">
<span>全{totalEpisode}集</span>
<span aria-hidden="true">·</span>
<span>{formatPlays(detail.countBrowse ?? detail.heatValue)}</span>
</p>
<div className="flex flex-wrap items-center gap-1.5 text-xs leading-[18px] text-white/90">
{tags.slice(0, 3).map((tag) =>
cardTagCanLink(tag) ? (
<Link
key={tag.slug ?? tag.id ?? tag.name}
href={tagPageHref(tag, "playlet")}
className="rounded-[3px] bg-white/10 px-1.5 py-0.5"
>
#{tag.name}
</Link>
) : (
<span key={tag.name} className="rounded-[3px] bg-white/10 px-1.5 py-0.5">
#{tag.name}
</span>
),
)}
{detail.anchor ? (
<span className="rounded-[3px] bg-white/10 px-1.5 py-0.5">演员·{detail.anchor}</span>
) : null}
</div>
</div>
</section>
{detail.desc?.trim() ? (
<section className="flex w-full flex-col gap-3">
<h2 className="text-base leading-[22px] font-medium text-white">简介</h2>
<p className="text-xs leading-[18px] text-white/65">{detail.desc.trim()}</p>
</section>
) : null}
{adGrid ?? <AdIcons variant="h5" />}
<H5EpisodeStrip detail={detail} onSelect={playEpisode} />
<H5MorePlaylets items={related} />
{bannerAds ?? <AdBanners variant="h5" />}
</div>
<div
data-component="h5-bottom-bar"
data-h5-bottom-bar
className="fixed inset-x-0 bottom-0 z-40 flex h-[68px] items-center justify-center gap-3 bg-[#010101] px-4 pb-[env(safe-area-inset-bottom)] lg:hidden"
>
<button
type="button"
aria-pressed={collected}
disabled={collecting}
onClick={toggleCollect}
className={`cursor-pointer flex h-11 w-[108px] items-center justify-center gap-3 rounded-full bg-white text-base leading-[22px] font-medium disabled:opacity-70 ${
collected ? "text-[#F12B9E]" : "text-[#3D3D3D]"
}`}
>
<span
className={`size-[18px] [mask:url(/images/icon_collect.svg)_center/contain_no-repeat] ${
collected ? "bg-[#F12B9E]" : "bg-[#A3A3B8]"
}`}
/>
{collected ? "已收藏" : "收藏"}
</button>
<Link
href={playHref(detail.id, 1, detail.slug)}
className="flex h-11 w-[223px] items-center justify-center rounded-full bg-[#F12B9E] text-base leading-[22px] font-medium text-white"
>
开始播放
</Link>
</div>
<div className="max-lg:hidden">
<FloatingActions />
</div>
</main>
);
}
+306
View File
@@ -0,0 +1,306 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { CornerFlagBadge, resolveContentBadge } from "@/components/home/section-heading";
import { dramaHref } from "@/components/short-drama/data";
import {
getEpisodeChunks,
getEpisodeRanges,
getEpisodeState,
totalEpisodeOf,
updateStatusLabel,
type DramaDetail,
type RelatedDrama,
} from "./data";
type EpisodeProps = {
detail: DramaDetail;
current: number;
onSelect: (episode: number) => void;
};
function LockIcon({ className = "" }: { className?: string }) {
return (
<svg viewBox="0 0 18 18" fill="none" className={className} aria-hidden="true">
<rect x="3.75" y="7.5" width="10.5" height="8.25" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M6 7.5V5.25a3 3 0 0 1 6 0V7.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M9 10.5v2.25" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
/** 免费/未解锁均进播放页;未解锁由播放页在播放器上盖下载遮罩 */
function useEpisodeClick(_detail: DramaDetail, onSelect: (episode: number) => void) {
return (episode: number) => onSelect(episode);
}
/** 与播放页「更多好剧」一致的三列推荐卡片 */
export function MorePlayletsGrid({ items }: { items: RelatedDrama[] }) {
if (!items.length) {
return <p className="py-8 text-center text-xs text-white/35">暂无更多推荐</p>;
}
return (
<div className="grid grid-cols-3 gap-x-[5px] gap-y-3">
{items.map((item) => {
const badge = resolveContentBadge(item);
return (
<Link key={item.id} href={dramaHref(item.id, item.slug)} className="flex flex-col gap-1">
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-md bg-[#525252]">
<DecryptedImage src={item.cover} alt="" className="size-full object-cover" />
<CornerFlagBadge
tone={badge}
className="absolute top-2 right-2 z-[1]"
badgeClassName="h-[18px]"
/>
</div>
<span className="truncate text-sm leading-5 text-white">{item.title}</span>
<span className="truncate text-xs leading-[18px] text-white/50">
{item.tagNames?.[0]}
{item.tagNames?.[0] && item.totalEpisode ? " · " : ""}
{item.totalEpisode ? `${item.totalEpisode}集` : ""}
</span>
</Link>
);
})}
</div>
);
}
export function EpisodePanel({ detail, current, onSelect }: EpisodeProps) {
const totalEpisode = totalEpisodeOf(detail);
const ranges = getEpisodeRanges(totalEpisode, 20);
const [rangeIndex, setRangeIndex] = useState(-1);
const range = ranges[rangeIndex];
const start = range?.start ?? 1;
const end = range?.end ?? totalEpisode;
const handleClick = useEpisodeClick(detail, onSelect);
return (
<section
data-component="episode-panel"
data-episodes
className="flex flex-col gap-4 rounded-2xl bg-[rgba(20,23,46,0.9)] p-6 outline outline-1 -outline-offset-1 outline-[#333354]"
>
<div className="flex h-10 items-center gap-6">
<h2 className="text-[22px] leading-[30px] text-white">选集</h2>
<p className="flex-1 text-sm text-[#8C879C]">共 {totalEpisode} 集</p>
<div className="flex items-center gap-8 text-sm">
{[{ label: "全部", index: -1 }, ...ranges.map((item, index) => ({ label: item.label, index }))].map(
(item) => (
<button
key={item.label}
type="button"
aria-pressed={rangeIndex === item.index}
onClick={() => setRangeIndex(item.index)}
className={`cursor-pointer ${rangeIndex === item.index ? "text-[#F12B9E]" : "text-[#8C879C] hover:text-white"}`}
>
{item.label}
</button>
),
)}
</div>
</div>
<div className="grid grid-cols-[repeat(10,154px)] justify-between gap-y-3">
{Array.from({ length: end - start + 1 }, (_, index) => start + index).map((episode) => {
const locked = getEpisodeState(episode, detail) === "locked";
const playing = episode === current;
return (
<button
key={episode}
type="button"
aria-current={playing || undefined}
aria-label={`第${episode}集${locked ? "(未解锁)" : ""}`}
onClick={() => handleClick(episode)}
className={`cursor-pointer relative flex h-[58px] items-center justify-center gap-0.5 overflow-hidden rounded-lg text-lg leading-6 font-semibold transition-colors ${
playing
? "bg-[rgba(241,43,158,0.1)] text-[#F12B9E] outline outline-1 -outline-offset-1 outline-[#F12B9E]"
: locked
? "bg-white/10 text-[#828186] outline outline-1 -outline-offset-1 outline-white/5 hover:bg-white/15"
: "bg-[#3A3D53] text-white hover:bg-[#454866]"
}`}
>
第{episode}集
{locked ? <LockIcon className="size-[18px]" /> : null}
{!locked ? (
<span className="absolute top-1 right-1 flex h-5 w-9 items-center justify-center rounded bg-[linear-gradient(136deg,#74FFED_0%,#10B9A2_100%)] text-[10px] font-normal leading-4 text-[#002D27]">
免费
</span>
) : null}
</button>
);
})}
</div>
</section>
);
}
export function H5EpisodeStrip({ detail, onSelect, current }: Omit<EpisodeProps, "current"> & { current?: number }) {
const totalEpisode = totalEpisodeOf(detail);
const ranges = getEpisodeChunks(totalEpisode, 30);
const [rangeIndex, setRangeIndex] = useState(0);
const { start, end } = ranges[rangeIndex] ?? ranges[0];
const handleClick = useEpisodeClick(detail, onSelect);
const hasPreview = Boolean(detail.preview?.trim());
const previewChapter = detail.chapterList?.find(
(item) => item.title?.trim() === "预告" || Number(item.currentEpisode) === 0,
);
const showPreview = hasPreview || Boolean(previewChapter);
return (
<section data-component="h5-episode-strip" data-episodes className="inline-flex w-full flex-col items-start gap-3">
<div className="inline-flex w-full items-center justify-between">
<h2 className="text-sm leading-5 font-medium text-white/90">剧集</h2>
<div className="flex items-center gap-4">
<span className="flex items-center gap-0.5 text-xs leading-[18px] text-[#656565]">
{updateStatusLabel(detail.updateStatus)} 共{totalEpisode}集
<img src="/images/arrow_rught.svg" alt="" className="size-[18px] opacity-50" />
</span>
</div>
</div>
<div className="inline-flex w-full items-center gap-6 overflow-x-auto text-sm leading-5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{ranges.map((item, index) => (
<button
key={item.label}
type="button"
aria-pressed={rangeIndex === index}
onClick={() => setRangeIndex(index)}
className={`cursor-pointer shrink-0 ${rangeIndex === index ? "text-white" : "text-white/35"}`}
>
{item.start}-{item.end}
</button>
))}
<Link href="/short-drama/hot" className="shrink-0 text-white/35">
更多好剧
</Link>
</div>
<div className="inline-flex w-full items-center gap-1.5 overflow-x-auto overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{showPreview && rangeIndex === 0 ? (
<button
type="button"
aria-label="预告"
onClick={() => {
if (previewChapter && Number(previewChapter.currentEpisode) > 0) {
handleClick(Number(previewChapter.currentEpisode));
return;
}
handleClick(1);
}}
className="relative size-[58px] shrink-0 cursor-pointer overflow-hidden rounded-md bg-[rgba(246,103,38,0.1)]"
>
<span className="absolute top-[21px] inset-x-0 flex items-end justify-center text-center text-base leading-[22px] font-medium text-[#F12B9E]">
预告
</span>
<img src="/images/playing.svg" alt="" className="absolute top-0 left-[38px] size-5" />
</button>
) : null}
{Array.from({ length: end - start + 1 }, (_, index) => start + index).map((episode) => {
const locked = getEpisodeState(episode, detail) === "locked";
const playing = current != null && episode === current;
if (playing) {
return (
<button
key={episode}
type="button"
aria-current="true"
aria-label={`第${episode}集`}
onClick={() => handleClick(episode)}
className="relative size-[58px] shrink-0 cursor-pointer overflow-hidden rounded-md bg-[rgba(246,103,38,0.1)]"
>
<span className="absolute top-[21px] inset-x-0 flex items-end justify-center text-center text-base leading-[22px] font-medium text-[#F12B9E]">
{episode}
</span>
<img src="/images/playing.svg" alt="" className="absolute top-0 left-[38px] size-5" />
{!locked ? (
<img
src="/images/tag_mf.png"
alt="免费"
className="pointer-events-none absolute top-0 right-0 h-[14px] w-[25px]"
/>
) : null}
</button>
);
}
if (locked) {
return (
<button
key={episode}
type="button"
aria-label={`第${episode}集(未解锁)`}
onClick={() => handleClick(episode)}
className="relative size-[58px] shrink-0 cursor-pointer overflow-hidden rounded-md bg-white/5 outline outline-1 -outline-offset-1 outline-[#222222]"
>
<span className="absolute top-[21px] left-2.5 inline-flex w-10 items-center justify-center text-base leading-[22px] font-medium text-[#828186]">
<span className="min-w-0 flex-1 text-center">{episode}</span>
<LockIcon className="size-[18px] shrink-0" />
</span>
</button>
);
}
return (
<button
key={episode}
type="button"
aria-label={`第${episode}集`}
onClick={() => handleClick(episode)}
className="relative size-[58px] shrink-0 cursor-pointer overflow-hidden rounded-md bg-white/5"
>
<span className="absolute top-[21px] inset-x-0 flex items-end justify-center text-center text-base leading-[22px] font-medium text-white">
{episode}
</span>
<img
src="/images/tag_mf.png"
alt="免费"
className="pointer-events-none absolute top-0 right-0 h-[14px] w-[25px]"
/>
</button>
);
})}
</div>
</section>
);
}
/** H5 详情:热门推荐 — 一行横向滚动 */
export function H5MorePlaylets({ items }: { items: RelatedDrama[] }) {
if (!items.length) return null;
return (
<section id="more-playlets" data-component="h5-more-playlets" className="flex scroll-mt-4 flex-col gap-3">
<h2 className="text-sm leading-5 font-medium text-white/90">热门推荐</h2>
<div className="-mx-4 flex gap-[9px] overflow-x-auto px-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{items.map((item) => {
const badge = resolveContentBadge(item);
return (
<Link key={item.id} href={dramaHref(item.id, item.slug)} className="flex w-[98px] shrink-0 flex-col gap-1">
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-md bg-[#525252]">
<DecryptedImage src={item.cover} alt="" className="size-full object-cover" />
<CornerFlagBadge
tone={badge}
className="absolute top-2 right-2 z-[1]"
badgeClassName="h-[18px]"
/>
</div>
<span className="truncate text-xs leading-[18px] font-medium text-white">{item.title}</span>
<span className="truncate text-xs leading-[18px] text-white/65">
{item.tagNames?.[0]}
{item.tagNames?.[0] && item.totalEpisode ? " · " : ""}
{item.totalEpisode ? `${item.totalEpisode}集` : ""}
</span>
</Link>
);
})}
</div>
</section>
);
}
@@ -0,0 +1,48 @@
import Link from "next/link";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { formatHeat } from "@/components/ranking/data";
import { dramaHref } from "@/components/short-drama/data";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import type { RelatedDrama } from "./data";
export function RelatedAside({ items }: { items: RelatedDrama[] }) {
if (!items.length) return null;
return (
<aside data-component="drama-related-aside" className="flex min-w-0 flex-1 flex-col gap-4 rounded-2xl bg-[rgba(24,23,46,0.88)] p-6 outline outline-1 -outline-offset-1 outline-[#383657]">
<div className="flex h-9 items-center justify-between">
<h2 className="text-[22px] font-bold text-white">热门推荐</h2>
<Link href="/short-drama/hot" className="flex items-center gap-1 text-xs leading-[18px] text-[#B8B2C2] hover:text-white">
更多
<img src="/images/more_arrow.svg" alt="" className="size-3.5" />
</Link>
</div>
{items.map((item) => (
<Link
key={item.id}
href={dramaHref(String(item.id), item.slug)}
className="group flex h-24 items-center gap-4"
>
<DecryptedImage
src={item.cover}
fallback={IMG_PLACEHOLDER}
alt=""
className="h-24 w-[72px] shrink-0 rounded-lg object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<h3 className="truncate text-base leading-[23px] text-[#F5F0FA] group-hover:text-[#F12B9E]">
{item.title}
</h3>
<p className="truncate text-sm leading-[21px] text-[#8C859C]">{item.desc}</p>
<div className="flex items-center justify-between text-xs leading-[18px]">
<span className="text-[#A89EB5]">{item.tagNames?.[0]}</span>
<span className="text-[#ABA1B8]">{formatHeat(item.heatValue ?? item.countBrowse)}</span>
</div>
</div>
</Link>
))}
</aside>
);
}
@@ -0,0 +1,401 @@
"use client";
import { toast } from "@heroui/react";
import Link from "next/link";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useDramaContext } from "@/components/analytics/use-playback-analytics";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { AdBanners, AdIcons } from "@/components/common/ads";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
CommentPanel,
playletCommentTarget,
type CommentTarget,
} from "@/components/common/comments";
import {
dramaDetailHref,
getEpisodeState,
type DramaDetail,
type RelatedDrama,
} from "@/components/drama-detail/data";
import { RelatedAside } from "@/components/drama-detail/related-aside";
import { FloatingActions } from "@/components/home/floating-actions";
import { PageBreadcrumb } from "@/components/seo/page-breadcrumb";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import { favoritePlaylet, getPlayletInfo, likePlaylet } from "@/lib/api/playlet-actions";
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { H5Play } from "./h5-play";
import { PcPlayer } from "./pc-player";
import { PlayEpisodePanel } from "./play-episode-panel";
import { useReportPlayletHistory } from "./use-report-playlet-history";
const pcFrame = "mx-auto w-[1700px] [zoom:var(--page-scale,1)]";
const pcScaleProps = { "data-pc-scale": true as const };
async function ensurePlayletAuthToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
function resolvePlayletLikeFavorite(data: Awaited<ReturnType<typeof getPlayletInfo>> | null | undefined) {
const info = data?.playletInfo;
return {
isLike: Boolean(data?.isLike ?? info?.isLike),
isFavorite: Boolean(data?.isFavorite ?? info?.isFavorite),
displayCountLike: Number(info?.displayCountLike) || 0,
countCollect: Number(info?.countCollect) || 0,
};
}
function initialDisplayCountLike(detail: DramaDetail) {
return Number(detail.displayCountLike) || 0;
}
function initialCountCollect(detail: DramaDetail) {
return Number(detail.countCollect) || 0;
}
type PillProps = {
icon: string;
label: string;
active?: boolean;
onClick?: () => void;
href?: string;
};
export function ActionPill({ icon, label, active = false, onClick, href }: PillProps) {
const className = `inline-flex h-[52px] w-auto shrink-0 items-center justify-center gap-2.5 rounded-[26px] bg-[rgba(31,23,31,0.55)] px-4 text-base leading-[22px] whitespace-nowrap outline outline-1 -outline-offset-1 outline-[rgba(255,227,196,0.28)] transition-colors hover:bg-[rgba(31,23,31,0.85)] ${
active ? "text-[#F12B9E]" : "text-white/90"
}`;
const content = (
<>
<span
className={`flex size-9 shrink-0 items-center justify-center rounded-full ${active ? "bg-[#F12B9E]" : "bg-[#F12B9E]/10"}`}
>
<img src={icon} alt="" className="max-h-[21px] max-w-[22px]" />
</span>
{label}
</>
);
return href ? (
<Link href={href} className={className}>
{content}
</Link>
) : (
<button type="button" aria-pressed={active} onClick={onClick} className={`cursor-pointer ${className}`}>
{content}
</button>
);
}
export function DramaPlayPage({
detail,
episode,
src,
related = [],
relatedAside,
adIcons,
promo,
}: {
detail: DramaDetail;
episode: number;
src: string;
related?: RelatedDrama[];
relatedAside?: ReactNode;
adIcons?: ReactNode;
promo?: ReactNode;
}) {
const [liked, setLiked] = useState(Boolean(detail.isLike));
const [displayCountLike, setDisplayCountLike] = useState(() => initialDisplayCountLike(detail));
const [liking, setLiking] = useState(false);
const [collected, setCollected] = useState(Boolean(detail.isFavorite));
const [countCollect, setCountCollect] = useState(() => initialCountCollect(detail));
const [collecting, setCollecting] = useState(false);
const [expanded, setExpanded] = useState(false);
const locked = getEpisodeState(episode, detail) === "locked" || !src;
const playSrc = locked ? "" : src;
const episodeLabel = String(episode).padStart(2, "0");
const chapter = detail.chapterList?.find((item) => Number(item.currentEpisode) === episode);
const tagName = detail.tagNames?.[0] || detail.themeNames?.[0] || "";
const dramaTrackContext = useMemo(
() => ({
media_id: String(detail.id || ""),
drama_id: String(detail.id || ""),
drama_title: detail.title || "未命名短剧",
episode: Math.max(1, episode),
video_id: String(chapter?.id || detail.id || ""),
drama_duration: Math.max(0, Math.round(Number(chapter?.mediaTime) || 0)),
recommend_trace_id: "",
drama_tag_key: tagName ? "tag" : "default",
drama_tag_name: tagName || "默认标签",
}),
[detail.id, detail.title, episode, chapter?.id, chapter?.mediaTime, tagName],
);
useDramaContext(dramaTrackContext);
const likeFavoriteKey = `${detail.id}:${detail.isLike}:${detail.isFavorite}:${detail.countCollect}:${detail.displayCountLike}`;
const [likeFavoriteSync, setLikeFavoriteSync] = useState(likeFavoriteKey);
if (likeFavoriteSync !== likeFavoriteKey) {
setLikeFavoriteSync(likeFavoriteKey);
setLiked(Boolean(detail.isLike));
setDisplayCountLike(initialDisplayCountLike(detail));
setCollected(Boolean(detail.isFavorite));
setCountCollect(initialCountCollect(detail));
}
// SSR 详情走 "use cache" 无登录态;客户端带 token 再拉一次,纠正已点赞 / 已收藏
useEffect(() => {
let cancelled = false;
void (async () => {
const token = await ensurePlayletAuthToken();
if (!token || cancelled) return;
try {
const data = await getPlayletInfo(
detail.slug?.trim() ? { slug: detail.slug.trim() } : { id: detail.id },
token,
);
if (cancelled) return;
const status = resolvePlayletLikeFavorite(data);
setLiked(status.isLike);
setCollected(status.isFavorite);
setDisplayCountLike(status.displayCountLike);
setCountCollect(status.countCollect);
} catch (error) {
console.warn("[drama-play/status]", error);
}
})();
return () => {
cancelled = true;
};
}, [detail.id, detail.slug]);
const share = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
toast.success("链接已复制");
} catch {
toast.danger("复制失败,请稍后重试");
}
};
useEffect(() => {
if (!expanded) return;
const root = document.documentElement;
root.dataset.playTheater = "";
window.scrollTo({ top: 0 });
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setExpanded(false);
};
window.addEventListener("keydown", onKey);
return () => {
delete root.dataset.playTheater;
window.removeEventListener("keydown", onKey);
};
}, [expanded]);
const toggleExpanded = () => setExpanded((value) => !value);
const toggleLike = async () => {
if (liking) return;
const playletId = Number(detail.id);
if (!Number.isInteger(playletId) || playletId < 1) return;
const token = await ensurePlayletAuthToken();
if (!token) {
openLoginDialog();
return;
}
const next = !liked;
setLiked(next);
setDisplayCountLike((value) => Math.max(0, value + (next ? 1 : -1)));
setLiking(true);
try {
const result = await likePlaylet({ id: playletId, isLike: next }, token);
setLiked(Boolean(result?.isLike ?? next));
if (typeof result?.displayCountLike === "number") {
setDisplayCountLike(result.displayCountLike);
} else if (typeof result?.likeNum === "number") {
setDisplayCountLike(result.likeNum);
}
} catch (error) {
setLiked(!next);
setDisplayCountLike((value) => Math.max(0, value + (next ? -1 : 1)));
handleUnauthorized(error);
} finally {
setLiking(false);
}
};
const toggleCollect = async () => {
if (collecting) return;
const playletId = Number(detail.id);
if (!Number.isInteger(playletId) || playletId < 1) return;
const token = await ensurePlayletAuthToken();
if (!token) {
openLoginDialog();
return;
}
const next = !collected;
setCollected(next);
setCountCollect((value) => Math.max(0, value + (next ? 1 : -1)));
setCollecting(true);
try {
const result = await favoritePlaylet({ id: playletId, isFavorite: next }, token);
setCollected(Boolean(result?.isFavorite ?? next));
if (typeof result?.countCollect === "number") setCountCollect(result.countCollect);
} catch (error) {
setCollected(!next);
setCountCollect((value) => Math.max(0, value + (next ? -1 : 1)));
handleUnauthorized(error);
} finally {
setCollecting(false);
}
};
const chapterId = detail.chapterList?.find((item) => Number(item.currentEpisode) === episode)?.id;
/** 与短剧详情共用短剧维度评论,不按单集拆分 */
const commentTarget: CommentTarget | null = playletCommentTarget(detail.id);
const reportWatchHistory = useReportPlayletHistory(Number(detail.id), Number(chapterId) || undefined);
return (
<main data-component="drama-play-page" className="relative flex-1 bg-[#0B0709]">
{!expanded ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 hidden h-[1200px] overflow-hidden [mask-image:linear-gradient(180deg,#000_70%,transparent_100%)] lg:block"
>
<DecryptedImage
src={detail.cover}
fallback={IMG_PLACEHOLDER}
alt=""
className="size-full scale-110 object-cover opacity-70 blur-[12px]"
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(9,5,9,0.32)_0%,rgba(9,5,9,0.56)_55%,rgba(6,5,9,0.94)_100%)]" />
</div>
) : null}
<div
{...(expanded ? {} : pcScaleProps)}
className={`relative hidden flex-col gap-6 pb-8 lg:flex ${expanded ? "" : `pt-6 ${pcFrame}`}`}
>
{!expanded ? (
<>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "短剧", href: dramaDetailHref(detail) },
{ name: `第${episode}集` },
]}
/>
<PageBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "短剧", href: dramaDetailHref(detail) },
{ name: `第${episode}集` },
]}
/>
</>
) : null}
<div className={expanded ? "contents" : "flex flex-col gap-4"}>
<div className={expanded ? "contents" : "flex h-[648px] gap-5"}>
<PcPlayer
key={`${episode}-${playSrc}`}
src={playSrc}
poster={detail.cover || ""}
title={`${detail.title || ""} 第${episode}集`}
locked={locked}
episode={episode}
dramaId={detail.slug?.trim() || String(detail.id)}
dramaTitle={detail.title || ""}
layout={expanded ? "theater" : "inline"}
onToggleExpand={toggleExpanded}
onPlaySuccess={reportWatchHistory}
/>
{!expanded ? <PlayEpisodePanel detail={detail} current={episode} /> : null}
</div>
<div
{...(expanded ? pcScaleProps : {})}
className={expanded ? `flex flex-col gap-4 ${pcFrame}` : "contents"}
>
{expanded ? <PlayEpisodePanel detail={detail} current={episode} wide /> : null}
{adIcons ?? <AdIcons variant="pc" />}
<section className="flex h-[140px] items-center justify-between rounded-xl bg-[rgba(19,15,22,0.9)] px-6 py-5 outline outline-1 -outline-offset-1 outline-[rgba(199,145,89,0.7)]">
<div className="flex min-w-0 flex-col gap-1.5">
<p className="text-sm leading-5 text-[#F12B9E]">第 {episode} 集</p>
<p className="truncate text-[28px] leading-[34px] font-semibold text-white">{detail.title}</p>
<p className="text-sm leading-5 text-[#9991AB]">
{detail.title} - 第{episodeLabel}集
</p>
</div>
<div className="flex shrink-0 gap-3">
<ActionPill
icon="/images/icon_like.svg"
label={liked ? "已点赞" : "点赞"}
active={liked}
onClick={toggleLike}
/>
<ActionPill
icon="/images/icon_collect.svg"
label={collected ? "已收藏" : "收藏"}
active={collected}
onClick={collecting ? undefined : toggleCollect}
/>
<ActionPill icon="/images/icon_share.svg" label="分享" onClick={share} />
<ActionPill icon="/images/icon_feedback.svg" label="反馈" href="/feedback" />
</div>
</section>
<div className="flex items-start gap-6">
<CommentPanel target={commentTarget} />
{relatedAside ?? <RelatedAside items={related} />}
</div>
{promo ?? <AdBanners variant="pc" />}
</div>
</div>
</div>
<H5Play
key={`${episode}-${playSrc}`}
detail={detail}
episode={episode}
src={playSrc}
locked={locked}
liked={liked}
displayCountLike={displayCountLike}
collected={collected}
countCollect={countCollect}
related={related}
onLike={toggleLike}
onCollect={toggleCollect}
onShare={share}
commentTarget={commentTarget}
onPlaySuccess={reportWatchHistory}
/>
<div className="max-lg:hidden">
<FloatingActions />
</div>
</main>
);
}
+581
View File
@@ -0,0 +1,581 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { BackButton } from "@/components/common/back-button";
import { H5CommentSheet, type CommentTarget } from "@/components/common/comments";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
dramaDetailHref,
getEpisodeState,
playHref,
totalEpisodeOf,
updateStatusLabel,
type DramaDetail,
type RelatedDrama,
} from "@/components/drama-detail/data";
import { MorePlayletsGrid } from "@/components/drama-detail/episodes";
import { openAppDownload } from "@/components/download/download-dialog";
import { playbackRates, XgPlayer, type XgPlayerInstance } from "@/components/player/xg-player";
import { CheckIcon, ChevronIcon } from "./icons";
import { useEpisodeRange } from "./play-episode-panel";
type Sheet = "episodes" | "speed" | "comments" | null;
/** 播完自动下一集倒计时(秒) */
const AUTO_NEXT_SECONDS = 3;
/** 切集后自动起播标记(sessionStorage) */
const AUTOPLAY_NEXT_KEY = "hjsq-drama-autoplay-next";
function consumeAutoplayFlag() {
if (typeof window === "undefined") return false;
try {
if (sessionStorage.getItem(AUTOPLAY_NEXT_KEY) !== "1") return false;
sessionStorage.removeItem(AUTOPLAY_NEXT_KEY);
return true;
} catch {
return false;
}
}
function markAutoplayNext() {
try {
sessionStorage.setItem(AUTOPLAY_NEXT_KEY, "1");
} catch {
/* ignore */
}
}
type H5PlayProps = {
detail: DramaDetail;
episode: number;
src: string;
locked: boolean;
liked: boolean;
/** 点赞数 displayCountLike */
displayCountLike?: number;
collected: boolean;
/** 收藏数 countCollect */
countCollect?: number;
related?: RelatedDrama[];
onLike: () => void;
onCollect: () => void;
onShare: () => void;
commentTarget: CommentTarget | null;
onPlaySuccess?: () => void;
};
function LockedNotice() {
return (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-4 bg-black/70 text-center">
<p className="text-sm leading-5 text-white">本集暂未在官网开放</p>
<button
type="button"
onClick={openAppDownload}
className="cursor-pointer inline-flex h-9 items-center justify-center rounded-full bg-[#F12B9E] px-6 text-sm font-medium text-white"
>
下载APP观看完整剧集
</button>
</div>
);
}
function formatCount(value: number) {
return value >= 10000 ? `${(value / 10000).toFixed(1)}w` : String(value);
}
function BottomSheet({
open,
onClose,
label,
className = "",
children,
}: {
open: boolean;
onClose: () => void;
label: string;
className?: string;
children: ReactNode;
}) {
return (
<>
<div
aria-hidden="true"
onClick={onClose}
className={`fixed inset-0 z-40 bg-black/70 transition-opacity ${open ? "opacity-100" : "pointer-events-none opacity-0"}`}
/>
<div
role="dialog"
aria-modal="true"
aria-label={label}
aria-hidden={!open}
inert={!open}
className={`fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-[20px] bg-[#1A1A1A] pb-[env(safe-area-inset-bottom)] transition-transform duration-300 ${
open ? "translate-y-0" : "translate-y-full"
} ${className}`}
>
{children}
</div>
</>
);
}
function SheetHead({ title, onClose, children }: { title: ReactNode; onClose: () => void; children?: ReactNode }) {
return (
<div className="flex items-center gap-3">
<button
type="button"
aria-label="收起"
onClick={onClose}
className="cursor-pointer flex size-6 items-center justify-center text-white"
>
<ChevronIcon direction="down" className="size-4" />
</button>
<h2 className="min-w-0 flex-1 text-center text-base leading-[22px] font-medium text-white">{title}</h2>
{children ?? <span className="size-6" />}
</div>
);
}
function EpisodeSheet({
detail,
episode,
collected,
related = [],
onCollect,
onClose,
}: Pick<H5PlayProps, "detail" | "episode" | "collected" | "related" | "onCollect"> & {
onClose: () => void;
}) {
const { ranges, rangeIndex, setRangeIndex, episodes } = useEpisodeRange(detail, episode);
const [tab, setTab] = useState<"episodes" | "more">("episodes");
return (
<div className="flex max-h-[75vh] flex-col gap-3 px-4 pt-2.5 pb-[18px]">
<span className="mx-auto h-[5px] w-9 shrink-0 rounded-full bg-white/20" />
<div className="flex items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<DecryptedImage
src={detail.cover}
alt=""
className="h-[75px] w-[54px] shrink-0 rounded-[3px] object-cover"
/>
<div className="flex min-w-0 flex-col gap-2">
<Link href={dramaDetailHref(detail)} className="flex items-center gap-2 text-sm leading-5 text-white">
<span className="truncate">{detail.title}</span>
<ChevronIcon direction="right" className="size-3 shrink-0" />
</Link>
<p className="text-xs leading-[18px] text-white/35">
{updateStatusLabel(detail.updateStatus)} 共{totalEpisodeOf(detail)}集
</p>
</div>
</div>
<button
type="button"
aria-pressed={collected}
onClick={onCollect}
className={`cursor-pointer shrink-0 rounded-full px-3 py-1 text-[10px] leading-4 ${collected ? "bg-white/5 text-[#F12B9E]" : "bg-[#F12B9E] text-white"}`}
>
{collected ? "已收藏" : "收藏"}
</button>
</div>
<div className="flex shrink-0 gap-6 overflow-x-auto text-sm leading-5 [scrollbar-width:none]">
{ranges.map((range, index) => (
<button
key={range.label}
type="button"
aria-pressed={tab === "episodes" && rangeIndex === index}
onClick={() => {
setTab("episodes");
setRangeIndex(index);
}}
className={`cursor-pointer shrink-0 ${tab === "episodes" && rangeIndex === index ? "text-white" : "text-white/35"}`}
>
{range.start}-{range.end}
</button>
))}
<button
type="button"
aria-pressed={tab === "more"}
onClick={() => setTab("more")}
className={`cursor-pointer shrink-0 ${tab === "more" ? "text-white" : "text-white/35"}`}
>
更多好剧
</button>
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain">
{tab === "episodes" ? (
<div className="grid grid-cols-5 gap-x-[11px] gap-y-2">
{episodes.map((item) => {
const active = item === episode;
const locked = getEpisodeState(item, detail) === "locked";
if (active) {
return (
<Link
key={item}
href={playHref(detail.id, item, detail.slug)}
replace
onClick={onClose}
aria-current="true"
className="relative flex h-[58px] items-center justify-center overflow-hidden rounded-md bg-[rgba(241,43,158,0.1)] text-base leading-[22px] text-[#F12B9E] outline outline-1 -outline-offset-1 outline-[#F12B9E]"
>
{item}
<img src="/images/playing.svg" alt="" className="absolute top-0 right-0 size-5 p-[3px]" />
{!locked ? (
<span className="absolute top-0 left-0 flex h-[14px] w-[25px] items-center justify-center rounded-sm bg-[linear-gradient(136deg,#74FFED_0%,#10B9A2_100%)] text-[8px] leading-3 text-[#002D27]">
免费
</span>
) : null}
</Link>
);
}
if (!locked) {
return (
<Link
key={item}
href={playHref(detail.id, item, detail.slug)}
replace
onClick={onClose}
className="relative flex h-[58px] items-center justify-center overflow-hidden rounded-md bg-[#3A3D53] text-base leading-[22px] text-white"
>
{item}
<span className="absolute top-0 right-0 flex h-[14px] w-[25px] items-center justify-center rounded-sm bg-[linear-gradient(136deg,#74FFED_0%,#10B9A2_100%)] text-[8px] leading-3 text-[#002D27]">
免费
</span>
</Link>
);
}
return (
<Link
key={item}
href={playHref(detail.id, item, detail.slug)}
replace
onClick={onClose}
aria-label={`第${item}集(未解锁)`}
className="relative flex h-[58px] items-center justify-center gap-0.5 overflow-hidden rounded-md bg-white/10 text-base leading-[22px] text-[#828186] outline outline-1 -outline-offset-1 outline-white/5"
>
{item}
<svg viewBox="0 0 18 18" fill="none" className="size-3.5" aria-hidden="true">
<path
d="M5.29 7.65V5.25a3.71 3.71 0 0 1 7.42 0v2.4"
stroke="currentColor"
strokeWidth="1.35"
strokeLinecap="round"
/>
<rect x="3.75" y="7.5" width="10.5" height="8.25" rx="1.5" stroke="currentColor" strokeWidth="1.35" />
</svg>
</Link>
);
})}
</div>
) : (
<MorePlayletsGrid items={related} />
)}
</div>
</div>
);
}
export function H5Play({
detail,
episode,
src,
locked,
liked,
displayCountLike,
collected,
countCollect,
related = [],
onLike,
onCollect,
onShare,
commentTarget,
onPlaySuccess,
}: H5PlayProps) {
const router = useRouter();
const playerRef = useRef<XgPlayerInstance | null>(null);
const [rate, setRate] = useState(1);
const [sheet, setSheet] = useState<Sheet>(null);
const [cleared, setCleared] = useState(false);
const [introOpen, setIntroOpen] = useState(false);
/** null=未进入自动下一集;数字=剩余秒数 */
const [autoNextSeconds, setAutoNextSeconds] = useState<number | null>(null);
/** 自动切集进入本集时消费一次,用于起播 */
const [shouldAutoplay] = useState(consumeAutoplayFlag);
const closeSheet = () => setSheet(null);
const totalEpisode = totalEpisodeOf(detail);
const nextEpisode = episode + 1;
const hasNext = nextEpisode <= totalEpisode;
const nextHref = hasNext ? playHref(detail.id, nextEpisode, detail.slug) : null;
useEffect(() => {
if (!sheet) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setSheet(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [sheet]);
// 换集后清倒计时
useEffect(() => {
setAutoNextSeconds(null);
}, [episode, src]);
// 倒计时结束跳转下一集并标记自动播放
useEffect(() => {
if (autoNextSeconds == null || !nextHref) return;
if (autoNextSeconds <= 0) {
markAutoplayNext();
router.push(nextHref);
return;
}
const timer = window.setTimeout(() => {
setAutoNextSeconds((value) => (value == null ? null : value - 1));
}, 1000);
return () => window.clearTimeout(timer);
}, [autoNextSeconds, nextHref, router]);
const goNextEpisode = () => {
if (!nextHref) return;
setAutoNextSeconds(null);
markAutoplayNext();
router.push(nextHref);
};
const handleEnded = () => {
if (!hasNext || locked) return;
setAutoNextSeconds(AUTO_NEXT_SECONDS);
};
const likeCount = displayCountLike ?? (Number(detail.displayCountLike) || 0);
const collectCount = countCollect ?? (Number(detail.countCollect) || 0);
/** 已点赞 #F52C56 · 已收藏 #F68902 · 文案始终 #DCDCDC */
const sideItems = [
{
label: formatCount(likeCount),
icon: "/images/icon_like.svg",
active: liked,
activeColor: "#F52C56",
onClick: onLike,
},
{
label: "评论",
icon: "/images/comment.svg",
active: false,
activeColor: "#FFFFFF",
onClick: () => setSheet("comments"),
},
{
label: formatCount(collectCount),
icon: "/images/icon_collect.svg",
active: collected,
activeColor: "#F68902",
onClick: onCollect,
},
{
label: "分享",
icon: "/images/share_h5.svg",
active: false,
activeColor: "#FFFFFF",
onClick: onShare,
},
];
return (
<div data-component="drama-h5-play" className="fixed inset-0 z-30 flex flex-col bg-[#0E0E0E] lg:hidden">
<div className="relative min-h-0 flex-1 overflow-hidden">
{locked ? (
<DecryptedImage
src={detail.cover}
alt={detail.title}
className="absolute inset-0 size-full object-cover"
/>
) : (
<XgPlayer
src={src}
poster={detail.cover}
playerRef={playerRef}
onReady={(player) => {
player.on("ratechange", () => setRate(player.playbackRate));
// 用户重新起播则取消自动下一集
player.on("playing", () => setAutoNextSeconds(null));
}}
onPlaySuccess={onPlaySuccess}
onEnded={handleEnded}
analyticsKind="drama"
clickToPlay={!shouldAutoplay}
className="absolute inset-0"
options={{ ignores: ["playbackrate", "cssfullscreen"] }}
/>
)}
<div className="pointer-events-none absolute inset-x-0 top-0 h-[min(300px,40%)] bg-[linear-gradient(180deg,#000_0%,rgba(0,0,0,0)_100%)]" />
{!cleared ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[min(212px,30%)] bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,rgba(0,0,0,0.75)_100%)]" />
) : null}
<div className="absolute inset-x-0 top-0 z-20 flex items-center gap-1 px-4 pt-[calc(12px+env(safe-area-inset-top))] pb-3">
<BackButton fallback={dramaDetailHref(detail)} />
<p className="min-w-0 flex-1 truncate text-lg leading-6 font-medium text-white">第{episode}集</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setSheet("speed")}
className="cursor-pointer flex items-center gap-1 text-xs leading-[18px] font-medium text-white"
>
<img src="/images/speed.svg" alt="" className="size-[18px]" />
{rate === 1 ? "1.0" : rate}x
</button>
<Link href="/feedback" aria-label="意见反馈" className="size-6 shrink-0">
<img src="/images/feedback.svg" alt="" className="size-full" />
</Link>
</div>
</div>
{locked ? <LockedNotice /> : null}
{!cleared ? (
<>
<div className="pointer-events-none absolute right-4 bottom-[120px] z-10 inline-flex flex-col items-center gap-[18px]">
<div className="flex flex-col items-center gap-2.5">
{sideItems.map((item) => (
<button
key={item.icon}
type="button"
aria-pressed={item.active}
onClick={item.onClick}
className="pointer-events-auto cursor-pointer flex flex-col items-center text-center text-xs leading-[18px] font-medium text-[#DCDCDC]"
>
<span className="relative size-[30px] overflow-hidden">
<span
aria-hidden="true"
className="absolute inset-0"
style={{
background: item.active ? item.activeColor : "#FFFFFF",
mask: `url(${item.icon}) center / 24px no-repeat`,
WebkitMask: `url(${item.icon}) center / 24px no-repeat`,
}}
/>
</span>
{item.label}
</button>
))}
</div>
</div>
<div className="pointer-events-none absolute bottom-[68px] left-4 z-10 flex w-[min(278px,calc(100%-90px))] flex-col gap-2.5">
<button
type="button"
aria-label="选集"
onClick={() => setSheet("episodes")}
className="pointer-events-auto flex max-w-full cursor-pointer items-center gap-[5px] text-left text-xs leading-[18px] font-medium text-white"
>
<h1 className="min-w-0 truncate">{detail.title}</h1>
<ChevronIcon direction="right" className="size-[18px] shrink-0" />
</button>
<div className="flex items-end gap-1.5">
<p
className={`max-w-[248px] text-xs leading-[18px] text-white/65 ${introOpen ? "pointer-events-auto max-h-40 overflow-y-auto" : "line-clamp-2"}`}
>
{detail.desc}
</p>
<button
type="button"
onClick={() => setIntroOpen((open) => !open)}
className="pointer-events-auto cursor-pointer shrink-0 text-xs leading-[18px] text-white"
>
{introOpen ? "收起" : "展开"}
</button>
</div>
</div>
</>
) : null}
{autoNextSeconds != null && hasNext ? (
<button
type="button"
onClick={goNextEpisode}
className="absolute inset-x-4 bottom-3 z-20 flex cursor-pointer items-center justify-between rounded-md bg-white/10 px-3 py-1.5 text-sm leading-5"
>
<span className="text-white">自动播放下一集</span>
<span className="text-[#FFAB6B]">倒计时{autoNextSeconds}s</span>
</button>
) : null}
</div>
<div className="flex min-h-[58px] shrink-0 items-center gap-2.5 bg-[#1A1A1A] px-4 pt-1.5 pb-[calc(6px+env(safe-area-inset-bottom))]">
<button
type="button"
onClick={() => setSheet("episodes")}
className="cursor-pointer flex min-w-0 flex-1 items-center justify-between rounded-[3px] bg-white/5 px-3 py-1.5 text-xs leading-[18px] text-white"
>
<span className="truncate">选集·全{totalEpisode}集·免费观看</span>
<ChevronIcon direction="up" className="size-6 shrink-0" />
</button>
{cleared ? (
<button
type="button"
aria-label="评论"
onClick={() => setSheet("comments")}
className="cursor-pointer rounded-[3px] bg-white/5 px-3 py-1.5 text-white"
>
<img src="/images/comment.svg" alt="" className="size-6" />
</button>
) : null}
<button
type="button"
aria-label={cleared ? "退出清屏" : "清屏"}
aria-pressed={cleared}
onClick={() => setCleared((value) => !value)}
className="cursor-pointer text-white"
>
<img src={cleared ? "/images/unscreen.svg" : "/images/screen.svg"} alt="" className="size-6" />
</button>
</div>
<BottomSheet open={sheet === "episodes"} onClose={closeSheet} label="选集">
<EpisodeSheet
detail={detail}
episode={episode}
collected={collected}
related={related}
onCollect={onCollect}
onClose={closeSheet}
/>
</BottomSheet>
<BottomSheet open={sheet === "speed"} onClose={closeSheet} label="倍速" className="gap-3 px-4 py-[18px]">
<SheetHead title="倍速" onClose={closeSheet} />
<div role="listbox" aria-label="选择倍速" className="flex flex-col">
{playbackRates.map((item) => (
<button
key={item}
type="button"
role="option"
aria-selected={item === rate}
onClick={() => {
if (playerRef.current) playerRef.current.playbackRate = item;
setRate(item);
closeSheet();
}}
className={`cursor-pointer flex h-12 items-center justify-between text-base leading-[22px] ${item === rate ? "text-[#F12B9E]" : "text-white"}`}
>
{item}x{item === rate ? <CheckIcon /> : null}
</button>
))}
</div>
</BottomSheet>
<BottomSheet open={sheet === "comments"} onClose={closeSheet} label="评论">
<H5CommentSheet onClose={closeSheet} target={commentTarget} />
</BottomSheet>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
type IconProps = { className?: string };
const stroke = {
fill: "none",
stroke: "currentColor",
strokeWidth: 1.8,
strokeLinecap: "round",
strokeLinejoin: "round",
} as const;
export function PlayIcon({ className = "size-6" }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<path d="M7 4.8v14.4a1 1 0 0 0 1.52.85l11.3-7.2a1 1 0 0 0 0-1.7L8.52 3.95A1 1 0 0 0 7 4.8Z" fill="currentColor" />
</svg>
);
}
export function PauseIcon({ className = "size-6" }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<rect x="6" y="4.5" width="4" height="15" rx="1.2" fill="currentColor" />
<rect x="14" y="4.5" width="4" height="15" rx="1.2" fill="currentColor" />
</svg>
);
}
export function VolumeIcon({ muted, className = "size-6" }: IconProps & { muted: boolean }) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" {...stroke} />
{muted ? (
<path d="m16 9.5 5 5m0-5-5 5" {...stroke} />
) : (
<path d="M15.5 9a4.2 4.2 0 0 1 0 6M18.3 6.5a8 8 0 0 1 0 11" {...stroke} />
)}
</svg>
);
}
export function PipIcon({ className = "size-6" }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2" {...stroke} />
<rect x="12" y="11.5" width="6.5" height="5" rx="1" fill="currentColor" />
</svg>
);
}
export function FullscreenIcon({ active = false, className = "size-6" }: IconProps & { active?: boolean }) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
{active ? (
<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" {...stroke} />
) : (
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" {...stroke} />
)}
</svg>
);
}
export function ChevronIcon({
direction,
className = "size-[18px]",
}: IconProps & { direction: "left" | "right" | "up" | "down" }) {
const rotate = {
left: "rotate-180",
right: "",
up: "-rotate-90",
down: "rotate-90",
}[direction];
return (
<svg viewBox="0 0 24 24" className={`${className} ${rotate}`} aria-hidden="true">
<path d="m9 5 7 7-7 7" {...stroke} />
</svg>
);
}
export function CommentIcon({ className = "size-6" }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<path
d="M12 4c4.7 0 8.5 3.2 8.5 7.2s-3.8 7.2-8.5 7.2c-1 0-1.9-.1-2.8-.4L5 20l.9-3.6c-1.5-1.3-2.4-3.1-2.4-5.2C3.5 7.2 7.3 4 12 4Z"
{...stroke}
/>
<path d="M8.5 11.3h.01M12 11.3h.01M15.5 11.3h.01" {...stroke} strokeWidth={2.4} />
</svg>
);
}
export function SpeedIcon({ className = "size-[18px]" }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<path d="M4.5 16.5a8 8 0 1 1 15 0" {...stroke} />
<path d="m12 13 3.5-4" {...stroke} />
</svg>
);
}
export function CheckIcon({ className = "size-5" }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<path d="m5 12.5 4.5 4.5L19 7.5" {...stroke} strokeWidth={2} />
</svg>
);
}
+273
View File
@@ -0,0 +1,273 @@
"use client";
import { useEffect, useRef } from "react";
import { QRCodeSVG } from "qrcode.react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { openAppDownload, useAppDownloadHref } from "@/components/download/download-dialog";
import { XgPlayer, type XgPlayerInstance } from "@/components/player/xg-player";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
type PlayerProps = {
src: string;
poster: string;
title: string;
locked: boolean;
};
const appVersions = [
{ label: "IOS 商店版", icon: "/images/ios.svg", primary: true },
{ label: "IOS 轻量版", icon: "/images/ios.svg", primary: false },
{ label: "Android版", icon: "/images/android.svg", primary: false },
];
function PlayerDownloadOverlay({
episode,
dramaTitle,
onClose,
scaled,
}: {
episode?: number;
dramaTitle: string;
onClose?: () => void;
scaled: boolean;
}) {
const href = useAppDownloadHref();
return (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-black/80"
onClick={(event) => event.target === event.currentTarget && onClose?.()}
>
<section
role="dialog"
aria-label="下载 App 观看"
className={`flex w-[860px] items-center justify-center gap-4 rounded-2xl bg-black/50 px-8 py-12 outline outline-1 -outline-offset-1 outline-[#525252] ${
scaled ? "[zoom:var(--fs)]" : ""
}`}
>
{href ? (
<div className="shrink-0 rounded-2xl bg-white p-2.5">
{/* 不传 title:避免 qrcode.react 写入 SVG <title> 被爬虫误读 */}
<QRCodeSVG value={href} size={200} level="M" aria-hidden />
</div>
) : null}
<div className="flex w-[515px] flex-col items-start gap-2">
{episode ? (
<span className="flex h-8 items-center gap-2 rounded-[38px] bg-[#F12B9E]/10 px-2.5 text-sm leading-5 text-[#F12B9E] outline outline-1 -outline-offset-1 outline-[#F12B9E]">
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
<rect
x="3.5"
y="7"
width="9"
height="6.5"
rx="1.2"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
/>
<path
d="M5.5 7V5.2a2.5 2.5 0 0 1 5 0V7M8 9.5v1.5"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
第 {episode} 集
</span>
) : null}
<h2 className="text-2xl leading-8 font-medium text-white">{episode ? "本集" : "完整版"}暂未在官网开放</h2>
<p className="text-lg leading-6 text-[#BDBDBD]">
手机扫码下载APP,查看更多《{dramaTitle}》{episode ? "剧集" : "内容"}
</p>
<div className="flex flex-wrap items-center gap-4">
{appVersions.map((item) => (
<button
key={item.label}
type="button"
onClick={openAppDownload}
disabled={!href}
className={`inline-flex h-[42px] w-auto shrink-0 cursor-pointer items-center justify-center gap-2.5 rounded-[64px] px-4 text-base leading-[22px] font-medium whitespace-nowrap text-white transition-opacity hover:opacity-85 disabled:cursor-not-allowed disabled:opacity-50 ${
item.primary
? "bg-[linear-gradient(90deg,#E5557E_0%,#83316D_100%)]"
: "outline outline-1 -outline-offset-1 outline-[#656565]"
}`}
>
<img
src={item.icon}
alt=""
className="max-w-none shrink-0"
style={{
width: "calc(20px * var(--page-unscale, 1))",
height: "calc(20px * var(--page-unscale, 1))",
}}
/>
{item.label}
</button>
))}
</div>
<p className="text-lg leading-6 text-[#BDBDBD]">扫码后自动识别设备并推荐安装方式</p>
</div>
</section>
{onClose ? (
<button
type="button"
aria-label="关闭"
onClick={onClose}
className="cursor-pointer absolute top-4 right-4 flex size-9 items-center justify-center rounded-full bg-white/10 text-xl text-white/80 hover:bg-white/20"
>
×
</button>
) : null}
</div>
);
}
export type PlayerLayout = "inline" | "theater" | "wide";
const layoutClasses: Record<PlayerLayout, string> = {
inline: "h-[648px] w-[1152px] rounded-xl outline-1",
theater: "[--ps:var(--page-scale,1)]",
wide: "[--ps:1]",
};
function DownloadAppCornerButton({ large }: { large: boolean }) {
const href = useAppDownloadHref();
const className = `absolute z-10 inline-flex max-w-[calc(100%-2rem)] items-center justify-center gap-2 rounded-[45px] bg-black/40 p-2.5 text-base leading-[22px] font-medium whitespace-nowrap text-white transition-colors hover:bg-black/60 ${
large ? "top-10 right-10 [zoom:var(--ps)]" : "top-6 right-6"
}`;
const content = (
<>
{/* 与 text-base 一样乘 --page-unscale,避免缩放后图标相对文案变小 */}
<img
src="/images/icon_download.svg"
alt=""
className="max-w-none shrink-0"
style={{
width: "calc(24px * var(--page-unscale, 1))",
height: "calc(24px * var(--page-unscale, 1))",
}}
/>
下载APP观看完整剧集
</>
);
if (href) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={className} aria-label="下载APP观看完整剧集">
{content}
</a>
);
}
return (
<button type="button" onClick={openAppDownload} className={`cursor-pointer ${className}`} aria-label="下载APP观看完整剧集">
{content}
</button>
);
}
type PcPlayerProps = PlayerProps & {
episode?: number;
dramaId?: string;
dramaTitle: string;
layout: PlayerLayout;
onToggleExpand?: () => void;
onPlaySuccess?: () => void;
analyticsKind?: "drama" | "video";
};
export function PcPlayer({
src,
poster,
title,
locked,
episode,
dramaId,
dramaTitle,
layout,
onToggleExpand,
onPlaySuccess,
analyticsKind = "drama",
}: PcPlayerProps) {
const playerRef = useRef<XgPlayerInstance | null>(null);
const toggleRef = useRef(onToggleExpand);
const large = layout !== "inline";
const theater = layout === "theater";
useEffect(() => {
toggleRef.current = onToggleExpand;
});
useEffect(() => {
const plugin = playerRef.current?.getPlugin("cssFullscreen") as { animate?: (full: boolean) => void } | null;
plugin?.animate?.(theater);
}, [theater]);
const onReady = (player: XgPlayerInstance) => {
// cssFullscreen=false 时插件不会注册 hook,强行 usePluginHooks 会打 warn
if (!onToggleExpand) return;
const plugin = player.getPlugin?.("cssFullscreen");
if (!plugin) return;
try {
player.usePluginHooks("cssFullscreen", "cssFullscreen_change", () => {
toggleRef.current?.();
return false;
});
} catch {
// 当前环境不支持该 hook 时忽略
}
};
return (
<div
data-component="pc-player"
className={`relative shrink-0 overflow-hidden bg-black outline -outline-offset-1 outline-[rgba(199,145,89,0.7)] ${
large
? "aspect-video w-full rounded-[calc(18px*var(--ps))] outline-[length:calc(1.5px*var(--ps))] [--fs:calc(1.4757*var(--ps))]"
: ""
} ${layoutClasses[layout]}`}
>
{locked ? (
<DecryptedImage
src={poster}
fallback={IMG_PLACEHOLDER}
alt={title}
className="absolute inset-0 size-full object-cover"
/>
) : (
<XgPlayer
src={src}
poster={poster}
playerRef={playerRef}
onReady={onReady}
onPlaySuccess={onPlaySuccess}
analyticsKind={analyticsKind}
clickToPlay
className="absolute inset-0"
options={{ cssFullscreen: Boolean(onToggleExpand) }}
/>
)}
{theater && dramaId ? (
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: dramaTitle || "短剧", href: `/short-drama/${dramaId}` },
...(episode ? [{ name: `第${episode}集` }] : []),
]}
/>
) : null}
{/* 右上角下载:直接跳转 config.appAddr;宽度随文案,避免固定宽换行 */}
<DownloadAppCornerButton large={large} />
{locked ? (
<PlayerDownloadOverlay episode={episode} dramaTitle={dramaTitle} scaled={large} />
) : null}
</div>
);
}
@@ -0,0 +1,169 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import {
dramaDetailHref,
getEpisodeChunks,
getEpisodeState,
playHref,
totalEpisodeOf,
type DramaDetail,
} from "@/components/drama-detail/data";
import { ChevronIcon } from "./icons";
export const EPISODE_CHUNK = 30;
function LockIcon({ className = "size-[18px]" }: { className?: string }) {
return (
<svg viewBox="0 0 18 18" fill="none" className={className} aria-hidden="true">
<path
d="M5.29 7.65V5.25a3.71 3.71 0 0 1 7.42 0v2.4"
stroke="currentColor"
strokeWidth="1.35"
strokeLinecap="round"
/>
<rect x="3.75" y="7.5" width="10.5" height="8.25" rx="1.5" stroke="currentColor" strokeWidth="1.35" />
<path d="M9 10.09v2.79" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round" />
</svg>
);
}
function FreeBadge() {
return (
<span className="absolute top-[5px] left-11 flex h-5 w-9 items-center justify-center rounded bg-[linear-gradient(136deg,#74FFED_0%,#10B9A2_100%)] text-[10px] leading-4 text-[#002D27]">
免费
</span>
);
}
export function useEpisodeRange(detail: DramaDetail, current: number) {
const ranges = getEpisodeChunks(totalEpisodeOf(detail), EPISODE_CHUNK);
const [rangeIndex, setRangeIndex] = useState(() => Math.floor((current - 1) / EPISODE_CHUNK));
const { start, end } = ranges[rangeIndex] ?? ranges[0];
const episodes = Array.from({ length: end - start + 1 }, (_, index) => start + index);
return { ranges, rangeIndex, setRangeIndex, episodes };
}
export function PlayEpisodePanel({
detail,
current,
wide = false,
}: {
detail: DramaDetail;
current: number;
wide?: boolean;
}) {
const { ranges, rangeIndex, setRangeIndex, episodes } = useEpisodeRange(detail, current);
return (
<aside
data-component="play-episode-panel"
className={`inline-flex shrink-0 flex-col items-start justify-start gap-3 overflow-hidden rounded-xl px-6 py-5 outline outline-1 -outline-offset-1 outline-[rgba(199,145,89,0.7)] ${
wide ? "w-full" : "h-[648px] w-[528px]"
} bg-[rgba(19,15,22,0.79)]`}
>
<div className="inline-flex h-9 w-full shrink-0 items-center justify-between gap-3">
<h2 className="min-w-0 text-[22px] leading-[30px] font-normal text-white">选集</h2>
<Link
href={dramaDetailHref(detail)}
className="inline-flex w-auto shrink-0 items-center gap-2 whitespace-nowrap text-xs leading-[18px] text-[#9991AB] hover:text-white"
>
<ChevronIcon direction="left" />
返回详情
</Link>
</div>
<div
className={`relative inline-flex h-8 w-full shrink-0 items-center ${
wide ? "justify-start gap-20" : "justify-between"
}`}
>
{ranges.map((range, index) => {
const active = index === rangeIndex;
return (
<button
key={range.label}
type="button"
aria-pressed={active}
onClick={() => setRangeIndex(index)}
className={`cursor-pointer relative h-8 text-base leading-[22px] ${
active ? "font-medium text-[#F12B9E]" : "font-normal text-[#6E6B75] hover:text-white"
}`}
>
{range.label}
{active ? (
<span className="absolute top-[30px] left-0 h-0.5 w-[39px] rounded-sm bg-[#FF1A8C]" />
) : null}
</button>
);
})}
</div>
<div
className={`inline-flex w-full flex-wrap content-start items-start justify-start gap-3 overflow-hidden ${
wide ? "" : "min-h-0 flex-1 overflow-y-auto [scrollbar-width:thin]"
}`}
>
{episodes.map((episode) => {
const playing = episode === current;
const locked = getEpisodeState(episode, detail) === "locked";
if (playing) {
return (
<Link
key={episode}
href={playHref(detail.id, episode, detail.slug)}
replace
scroll={false}
aria-current="true"
className="relative h-[76px] w-[86px] overflow-hidden rounded-lg bg-[rgba(241,43,158,0.1)] outline outline-1 -outline-offset-1 outline-[#F12B9E]"
>
<span className="absolute top-[29px] left-[14px] flex w-[58px] items-end justify-center text-center text-lg leading-6 text-[#F12B9E]">
{episode}
</span>
<img src="/images/playing.svg" alt="" className="absolute top-2 left-[58px] size-5" />
{!locked ? <FreeBadge /> : null}
</Link>
);
}
if (!locked) {
return (
<Link
key={episode}
href={playHref(detail.id, episode, detail.slug)}
replace
scroll={false}
className="relative h-[76px] w-[86px] overflow-hidden rounded-lg bg-[#3A3D53] transition-colors hover:bg-[#454866]"
>
<span className="absolute top-[29px] left-[14px] flex w-[58px] items-end justify-center text-center text-lg leading-6 text-white">
{episode}
</span>
<FreeBadge />
</Link>
);
}
return (
<Link
key={episode}
href={playHref(detail.id, episode, detail.slug)}
replace
scroll={false}
aria-label={`第${episode}集(未解锁)`}
className="relative h-[76px] w-[86px] overflow-hidden rounded-lg bg-white/10 outline outline-1 -outline-offset-1 outline-white/5 transition-colors hover:bg-white/15"
>
<span className="absolute top-[29px] left-[14px] inline-flex w-[58px] items-center justify-center gap-0.5 text-lg leading-6 text-[#828186]">
<span className="min-w-0 flex-1 text-center">{episode}</span>
<LockIcon className="size-[18px] shrink-0" />
</span>
</Link>
);
})}
</div>
</aside>
);
}
@@ -0,0 +1,49 @@
"use client";
import { useCallback, useRef } from "react";
import { reportPlayletHistory } from "@/lib/api/playlet-actions";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
async function ensureToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
/** 播放成功后上报观看历史;同一 playletId+chapterId 只报一次 */
export function useReportPlayletHistory(playletId: number, chapterId?: number) {
const reportedKeyRef = useRef<string | null>(null);
return useCallback(async () => {
if (!Number.isInteger(playletId) || playletId < 1) return;
if (!Number.isInteger(chapterId) || !chapterId || chapterId < 1) return;
const key = `${playletId}:${chapterId}`;
if (reportedKeyRef.current === key) return;
reportedKeyRef.current = key;
const token = await ensureToken();
if (!token) {
reportedKeyRef.current = null;
return;
}
try {
await reportPlayletHistory({ playletId, chapterId }, token);
} catch (error) {
reportedKeyRef.current = null;
if (process.env.NODE_ENV === "development") {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[playlet/history] report failed: ${message}`);
}
}
}, [playletId, chapterId]);
}
+46
View File
@@ -0,0 +1,46 @@
import { pickPlayletCardTags, pickVideoCardTags } from "@/components/common/card-tags";
import { resolveContentBadge } from "@/components/home/section-heading";
import { formatHeat } from "@/components/ranking/data";
import type { ShortDramaItem } from "@/components/short-drama/data";
import type { FavoriteVideoListItem, PlayletData } from "@/lib/api/types";
export const favoriteTypes = ["短剧", "视频"] as const;
export type FavoriteType = (typeof favoriteTypes)[number];
export const FAVORITES_PAGE_SIZE = 24;
/** 短剧收藏列表项 → 卡片(接口 PlayletData) */
export function mapFavoritePlayletItem(item: PlayletData): ShortDramaItem | null {
const id = Number(item.id);
if (!Number.isInteger(id) || id < 1) return null;
return {
kind: "drama",
id: String(id),
slug: item.slug,
title: (item.title || "").trim() || "未命名短剧",
subtitle: (item.desc || "").trim(),
tags: pickPlayletCardTags(item),
heat: formatHeat(item.heatValue ?? item.countBrowse),
episodes: item.totalEpisode ? `${item.totalEpisode}集` : "",
badge: resolveContentBadge(item),
cover: (item.cover || "").trim(),
};
}
/** 视频收藏列表项 → 卡片 */
export function mapFavoriteVideoItem(item: FavoriteVideoListItem): ShortDramaItem | null {
const id = Number(item.id);
if (!Number.isInteger(id) || id < 1) return null;
return {
kind: "drama",
id: String(id),
slug: item.slug,
title: (item.title || "").trim() || "未命名视频",
subtitle: "",
tags: pickVideoCardTags(item),
heat: formatHeat(item.watchNum),
episodes: "",
cover: (item.coverImg || "").trim(),
};
}
+221
View File
@@ -0,0 +1,221 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { EmptyState } from "@/components/common/empty-state";
import { Pagination } from "@/components/common/pagination";
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 { H5VideoCard, VideoCard } from "@/components/video/video-card";
import { ApiError } from "@/lib/api/client";
import { getPlayletFavoriteList } from "@/lib/api/playlet-actions";
import { getVideoFavoriteList } from "@/lib/api/video-actions";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import {
FAVORITES_PAGE_SIZE,
favoriteTypes,
mapFavoritePlayletItem,
mapFavoriteVideoItem,
type FavoriteType,
} from "./data";
async function resolveToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
/** 我的收藏:短剧 GET /api/app/playlet/favorite/list · 视频 GET /api/app/video/favorite/list */
export function FavoritesPage() {
const router = useRouter();
const topRef = useRef<HTMLDivElement>(null);
const [type, setType] = useState<FavoriteType>("短剧");
const [page, setPage] = useState(1);
const [items, setItems] = useState<ShortDramaItem[]>([]);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
void (async () => {
await Promise.resolve();
if (cancelled) return;
setLoading(true);
setError(null);
try {
const token = await resolveToken();
if (cancelled) return;
if (!token) {
setItems([]);
setTotalPages(1);
setError("请先登录后查看收藏");
return;
}
if (type === "短剧") {
const data = await getPlayletFavoriteList({ pageNum: page, pageSize: FAVORITES_PAGE_SIZE }, token);
if (cancelled) return;
const list = (data?.list ?? [])
.map(mapFavoritePlayletItem)
.filter((item): item is ShortDramaItem => Boolean(item));
const total = Number(data?.totalCount) || 0;
setItems(list);
setTotalPages(Math.max(1, Math.ceil(total / FAVORITES_PAGE_SIZE) || 1));
} else {
const data = await getVideoFavoriteList({ pageNum: page, pageSize: FAVORITES_PAGE_SIZE }, token);
if (cancelled) return;
const list = (data?.list ?? [])
.map(mapFavoriteVideoItem)
.filter((item): item is ShortDramaItem => Boolean(item));
const total = Number(data?.totalCount) || 0;
setItems(list);
setTotalPages(Math.max(1, Math.ceil(total / FAVORITES_PAGE_SIZE) || 1));
}
} catch (err) {
if (cancelled) return;
setItems([]);
setTotalPages(1);
setError(err instanceof ApiError ? err.message : "加载失败,请稍后重试");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [type, page, reloadKey]);
const changeType = (next: FavoriteType) => {
setType(next);
setPage(1);
};
const changePage = (next: number) => {
setPage(next);
topRef.current?.scrollIntoView({ behavior: "smooth" });
};
const goBack = () => {
if (window.history.length > 1) router.back();
else router.push("/profile");
};
const isVideo = type === "视频";
const PcCard = isVideo ? VideoCard : ShortDramaCard;
const MobileCard = isVideo ? H5VideoCard : H5ShortDramaCard;
const empty = !loading && !error && items.length === 0;
return (
<main data-component="favorites-page" className="flex-1 bg-[#07060D] lg:bg-[#070611]">
<div className="relative flex h-12 items-center justify-center px-4 lg:hidden">
<button type="button" aria-label="返回" onClick={goBack} className="cursor-pointer absolute left-4 size-6">
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
<path d="M15 5l-7 7 7 7" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<h1 className="text-base leading-[22px] font-bold text-white">我的收藏</h1>
</div>
<div
ref={topRef}
data-pc-scale
className="mx-auto flex w-full scroll-mt-24 flex-col gap-3 px-4 py-3 lg:w-[1700px] lg:gap-5 lg:px-0 lg:py-[30px] lg:[zoom:var(--page-scale,1)]"
>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: "个人中心", href: "/profile" },
{ name: "追剧与收藏" },
]}
/>
<div className="hidden flex-col gap-px lg:flex">
<h2 className="text-[30px] leading-9 font-semibold text-white">我的收藏</h2>
<p className="text-sm leading-5 text-[#9790A6]">继续关注喜欢的内容</p>
</div>
<div className="flex gap-3 lg:hidden">
{favoriteTypes.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === type}
onClick={() => changeType(option)}
className={`cursor-pointer flex h-8 items-center justify-center rounded-2xl px-4 text-sm leading-5 transition-colors ${
option === type ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
}`}
>
{option}
</button>
))}
</div>
<div className="hidden gap-[9px] lg:flex">
{favoriteTypes.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === type}
onClick={() => changeType(option)}
className={`cursor-pointer flex h-8 w-[88px] items-center justify-center rounded-2xl px-4 text-xs leading-[18px] transition-colors ${
option === type ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
}`}
>
{option}
</button>
))}
</div>
{loading ? (
<p className="py-16 text-center text-sm text-[#948CA8]">加载中…</p>
) : error ? (
<EmptyState
title={error}
description="请检查网络后重试"
onReload={() => setReloadKey((value) => value + 1)}
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
/>
) : empty ? (
<EmptyState
title="暂无收藏内容"
description="去首页发现更多好剧吧"
onReload={null}
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
/>
) : (
<>
<div
className={`hidden justify-between gap-x-5 gap-y-6 lg:grid ${
isVideo ? "grid-cols-[repeat(auto-fill,324px)]" : "grid-cols-[repeat(auto-fill,266px)]"
}`}
>
{items.map((item) => (
<PcCard key={`${type}-${item.id}`} item={item} />
))}
</div>
<div className="grid grid-cols-2 gap-x-[7px] gap-y-3 lg:hidden">
{items.map((item) => (
<MobileCard key={`${type}-${item.id}`} item={item} showEpisodes={false} />
))}
</div>
<Pagination page={page} total={totalPages} onChange={changePage} showJump={false} />
</>
)}
</div>
</main>
);
}
+449
View File
@@ -0,0 +1,449 @@
"use client";
import { Label, ListBox, Select } from "@heroui/react";
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { uploadImage } from "@/lib/api/auth";
import { ApiError } from "@/lib/api/client";
import { submitQuestionnaire } from "@/lib/api/feedback";
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
import { ensureGuestSession, getAuthToken, getAuthUser } from "@/lib/auth/session";
import {
detectDeviceType,
detectNetworkType,
deviceTypes,
frequencies,
networkTypes,
operators,
provinces,
questionTypes,
} from "./feedback-options";
const MAX_FILES = 3;
const MAX_SIZE = 5 * 1024 * 1024;
const ACCEPT_TYPES = new Set(["image/png", "image/jpeg", "image/jpg"]);
const labelClass =
"text-xs leading-[18px] font-bold text-[#FAF7FF] lg:text-sm lg:leading-5 lg:font-semibold lg:text-[#F5F2FC]";
const controlClass =
"w-full rounded-xl bg-[#1C1726] px-3 text-xs leading-[18px] text-[#FAF7FF] outline-none placeholder:text-[#9E94AD] focus:outline focus:outline-1 focus:outline-[#F12B9E]/60 lg:bg-[#13121C] lg:px-4 lg:text-sm lg:leading-5 lg:outline lg:outline-1 lg:-outline-offset-1 lg:outline-[#2E293D] lg:placeholder:text-[#948CA8] lg:focus:outline-[#F12B9E]/60";
type PreviewFile = { id: string; file: File; url: string };
function isAcceptedImage(file: File) {
if (ACCEPT_TYPES.has(file.type)) return true;
return /\.(png|jpe?g)$/i.test(file.name || "");
}
function FieldSelect({
label,
placeholder,
value,
options,
onChange,
}: {
label: string;
placeholder: string;
value: string | null;
options: readonly string[];
onChange: (value: string | null) => void;
}) {
return (
<Select
aria-label={label}
className="flex w-full flex-col gap-3 lg:gap-2"
placeholder={placeholder}
value={value}
onChange={(next) => onChange(next == null ? null : String(next))}
>
{/* Label 必须放在 Select 内,否则 React Aria 会报缺少 aria-label */}
<Label className={labelClass}>{label}</Label>
<Select.Trigger className={`${controlClass} flex h-11 items-center justify-between gap-2 pr-3 lg:h-[50px] lg:pr-4`}>
<Select.Value className="min-w-0 flex-1 truncate text-left data-[placeholder=true]:text-[#9E94AD] lg:data-[placeholder=true]:text-[#948CA8]" />
<Select.Indicator className="size-4 shrink-0 text-[#948CA8]" />
</Select.Trigger>
<Select.Popover className="max-h-64 overflow-auto rounded-xl bg-[#1C1726] outline outline-1 -outline-offset-1 outline-[#2E293D] lg:bg-[#13121C]">
<ListBox aria-label={label} className="p-1">
{options.map((item) => (
<ListBox.Item
key={item}
id={item}
textValue={item}
className="cursor-pointer rounded-lg px-3 py-2.5 text-sm leading-5 text-[#FAF7FF] outline-none data-[focused=true]:bg-[#F12B9E]/20 data-[selected=true]:text-[#F12B9E]"
>
{item}
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
);
}
export function FeedbackForm() {
const [questionType, setQuestionType] = useState<string | null>(null);
const [frequency, setFrequency] = useState<string | null>("偶尔出现");
const [deviceType, setDeviceType] = useState<string | null>(null);
const [networkType, setNetworkType] = useState<string | null>(null);
const [operator, setOperator] = useState<string | null>(null);
const [province, setProvince] = useState<string | null>(null);
const [city, setCity] = useState("");
const [deviceModel, setDeviceModel] = useState("");
const [description, setDescription] = useState("");
const [contact, setContact] = useState("");
const [files, setFiles] = useState<PreviewFile[]>([]);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<{ tone: "error" | "success"; text: string } | null>(null);
useEffect(() => {
setDeviceType(detectDeviceType());
setNetworkType(detectNetworkType());
}, []);
const pickFiles = (event: ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(event.target.files ?? []);
event.target.value = "";
if (!picked.length) return;
const remain = MAX_FILES - files.length;
if (remain <= 0) {
setMessage({ tone: "error", text: `最多上传 ${MAX_FILES} 张图片` });
return;
}
const next: PreviewFile[] = [];
let skippedType = 0;
let skippedSize = 0;
let skippedLimit = 0;
for (let i = 0; i < picked.length; i++) {
if (next.length >= remain) {
skippedLimit += picked.length - i;
break;
}
const file = picked[i];
if (!isAcceptedImage(file)) {
skippedType += 1;
continue;
}
if (file.size > MAX_SIZE) {
skippedSize += 1;
continue;
}
next.push({
id: `${Date.now()}-${i}-${file.name}`,
file,
url: URL.createObjectURL(file),
});
}
if (skippedType) setMessage({ tone: "error", text: "仅支持 PNG / JPG 图片" });
else if (skippedSize) setMessage({ tone: "error", text: "单张图片不能超过 5MB" });
else if (skippedLimit) setMessage({ tone: "error", text: `最多上传 ${MAX_FILES} 张图片` });
else setMessage(null);
if (next.length) setFiles((current) => [...current, ...next]);
};
const removeFile = (id: string) => {
setFiles((current) => {
const target = current.find((item) => item.id === id);
if (target) URL.revokeObjectURL(target.url);
return current.filter((item) => item.id !== id);
});
};
const resetForm = () => {
files.forEach((item) => URL.revokeObjectURL(item.url));
setQuestionType(null);
setFrequency("偶尔出现");
setDeviceType(detectDeviceType());
setNetworkType(detectNetworkType());
setOperator(null);
setProvince(null);
setCity("");
setDeviceModel("");
setDescription("");
setContact("");
setFiles([]);
};
const submit = async (event: FormEvent) => {
event.preventDefault();
if (submitting) return;
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
if (!token) {
setMessage({ tone: "error", text: "请先登录后再提交反馈" });
openLoginDialog();
return;
}
if (!questionType) {
setMessage({ tone: "error", text: "请选择问题类型" });
return;
}
if (!frequency) {
setMessage({ tone: "error", text: "请选择出现频率" });
return;
}
if (!deviceType) {
setMessage({ tone: "error", text: "请选择设备类型" });
return;
}
if (!networkType) {
setMessage({ tone: "error", text: "请选择网络类型" });
return;
}
if (!operator) {
setMessage({ tone: "error", text: "请选择运营商" });
return;
}
if (!province) {
setMessage({ tone: "error", text: "请选择所在省份" });
return;
}
const content = description.trim();
if (!content) {
setMessage({ tone: "error", text: "请填写问题描述" });
return;
}
if ([...content].length > 500) {
setMessage({ tone: "error", text: "问题描述最多 500 字" });
return;
}
if (files.length > MAX_FILES) {
setMessage({ tone: "error", text: `最多上传 ${MAX_FILES} 张图片` });
return;
}
for (const item of files) {
if (item.file.size > MAX_SIZE) {
setMessage({ tone: "error", text: "单张图片不能超过 5MB" });
return;
}
}
setSubmitting(true);
setMessage(null);
try {
const attachments: { fileType: string; url: string }[] = [];
for (const item of files) {
const uploaded = await uploadImage(item.file, token);
const url = (uploaded?.sourceNameFs || uploaded?.url || "").trim();
if (!url) throw new ApiError("图片上传失败", 500);
attachments.push({ fileType: "img", url });
}
const contactValue = contact.trim();
const cityValue = city.trim();
const modelValue = deviceModel.trim();
const userId = Number(getAuthUser()?.userId) || 0;
await submitQuestionnaire(
{
description: content,
deviceType,
frequency,
networkType,
operator,
province,
questionType,
...(cityValue ? { city: cityValue } : {}),
...(contactValue ? { contact: contactValue } : {}),
...(modelValue ? { deviceModel: modelValue } : {}),
...(attachments.length ? { attachments } : {}),
...(userId > 0 ? { userId } : {}),
},
token,
);
resetForm();
setMessage({ tone: "success", text: "提交成功,感谢您的反馈!" });
} catch (error) {
if (handleUnauthorized(error, { message: "请先登录后再提交反馈" })) {
setMessage({ tone: "error", text: "请先登录后再提交反馈" });
} else {
setMessage({
tone: "error",
text: error instanceof Error ? error.message : "提交失败",
});
}
} finally {
setSubmitting(false);
}
};
return (
<form data-component="feedback-form"
onSubmit={submit}
className="flex flex-col gap-3 rounded-2xl bg-[#13111C] px-4 py-3.5 lg:flex-1 lg:gap-[18px] lg:bg-[#0E0D17] lg:p-[22px] lg:outline lg:outline-1 lg:-outline-offset-1 lg:outline-[#2E293D]"
>
<h2 className="text-base leading-[22px] font-bold text-[#FAF7FF] lg:text-[22px] lg:leading-7 lg:font-semibold lg:text-[#F5F2FC]">
提交反馈
</h2>
<p className="text-xs leading-[18px] text-[#9E94AD] lg:text-sm lg:leading-5 lg:text-[#948CA8]">
我们会认真阅读每一条反馈,并尽快改进产品体验。
</p>
<FieldSelect
label="问题类型"
placeholder="请选择问题类型"
value={questionType}
options={questionTypes}
onChange={(value) => {
setQuestionType(value);
setMessage(null);
}}
/>
<div className="grid gap-3 lg:grid-cols-2 lg:gap-4">
<FieldSelect
label="出现频率"
placeholder="请选择出现频率"
value={frequency}
options={frequencies}
onChange={setFrequency}
/>
<FieldSelect
label="设备类型"
placeholder="请选择设备类型"
value={deviceType}
options={deviceTypes}
onChange={setDeviceType}
/>
<FieldSelect
label="网络类型"
placeholder="请选择网络类型"
value={networkType}
options={networkTypes}
onChange={setNetworkType}
/>
<FieldSelect
label="运营商"
placeholder="请选择运营商"
value={operator}
options={operators}
onChange={setOperator}
/>
<FieldSelect
label="所在省份"
placeholder="请选择所在省份"
value={province}
options={provinces}
onChange={setProvince}
/>
<label className="flex flex-col gap-3 lg:gap-2">
<span className={labelClass}>所在城市(选填)</span>
<input
value={city}
onChange={(event) => setCity(event.target.value)}
maxLength={40}
placeholder="如:深圳"
className={`${controlClass} h-11 lg:h-[50px]`}
/>
</label>
</div>
<label className="flex flex-col gap-3 lg:gap-2">
<span className={labelClass}>设备型号(选填)</span>
<input
value={deviceModel}
onChange={(event) => setDeviceModel(event.target.value)}
maxLength={80}
placeholder="如:iPhone 15 / Pixel 8"
className={`${controlClass} h-11 lg:h-[50px]`}
/>
</label>
<label className="flex flex-col gap-3 lg:gap-2">
<span className={labelClass}>问题描述</span>
<textarea
value={description}
onChange={(event) => {
setDescription(event.target.value);
setMessage(null);
}}
maxLength={500}
placeholder="请尽量详细描述问题、出现步骤和期望结果"
className={`${controlClass} h-[72px] resize-none py-3 lg:h-[120px] lg:py-3.5`}
/>
</label>
<label className="flex flex-col gap-3 lg:gap-2">
<span className={labelClass}>联系方式(选填)</span>
<input
value={contact}
onChange={(event) => setContact(event.target.value)}
maxLength={100}
placeholder="邮箱或其他联系方式"
className={`${controlClass} h-11 lg:h-[50px]`}
/>
</label>
{files.length ? (
<ul className="flex flex-wrap gap-2">
{files.map((item) => (
<li key={item.id} className="relative size-16 overflow-hidden rounded-lg lg:size-20">
<img src={item.url} alt={item.file.name} className="size-full object-cover" />
<button
type="button"
aria-label="移除图片"
onClick={() => removeFile(item.id)}
className="cursor-pointer absolute top-0.5 right-0.5 flex size-5 items-center justify-center rounded-full bg-black/70 text-xs text-white"
>
×
</button>
</li>
))}
</ul>
) : null}
<label
className={`flex h-[46px] cursor-pointer items-center gap-2 rounded-xl bg-[#1C1726] px-3 lg:h-[72px] lg:gap-2.5 lg:bg-[#13121C] lg:px-4 lg:outline lg:outline-1 lg:-outline-offset-1 lg:outline-[#2E293D] ${
files.length >= MAX_FILES ? "pointer-events-none opacity-50" : ""
}`}
>
<span className="text-lg leading-6 text-[#F12B9E] lg:w-5 lg:text-xl lg:leading-[26px] lg:font-semibold">+</span>
<span className="truncate text-xs leading-[18px] text-[#9E94AD] lg:text-sm lg:leading-5 lg:text-[#948CA8]">
{files.length
? `已选 ${files.length}/${MAX_FILES} 张,还可继续添加`
: `添加问题截图,最多 ${MAX_FILES} 张,单张不超过 5MB`}
</span>
<input
type="file"
accept="image/png,image/jpeg"
multiple
disabled={files.length >= MAX_FILES}
onChange={pickFiles}
className="sr-only"
/>
</label>
{message ? (
<p className={`text-xs leading-[18px] ${message.tone === "error" ? "text-[#FF5A7A]" : "text-[#4ADE80]"}`}>
{message.text}
</p>
) : null}
<button
type="submit"
disabled={submitting}
className="cursor-pointer h-[46px] w-full rounded-xl bg-[#F12B9E] text-sm leading-5 font-bold text-[#FAF7FF] transition-opacity hover:opacity-90 disabled:opacity-60 lg:w-40 lg:rounded-[23px] lg:font-semibold lg:text-[#F5F2FC]"
>
{submitting ? "提交中..." : "提交反馈"}
</button>
</form>
);
}
@@ -0,0 +1,81 @@
/** 与 swagger QuestionnaireSubmitReq 枚举文案保持一致 */
export const questionTypes = [
"播放卡顿/缓冲",
"无法播放",
"画质模糊",
"音画不同步",
"加载失败",
"闪退/崩溃",
"登录异常",
"其他问题",
] as const;
export const frequencies = ["每次都会", "经常出现", "偶尔出现", "仅现一次"] as const;
export const deviceTypes = ["iPhone", "Android", "iPad", "其他"] as const;
export const networkTypes = ["WI-FI", "5G", "4G", "其他"] as const;
export const operators = ["电信", "联通", "移动", "广电"] as const;
export const provinces = [
"北京",
"天津",
"河北",
"山西",
"内蒙古",
"辽宁",
"吉林",
"黑龙江",
"上海",
"江苏",
"浙江",
"安徽",
"福建",
"江西",
"山东",
"河南",
"湖北",
"湖南",
"广东",
"广西",
"海南",
"重庆",
"四川",
"贵州",
"云南",
"西藏",
"陕西",
"甘肃",
"青海",
"宁夏",
"新疆",
"香港",
"澳门",
"台湾",
"海外",
] as const;
export function detectDeviceType(): (typeof deviceTypes)[number] {
if (typeof navigator === "undefined") return "其他";
const ua = navigator.userAgent;
if (/iPad/i.test(ua)) return "iPad";
if (/iPhone/i.test(ua)) return "iPhone";
if (/Android/i.test(ua)) return "Android";
return "其他";
}
export function detectNetworkType(): (typeof networkTypes)[number] {
if (typeof navigator === "undefined") return "其他";
const connection = (
navigator as Navigator & {
connection?: { type?: string; effectiveType?: string };
}
).connection;
const type = (connection?.type || "").toLowerCase();
const effective = (connection?.effectiveType || "").toLowerCase();
if (type === "wifi" || effective.includes("wifi")) return "WI-FI";
if (effective === "5g" || (type === "cellular" && effective.includes("5g"))) return "5G";
if (effective === "4g" || effective === "3g" || type === "cellular") return "4G";
return "WI-FI";
}
+38
View File
@@ -0,0 +1,38 @@
import { FeedbackForm } from "@/components/feedback/feedback-form";
import { InfoPageShell } from "@/components/info/info-page-layout";
const notes = [
{ title: "处理时间", text: "一般问题将在 1–3 个工作日内查看。" },
{ title: "紧急问题", text: "账号安全或付费异常,请在描述中注明。" },
];
export function FeedbackPage({ gmailAddr }: { gmailAddr?: string | null }) {
const email = gmailAddr?.trim();
return (
<InfoPageShell data-component="feedback-page" title="问题反馈" subtitle="告诉我们您遇到的问题或建议">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:gap-6">
<FeedbackForm />
<aside className="flex flex-col gap-[5px] rounded-2xl bg-[#13111C] px-4 py-3 lg:w-80 lg:shrink-0 lg:gap-[18px] lg:bg-[#0E0D17] lg:p-[22px] lg:outline lg:outline-1 lg:-outline-offset-1 lg:outline-[#2E293D]">
<h2 className="text-sm leading-5 font-bold text-[#FAF7FF] lg:text-xl lg:leading-[26px] lg:font-semibold lg:text-[#F5F2FC]">
反馈说明
</h2>
{notes.map((note) => (
<div key={note.title} className="flex flex-col lg:gap-1.5">
<h3 className="hidden text-sm leading-5 text-[#F5F2FC] lg:block">{note.title}</h3>
<p className="text-xs leading-[18px] text-[#9E94AD] lg:text-sm lg:leading-5 lg:text-[#948CA8]">
<span className="lg:hidden">{note.title}:</span>
{note.text}
</p>
</div>
))}
{email ? (
<a href={`mailto:${email}`} className="text-xs leading-[18px] text-[#F12B9E]">
联系邮箱:{email}
</a>
) : null}
</aside>
</div>
</InfoPageShell>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { EmptyState } from "@/components/common/empty-state";
import { InfoPageShell, infoCardClass } from "@/components/info/info-page-layout";
import type { RichTextInfo } from "@/lib/api/types";
const richtextBodyClass =
"richtext-body [&_a]:text-[#F12B9E] [&_h1]:mb-2 [&_h1]:font-bold [&_h2]:mt-3 [&_h2]:mb-1.5 [&_h2]:font-bold [&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:font-semibold [&_li]:ml-4 [&_li]:list-disc [&_p]:mb-2 [&_ul]:mb-2";
export function HelpPage({ data }: { data: RichTextInfo | null }) {
const html = data?.content?.trim() || "";
const subtitle = data?.seoInfo?.description?.trim() || data?.title?.trim() || "常见问题与使用指南";
return (
<InfoPageShell data-component="help-page" title="帮助中心" subtitle={subtitle} mobile={
html ? (
<article
data-component="help-page-h5"
className={`${richtextBodyClass} rounded-2xl bg-[#13111C] px-4 py-3.5 text-xs leading-[18px] text-[#9E94AD] [&_h1]:text-base [&_h1]:text-[#FAF7FF] [&_h2]:text-sm [&_h2]:text-[#FAF7FF] [&_h3]:text-sm [&_h3]:text-[#FAF7FF]`}
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<EmptyState
title="暂无帮助内容"
description="帮助文档正在完善中,请稍后再试或通过问题反馈联系我们"
className="rounded-2xl px-4 py-12"
/>
)
}>
{html ? (
<article
data-component="help-page-pc"
className={`${infoCardClass} ${richtextBodyClass} gap-4 px-8 pt-7 pb-[34px] text-sm leading-5 text-[#948CA8] [&_h1]:text-xl [&_h1]:leading-[26px] [&_h1]:font-semibold [&_h1]:text-[#F5F2FC] [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-[#F5F2FC] [&_h3]:text-base [&_h3]:font-medium [&_h3]:text-[#F5F2FC] [&_li]:ml-5 [&_p]:mb-3 [&_ul]:mb-3`}
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<EmptyState
title="暂无帮助内容"
description="帮助文档正在完善中,请稍后再试或通过问题反馈联系我们"
className="rounded-3xl px-[110px] py-24"
/>
)}
</InfoPageShell>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { pickPlayletCardTags, pickVideoCardTags } from "@/components/common/card-tags";
import { playHref } from "@/components/drama-detail/data";
import { formatHeat } from "@/components/ranking/data";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { videoHref } from "@/components/video/video-card";
import type { PlayletHistoryItem, VideoHistoryItem } from "@/lib/api/types";
export const historyTypes = ["短剧", "视频"] as const;
export type HistoryType = (typeof historyTypes)[number];
export const HISTORY_PAGE_SIZE = 24;
export function formatWatchedAt(value?: string | null) {
const raw = (value || "").trim();
if (!raw) return "";
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return raw;
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
/** 短剧历史 → 卡片展示(保留接口字段语义,仅做展示文案) */
export function mapPlayletHistoryItem(item: PlayletHistoryItem): ShortDramaItem | null {
const id = Number(item.id);
if (!Number.isInteger(id) || id < 1) return null;
const episode = Number(item.episode) || 0;
const href = episode > 0 ? playHref(id, episode, item.slug) : undefined;
return {
kind: "drama",
id: String(id),
slug: item.slug,
title: (item.title || "").trim() || "未命名短剧",
subtitle: (item.desc || "").trim(),
tags: pickPlayletCardTags(item),
heat: formatHeat(item.heatValue ?? item.countBrowse),
episodes: episode > 0 ? `看到第${episode}集` : item.totalEpisode ? `全${item.totalEpisode}集` : "",
cover: (item.cover || "").trim(),
href,
};
}
/** 视频历史 → 卡片展示 */
export function mapVideoHistoryItem(item: VideoHistoryItem): ShortDramaItem | null {
const id = Number(item.id);
if (!Number.isInteger(id) || id < 1) return null;
return {
kind: "drama",
id: String(id),
slug: item.slug,
title: (item.title || "").trim() || "未命名视频",
subtitle: (item.desc || "").trim(),
tags: pickVideoCardTags(item),
heat: formatHeat(item.watchNum),
episodes: formatWatchedAt(item.watchedAt),
cover: (item.coverImg || "").trim(),
href: videoHref(String(id), item.slug),
};
}
+310
View File
@@ -0,0 +1,310 @@
"use client";
import { AlertDialog, Button, toast } from "@heroui/react";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { EmptyState } from "@/components/common/empty-state";
import { Pagination } from "@/components/common/pagination";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import { H5ShortDramaCard, ShortDramaCard } from "@/components/short-drama/short-drama-card";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { H5VideoCard, VideoCard } from "@/components/video/video-card";
import { ApiError } from "@/lib/api/client";
import { clearPlayletHistory, getPlayletHistoryList } from "@/lib/api/playlet-actions";
import { clearVideoHistory, getVideoHistoryList } from "@/lib/api/video-actions";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import {
HISTORY_PAGE_SIZE,
historyTypes,
mapPlayletHistoryItem,
mapVideoHistoryItem,
type HistoryType,
} from "./data";
async function resolveToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
/** 观看历史:短剧 GET /api/app/playlet/history/list · 视频 GET /api/app/video/history/list */
export function HistoryPage() {
const router = useRouter();
const topRef = useRef<HTMLDivElement>(null);
const [type, setType] = useState<HistoryType>("短剧");
const [page, setPage] = useState(1);
const [items, setItems] = useState<ShortDramaItem[]>([]);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(true);
const [clearing, setClearing] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
void (async () => {
await Promise.resolve();
if (cancelled) return;
setLoading(true);
setError(null);
try {
const token = await resolveToken();
if (cancelled) return;
if (!token) {
setItems([]);
setTotalPages(1);
setError("请先登录后查看观看历史");
return;
}
if (type === "短剧") {
const data = await getPlayletHistoryList({ pageNum: page, pageSize: HISTORY_PAGE_SIZE }, token);
if (cancelled) return;
const list = (data?.list ?? [])
.map(mapPlayletHistoryItem)
.filter((item): item is ShortDramaItem => Boolean(item));
const total = Number(data?.totalCount) || 0;
setItems(list);
setTotalPages(Math.max(1, Math.ceil(total / HISTORY_PAGE_SIZE) || 1));
} else {
const data = await getVideoHistoryList({ pageNum: page, pageSize: HISTORY_PAGE_SIZE }, token);
if (cancelled) return;
const list = (data?.list ?? [])
.map(mapVideoHistoryItem)
.filter((item): item is ShortDramaItem => Boolean(item));
const total = Number(data?.totalCount) || 0;
setItems(list);
setTotalPages(Math.max(1, Math.ceil(total / HISTORY_PAGE_SIZE) || 1));
}
} catch (err) {
if (cancelled) return;
setItems([]);
setTotalPages(1);
setError(err instanceof ApiError ? err.message : "加载失败,请稍后重试");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [type, page, reloadKey]);
const changeType = (next: HistoryType) => {
setType(next);
setPage(1);
};
const changePage = (next: number) => {
setPage(next);
topRef.current?.scrollIntoView({ behavior: "smooth" });
};
const openClearConfirm = () => {
if (clearing || items.length === 0) return;
setConfirmOpen(true);
};
const clearHistory = async () => {
if (clearing || items.length === 0) return;
const label = type === "短剧" ? "短剧" : "视频";
const token = await resolveToken();
if (!token) {
setConfirmOpen(false);
toast.danger("请先登录后再清空");
return;
}
setClearing(true);
setError(null);
try {
// DELETE /api/app/playlet/history · DELETE /api/app/video/history
const result = type === "短剧" ? await clearPlayletHistory(token) : await clearVideoHistory(token);
const deleted = Number(result?.deletedCount) || 0;
setItems([]);
setTotalPages(1);
setPage(1);
setConfirmOpen(false);
setReloadKey((value) => value + 1);
toast.success(deleted > 0 ? `已清空${deleted}条${label}历史` : `${label}历史已清空`);
} catch (err) {
const message = err instanceof ApiError ? err.message : "清空失败,请稍后重试";
setError(message);
toast.danger(message);
} finally {
setClearing(false);
}
};
const goBack = () => {
if (window.history.length > 1) router.back();
else router.push("/profile");
};
const isVideo = type === "视频";
const PcCard = isVideo ? VideoCard : ShortDramaCard;
const MobileCard = isVideo ? H5VideoCard : H5ShortDramaCard;
const empty = !loading && !error && items.length === 0;
const canClear = !clearing && items.length > 0;
const clearLabel = type === "短剧" ? "短剧" : "视频";
return (
<main data-component="history-page" className="flex-1 bg-[#07060D] lg:bg-[#070611]">
<div className="relative flex h-12 items-center justify-center px-4 lg:hidden">
<button type="button" aria-label="返回" onClick={goBack} className="cursor-pointer absolute left-4 size-6">
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
<path d="M15 5l-7 7 7 7" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<h1 className="text-base leading-[22px] font-bold text-white">观看历史</h1>
<button
type="button"
onClick={openClearConfirm}
disabled={!canClear}
className="cursor-pointer absolute right-4 text-xs leading-[18px] font-bold text-[#F12B9E] disabled:opacity-40"
>
{clearing ? "清空中" : "清空"}
</button>
</div>
<div
ref={topRef}
data-pc-scale
className="mx-auto flex w-full scroll-mt-24 flex-col gap-3 px-4 py-3 lg:w-[1700px] lg:gap-5 lg:px-0 lg:py-[30px] lg:[zoom:var(--page-scale,1)]"
>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: "个人中心", href: "/profile" },
{ name: "观看历史" },
]}
/>
<div className="hidden items-center lg:flex">
<div className="flex flex-1 flex-col gap-px">
<h2 className="text-[30px] leading-9 font-semibold text-white">观看历史</h2>
<p className="text-sm leading-5 text-[#9790A6]">找回最近观看的剧集</p>
</div>
<button
type="button"
onClick={openClearConfirm}
disabled={!canClear}
className="cursor-pointer h-9 w-24 rounded-[18px] bg-white/6 text-xs leading-[18px] font-semibold text-[#B5AEC1] transition-colors hover:text-white disabled:opacity-40"
>
{clearing ? "清空中" : "清空记录"}
</button>
</div>
<div className="flex gap-3 lg:gap-[9px]">
{historyTypes.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === type}
onClick={() => changeType(option)}
className={`cursor-pointer flex h-8 items-center justify-center rounded-2xl px-4 text-sm leading-5 transition-colors lg:w-[88px] lg:text-xs lg:leading-[18px] ${
option === type ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
}`}
>
{option}
</button>
))}
</div>
{loading ? (
<p className="py-16 text-center text-sm text-[#948CA8]">加载中…</p>
) : error ? (
<EmptyState
title={error}
description="请检查网络后重试"
onReload={() => setReloadKey((value) => value + 1)}
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
/>
) : empty ? (
<EmptyState
title="暂无观看记录"
description="去首页发现更多好剧吧"
onReload={null}
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
/>
) : (
<>
<div
className={`hidden justify-between gap-x-5 gap-y-6 lg:grid ${
isVideo ? "grid-cols-[repeat(auto-fill,324px)]" : "grid-cols-[repeat(auto-fill,266px)]"
}`}
>
{items.map((item) => (
<PcCard key={item.id} item={item} />
))}
</div>
<div className="grid grid-cols-2 gap-x-[7px] gap-y-3 lg:hidden">
{items.map((item) => (
<MobileCard key={item.id} item={item} showEpisodes />
))}
</div>
<Pagination page={page} total={totalPages} onChange={changePage} showJump={false} />
</>
)}
</div>
<AlertDialog
isOpen={confirmOpen}
onOpenChange={(open) => {
if (clearing) return;
setConfirmOpen(open);
}}
>
<AlertDialog.Backdrop variant="opaque" className="bg-black/70">
<AlertDialog.Container placement="center" size="sm">
<AlertDialog.Dialog className="w-[min(100vw-32px,360px)] rounded-2xl bg-[#211F2E] p-5 text-white shadow-[0_12px_40px_rgba(241,43,158,0.18)] outline outline-1 -outline-offset-1 outline-[#3A3652]">
<AlertDialog.Header className="mb-3 flex flex-col items-start gap-3">
<AlertDialog.Icon
status="danger"
className="size-9 rounded-full bg-[#F12B9E]/15 text-[#F12B9E] outline outline-1 -outline-offset-1 outline-[#F12B9E]/55"
/>
<AlertDialog.Heading className="text-lg leading-6 font-semibold text-white">
清空观看历史
</AlertDialog.Heading>
</AlertDialog.Header>
<AlertDialog.Body className="mb-5">
<p className="text-sm leading-5 text-[#B8B0C8]">
确定清空全部{clearLabel}观看历史?此操作不可恢复。
</p>
</AlertDialog.Body>
<AlertDialog.Footer className="flex items-center justify-end gap-3">
<Button
variant="ghost"
isDisabled={clearing}
onPress={() => setConfirmOpen(false)}
className="h-9 min-w-[72px] rounded-full px-4 text-sm text-[#C8C0D6] hover:bg-white/8 hover:text-white"
>
取消
</Button>
<Button
isDisabled={clearing}
onPress={() => void clearHistory()}
className="h-9 min-w-[96px] rounded-full bg-[#F12B9E] px-4 text-sm font-medium text-white hover:bg-[#F23DA8] disabled:opacity-60"
>
{clearing ? "清空中…" : "确定清空"}
</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
</AlertDialog.Container>
</AlertDialog.Backdrop>
</AlertDialog>
</main>
);
}
+336
View File
@@ -0,0 +1,336 @@
export type BadgeTone = "hot" | "fresh" | "original";
export type Drama = {
id: string;
title: string;
subtitle: string;
tags: string[];
heat: string;
episodes: string;
badge?: BadgeTone;
cover: string;
};
export type Spotlight = {
id: string;
title: string;
summary: string;
category: string;
heat: string;
episodes: string;
cover: string;
titleLines: 1 | 2;
};
export type RankItem = {
rank: number;
title: string;
heat: string;
};
export type RankBoard = {
id: string;
title: string;
subtitle: string;
cover: string;
items: RankItem[];
};
/** 本地 mock 不再使用 picsum;无封面时留空 */
const cover = (..._args: [string, number, number]) => {
void _args;
return "";
};
export const heroSlides = [
{
id: "reunion",
title: "标题文字信息",
tags: ["爱情", "都市爱情", "破镜重圆", "故人重逢"],
summary:
"简介:七年前,顾星晚因旧事入狱;多年后与宴矜重逢,他却称早已不认识她。未说出口的秘密与过往纠葛,再次将两人牵引到一起。七年前,顾星晚因旧事入狱;多年后与宴矜重逢,他",
cover: cover("haijiao-hero-1", 1700, 725),
thumb: cover("haijiao-hero-1", 208, 117),
},
{
id: "sunlight",
title: "陷落阳光",
tags: ["都市", "重生", "甜宠"],
summary:
"简介:她在最亮的正午醒来,名字、住址和那段没说完的话都换了主人。唯一没变的,是街角还在等她的那个人。",
cover: cover("haijiao-hero-2", 1700, 725),
thumb: cover("haijiao-hero-2", 208, 117),
},
{
id: "jianghu",
title: "江湖好梦",
tags: ["古装", "权谋", "情仇"],
summary:
"简介:一纸旧约把她送回灯火未灭的城。仇人成了座上宾,故人站在对面,这一局她决定自己落子。",
cover: cover("haijiao-hero-3", 1700, 725),
thumb: cover("haijiao-hero-3", 208, 117),
},
] as const;
export const noticeText = "精彩短剧持续更新,发现好故事,开启你的追剧时光。";
export const adItems = Array.from({ length: 16 }, (_, index) => ({
id: `ad-${index + 1}`,
title: "广告标题",
cover: cover(`haijiao-ad-${index + 1}`, 160, 160),
}));
const drama = (
id: string,
badge: BadgeTone | undefined,
seed: string,
size: "wide" | "card",
): Drama => ({
id,
title: "有恩必报",
subtitle: "权谋交锋爱意藏在局中",
tags: ["标签"],
heat: "25.8万 热度",
episodes: "30集",
badge,
cover: cover(seed, size === "wide" ? 553 : 410, size === "wide" ? 355 : 215),
});
export const mosaicDramas: Drama[] = [
drama("mosaic-1", "hot", "haijiao-mosaic-1", "wide"),
drama("mosaic-2", "hot", "haijiao-mosaic-2", "wide"),
drama("mosaic-3", "hot", "haijiao-mosaic-3", "wide"),
drama("mosaic-4", "hot", "haijiao-mosaic-4", "wide"),
];
export const themeBlocks = [
{
id: "theme-a",
title: "标题标题A",
subtitle: "副标题,我是一个副标题",
cover: cover("haijiao-theme-a", 640, 480),
},
{
id: "theme-b",
title: "标题标题B",
subtitle: "副标题,我是一个副标题",
cover: cover("haijiao-theme-b", 640, 480),
},
] as const;
export const promoBanners = [
cover("haijiao-banner-a", 840, 97),
cover("haijiao-banner-b", 840, 97),
];
const hotBadges: BadgeTone[] = ["hot", "fresh", "original", "hot", "fresh", "original", "hot", "fresh"];
export const hotDramas: Drama[] = hotBadges.map((badge, index) =>
drama(`hot-${index + 1}`, badge, `haijiao-hot-${index + 1}`, "card"),
);
export const themeTiles = [
{ id: "twist", title: "高能反转", count: "23部剧情", cover: cover("haijiao-tile-1", 410, 231) },
{ id: "dream", title: "江湖好梦", count: "23部剧情", cover: cover("haijiao-tile-2", 410, 231) },
{ id: "heart", title: "怦然心动", count: "23部剧情", cover: cover("haijiao-tile-3", 410, 231) },
{ id: "city", title: "都市新生", count: "23部剧情", cover: cover("haijiao-tile-4", 410, 231) },
] as const;
export const spotlights: Spotlight[] = [
{
id: "spot-1",
title: "我是一个大标题很长的大标题",
summary: "久别重逢,命运再次展现,正午阳光天气正好,一切都刚刚好最多显示三行",
category: "仙侠|团宠",
heat: "热度 95.6万",
episodes: "30集",
cover: cover("haijiao-spot-1", 247, 353),
titleLines: 1,
},
{
id: "spot-2",
title: "我是一个大标题大标题显示两行",
summary: "久别重逢,命运再次展现,正午阳光天气正好,一切都刚刚好最多显示三行",
category: "仙侠|团宠",
heat: "热度 95.6万",
episodes: "30集",
cover: cover("haijiao-spot-2", 247, 353),
titleLines: 2,
},
{
id: "spot-3",
title: "陷落阳光",
summary: "久别重逢,命运再次展现,正午阳光天气正好,一切都刚刚好最多显示三行",
category: "仙侠|团宠",
heat: "热度 95.6万",
episodes: "30集",
cover: cover("haijiao-spot-3", 247, 353),
titleLines: 1,
},
{
id: "spot-4",
title: "我是一个大标题大标题显示两行",
summary: "久别重逢,命运再次展现,正午阳光天气正好,一切都刚刚好最多显示三行",
category: "仙侠|团宠",
heat: "热度 95.6万",
episodes: "30集",
cover: cover("haijiao-spot-4", 247, 353),
titleLines: 2,
},
{
id: "spot-5",
title: "我是一个大标题很长的大标题",
summary: "久别重逢,命运再次展现,正午阳光天气正好,一切都刚刚好最多显示三行",
category: "仙侠|团宠",
heat: "热度 95.6万",
episodes: "30集",
cover: cover("haijiao-spot-5", 247, 353),
titleLines: 1,
},
{
id: "spot-6",
title: "我是一个大标题大标题显示两行",
summary: "久别重逢,命运再次展现,正午阳光天气正好,一切都刚刚好最多显示三行",
category: "仙侠|团宠",
heat: "热度 95.6万",
episodes: "30集",
cover: cover("haijiao-spot-6", 247, 353),
titleLines: 2,
},
];
export const rankedDramas: Drama[] = [1, 2, 3, 4].map((rank) => ({
...drama(`rank-${rank}`, "hot", `haijiao-rank-${rank}`, "card"),
title: "有恩必报",
}));
export const featuredSlides = [
{
id: "feature-1",
title: "我是一个大标题",
subtitle: "权谋交锋爱意藏在局中",
tags: ["标签", "标签", "标签"],
heat: "25.8万 热度",
cover: cover("haijiao-feature-1", 600, 341),
side: cover("haijiao-feature-1-side", 528, 300),
},
{
id: "feature-2",
title: "陷落阳光",
subtitle: "正午重逢,名字却先一步认错了人",
tags: ["都市", "重生", "甜宠"],
heat: "18.2万 热度",
cover: cover("haijiao-feature-2", 600, 341),
side: cover("haijiao-feature-2-side", 528, 300),
},
{
id: "feature-3",
title: "江湖好梦",
subtitle: "灯还没灭,旧约已经换了主人",
tags: ["古装", "权谋", "情仇"],
heat: "32.4万 热度",
cover: cover("haijiao-feature-3", 600, 341),
side: cover("haijiao-feature-3-side", 528, 300),
},
] as const;
const boardItems: RankItem[] = [
{ rank: 1, title: "锦绣未央", heat: "热度 95.6万" },
{ rank: 2, title: "锦绣未央", heat: "热度 85.3万" },
{ rank: 3, title: "锦绣未央", heat: "热度 80.9万" },
];
export const rankBoards: RankBoard[] = [1, 2, 3, 4].map((index) => ({
id: `board-${index}`,
title: "一周口碑电影榜",
subtitle: "海角榜单",
cover: cover(`haijiao-board-${index}`, 410, 644),
items: boardItems,
}));
export const friendLinks = [
"4399小游戏",
"4399游戏盒",
"4399游戏吧",
"4399手机游戏网",
"4399网页游戏",
"4399游戏资讯",
"4399手游论坛",
"4399手机开放平台",
"4399游戏盒推荐",
"4399在线玩",
];
const posterPattern: Pick<Drama, "badge">[] = [
{ badge: "original" },
{ badge: "hot" },
{ badge: "hot" },
{ badge: "fresh" },
];
const poster = (id: string, index: number, pattern: Pick<Drama, "badge">): Drama => ({
id,
title: "他来时盛夏",
subtitle: "权谋交锋爱意藏在局中",
tags: ["标签"],
heat: "25.8万 热度",
episodes: "24集",
...pattern,
cover: cover(`haijiao-${id}-${index}`, 336, 448),
});
export const h5PosterGroups = ["样式A", "样式B", "样式C"].map((title, group) => ({
title,
dramas: posterPattern.map((pattern, index) => poster(`h5-${group}-${index}`, index, pattern)),
}));
const rankPattern: BadgeTone[] = ["original", "hot", "fresh", "original", "fresh", "original"];
export const h5RankedDramas: Drama[] = rankPattern.map((badge, index) =>
poster(`h5-rank-${index}`, index, { badge }),
);
export const h5HotPicks = Array.from({ length: 6 }, (_, index) => ({
id: `h5-pick-${index + 1}`,
rank: index + 1,
label: "仙侠精选",
title: "锦绣未央",
summary: "简介:久别重逢,命运再次最多显示两行",
category: "仙侠|团宠",
heat: "95.6万",
episodes: "30集",
cover: cover(`haijiao-h5-pick-${index + 1}`, 304, 166),
}));
export const h5FeaturedSlides = [
{
id: "h5-feature-1",
title: "短剧标题",
summary: "简介:久别重逢,命运再次展现,正午阳光天气正好,一切都刚刚好,故事从这一刻重新开始。",
cover: cover("haijiao-h5-feature-1", 494, 692),
},
{
id: "h5-feature-2",
title: "陷落阳光",
summary: "简介:她在最亮的正午醒来,名字、住址和那段没说完的话都换了主人。",
cover: cover("haijiao-h5-feature-2", 494, 692),
},
{
id: "h5-feature-3",
title: "江湖好梦",
summary: "简介:一纸旧约把她送回灯火未灭的城,这一局她决定自己落子。",
cover: cover("haijiao-h5-feature-3", 494, 692),
},
{
id: "h5-feature-4",
title: "锦绣未央",
summary: "简介:权谋交锋,爱意藏在局中,每一步都是试探。",
cover: cover("haijiao-h5-feature-4", 494, 692),
},
{
id: "h5-feature-5",
title: "他来时盛夏",
summary: "简介:那年盛夏他回到小城,所有没说出口的话都有了答案。",
cover: cover("haijiao-h5-feature-5", 494, 692),
},
];
+94
View File
@@ -0,0 +1,94 @@
import Link from "next/link";
import { AdBanners } from "@/components/common/ads/ad-banners";
import { CardTags } from "@/components/common/card-tags";
import {
MEDIA_CARD_COVER,
MEDIA_CARD_DESC,
MEDIA_CARD_EPISODES,
MEDIA_CARD_META,
MEDIA_CARD_ROOT,
MEDIA_CARD_TITLE_PC,
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";
type DramaCardProps = {
drama: Drama;
size?: "wide" | "card";
rank?: string;
};
export function DramaCard({ drama, size = "card", rank }: DramaCardProps) {
const isWide = size === "wide";
const detailHref = dramaHref(drama.id);
return (
<article data-component="drama-card" className={MEDIA_CARD_ROOT}>
<Link href={detailHref} className="block">
<div className={`relative overflow-hidden rounded-t-lg ${isWide ? "h-[355px]" : "h-[215px]"}`}>
<img src={drama.cover} alt="" className={MEDIA_CARD_COVER} />
{drama.badge ? (
<div className="absolute top-4 right-4">
<Badge tone={drama.badge} />
</div>
) : null}
<span
className={`absolute right-4 bottom-4 rounded-md bg-black/60 px-3 py-2 font-medium text-white ${MEDIA_CARD_EPISODES}`}
>
{drama.episodes}
</span>
</div>
<div className={`flex flex-col gap-1.5 ${isWide ? "px-4 pt-4" : "px-2 pt-2"}`}>
<h3 className={`flex items-center gap-2 ${isWide ? MEDIA_CARD_TITLE_PC : MEDIA_CARD_TITLE_PC_MD}`}>
{rank ? <span className="media-card-title shrink-0 font-extrabold text-[#F12B9E]">{rank}</span> : null}
<span className="truncate">{drama.title}</span>
</h3>
<p className={`${MEDIA_CARD_DESC} text-white/60`}>{drama.subtitle}</p>
</div>
</Link>
<div
className={`flex items-center justify-between gap-2 ${MEDIA_CARD_META} ${isWide ? "px-4 pt-1.5 pb-4" : "px-2 pt-1 pb-2"}`}
>
<CardTags tags={drama.tags} kind="playlet" className="text-white/60" />
<span className="shrink-0 font-medium text-[#F92882]">{drama.heat}</span>
</div>
</article>
);
}
export function ThemeBlock({ title, subtitle, cover }: { title: string; subtitle: string; cover: string }) {
return (
<article className="relative h-[475px] overflow-hidden rounded-lg bg-[#0C1A26]">
<img src={cover} alt="" className="absolute -bottom-8 -left-16 h-[340px] w-[330px] rotate-[-27deg] object-cover opacity-80" />
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(12,26,38,0.2),rgba(12,26,38,0.72))]" />
<div className="absolute top-24 left-12">
<h3 className="text-[54px] leading-[42px] font-bold text-[#EFF3F3]">{title}</h3>
<p className="mt-7 text-2xl leading-[21px] text-[#DDE0E3]">{subtitle}</p>
</div>
<Link
href="/more"
className="absolute bottom-[72px] left-12 inline-flex items-center gap-1.5 rounded-full border border-[#F12B9E] px-4 py-4 text-2xl leading-[21px] text-[#F12B9E]"
>
浏览更多
<span aria-hidden="true">→</span>
</Link>
</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} />;
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useState } from "react";
import { Autoplay, EffectCoverflow } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import type { Swiper as SwiperInstance } from "swiper";
import "swiper/css";
import "swiper/css/effect-coverflow";
import { ClientOnly } from "@/components/common/client-only";
import { featuredSlides } from "./data";
const loopSlides = featuredSlides.length < 6 ? [...featuredSlides, ...featuredSlides] : featuredSlides;
function FeaturedSlide({
slide,
active,
}: {
slide: (typeof featuredSlides)[number];
active: boolean;
}) {
return (
<div className="relative size-full">
<img
src={active ? slide.cover : slide.side}
alt=""
className="absolute inset-0 size-full object-cover"
/>
<div
className={`absolute inset-0 transition-colors duration-300 ${
active
? "bg-[linear-gradient(180deg,rgba(36,36,36,0.15)_18%,rgba(0,0,0,0.72)_83%)]"
: "bg-black/50"
}`}
/>
<div
className={`absolute inset-x-0 bottom-0 flex flex-col gap-1.5 px-6 py-4 transition-opacity duration-300 ${
active ? "opacity-100" : "pointer-events-none opacity-0"
}`}
>
<h3 className="text-[32px] leading-8 font-semibold text-white">{slide.title}</h3>
<p className="text-base font-medium text-white/60">{slide.subtitle}</p>
<div className="flex items-center justify-between text-sm">
<div className="flex gap-2 text-white/60">
{slide.tags.map((tag, tagIndex) => (
<span key={`${slide.id}-${tagIndex}`}>{tag}</span>
))}
</div>
<span className="text-base font-medium text-[#F92882]">{slide.heat}</span>
</div>
</div>
</div>
);
}
export function FeaturedCarousel() {
const [swiper, setSwiper] = useState<SwiperInstance | null>(null);
return (
<div data-component="featured-carousel" className="relative flex h-[420px] items-center overflow-hidden rounded-2xl bg-[#030406] bg-[url(/images/swiper_bg.png)] bg-cover bg-center bg-no-repeat px-2">
<ClientOnly
fallback={
<div className="mx-auto h-[341px] w-[min(600px,52%)] overflow-hidden rounded-2xl shadow-[0_12px_48px_rgba(0,0,0,0.55)]">
<FeaturedSlide slide={featuredSlides[0]} active />
</div>
}
>
<Swiper
modules={[Autoplay, EffectCoverflow]}
effect="coverflow"
grabCursor
centeredSlides
slidesPerView="auto"
spaceBetween={-180}
loop
speed={500}
autoplay={{ delay: 5000, disableOnInteraction: false, pauseOnMouseEnter: true }}
coverflowEffect={{
rotate: 0,
stretch: 48,
depth: 120,
modifier: 1.2,
scale: 0.78,
slideShadows: false,
}}
onSwiper={setSwiper}
className="featured-swiper h-full w-full !overflow-visible"
>
{loopSlides.map((slide, index) => (
<SwiperSlide
key={`${slide.id}-${index}`}
className="!box-border !h-[341px] !w-[min(600px,52%)] overflow-hidden rounded-2xl [&.swiper-slide-active]:z-10 [&.swiper-slide-active]:shadow-[0_12px_48px_rgba(0,0,0,0.55)]"
>
{({ isActive }) => <FeaturedSlide slide={slide} active={isActive} />}
</SwiperSlide>
))}
</Swiper>
</ClientOnly>
<button
type="button"
aria-label="上一部精选"
onClick={() => swiper?.slidePrev()}
className="absolute top-1/2 left-4 z-20 flex size-11 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border border-[#47475C]/80 bg-[#1B1B2B]/90 text-2xl leading-none text-white"
>
‹
</button>
<button
type="button"
aria-label="下一部精选"
onClick={() => swiper?.slideNext()}
className="absolute top-1/2 right-4 z-20 flex size-11 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border border-[#47475C]/80 bg-[#1B1B2B]/90 text-2xl leading-none text-white"
>
›
</button>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useSyncExternalStore } from "react";
import { openAppDownload, useAppDownloadHref } from "@/components/download/download-dialog";
function subscribe(callback: () => void) {
window.addEventListener("scroll", callback, { passive: true });
window.addEventListener("resize", callback);
return () => {
window.removeEventListener("scroll", callback);
window.removeEventListener("resize", callback);
};
}
const getPastFirstScreen = () => window.scrollY > window.innerHeight;
const getServerSnapshot = () => false;
export function FloatingActions() {
const showToTop = useSyncExternalStore(subscribe, getPastFirstScreen, getServerSnapshot);
const downloadHref = useAppDownloadHref();
return (
<div data-component="floating-actions" className="fixed right-3 bottom-20 z-40 flex flex-col items-center gap-3 lg:right-6 lg:bottom-24 lg:gap-4">
<button
type="button"
aria-label="回到顶部"
aria-hidden={!showToTop}
tabIndex={showToTop ? 0 : -1}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
className={`cursor-pointer size-11 transition-[opacity,translate] duration-300 lg:size-[60px] ${
showToTop ? "opacity-100" : "pointer-events-none translate-y-2 opacity-0"
}`}
>
<img src="/images/toTop.png" alt="" className="size-full" />
</button>
{downloadHref ? (
<a
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="立即下载"
className="size-[52px] lg:size-[68px]"
>
<img src="/images/download.png" alt="" className="size-full" />
</a>
) : (
<button
type="button"
aria-label="立即下载"
onClick={() => openAppDownload()}
className="size-[52px] cursor-pointer lg:size-[68px]"
>
<img src="/images/download.png" alt="" className="size-full" />
</button>
)}
</div>
);
}
+177
View File
@@ -0,0 +1,177 @@
import Link from "next/link";
import { CardTags } from "@/components/common/card-tags";
import {
MEDIA_CARD_COVER,
MEDIA_CARD_DESC_SM,
MEDIA_CARD_EPISODES_H5,
MEDIA_CARD_FOOTER_H5,
MEDIA_CARD_INFO_H5,
MEDIA_CARD_ROOT,
MEDIA_CARD_TITLE_H5,
} from "@/components/common/media-card-hover";
import { dramaHref } from "@/components/short-drama/data";
import type { Drama, RankBoard } from "./data";
import { h5HotPicks } from "./data";
import { Badge } from "./section-heading";
const pickRankStyle = [
"bg-[linear-gradient(225deg,#FF7663_0%,#FF26E6_100%)]",
"bg-[linear-gradient(180deg,#5E9EFF_0%,#0053CF_100%)]",
"bg-[linear-gradient(225deg,#FFD1BF_0%,#F9885A_100%)]",
];
const boardBadges = ["/images/num1.png", "/images/num2.png", "/images/num3.png"];
export function H5SectionHeading({
title,
more = true,
moreHref = "/more",
}: {
title: string;
more?: boolean;
moreHref?: string;
}) {
return (
<div data-component="h5-section-heading" className="flex w-full items-center justify-between gap-2">
<h2 className="flex min-w-0 items-center gap-1 text-lg leading-6 font-semibold text-[#F2F2FA]">
<span className="h-4 w-1.5 shrink-0 rounded-[3px] bg-[#F12B9E]" />
<span className="truncate">{title}</span>
</h2>
{more ? (
<Link href={moreHref} className="flex shrink-0 items-center gap-0.5 text-xs leading-[18px] text-white/55">
更多
<img src="/images/arrow_rught.svg" alt="" className="size-[18px]" />
</Link>
) : null}
</div>
);
}
export function H5PosterCard({ drama, rank }: { drama: Drama; rank?: string }) {
const detailHref = dramaHref(drama.id);
return (
<article data-component="h5-poster-card" className={MEDIA_CARD_ROOT}>
<Link href={detailHref} className="block">
<div className="relative aspect-[3/4] overflow-hidden rounded-t-lg">
<img src={drama.cover} alt="" className={MEDIA_CARD_COVER} />
{drama.badge ? (
<div className="absolute top-2 right-2">
<Badge tone={drama.badge} className="h-6" />
</div>
) : null}
<span
className={`absolute right-2 bottom-2 rounded-lg bg-[#08060D]/88 px-1.5 py-0.5 text-white ${MEDIA_CARD_EPISODES_H5}`}
>
{drama.episodes}
</span>
</div>
<div className={MEDIA_CARD_INFO_H5}>
<h3 className={`flex items-center gap-1 ${MEDIA_CARD_TITLE_H5}`}>
{rank ? <span className="shrink-0 text-sm leading-5 font-extrabold text-[#F12B9E]">{rank}</span> : null}
<span className="truncate">{drama.title}</span>
</h3>
<p className={`${MEDIA_CARD_DESC_SM} text-[#8C859C]`}>{drama.subtitle}</p>
</div>
</Link>
<div className={MEDIA_CARD_FOOTER_H5}>
<CardTags tags={drama.tags} kind="playlet" className="min-w-0 text-[#A89EB5]" />
<span className="shrink-0 text-[#F12B9E]">{drama.heat}</span>
</div>
</article>
);
}
export function H5PromoStrip({ covers }: { covers: readonly string[] }) {
return (
<div data-component="h5-promo-strip" className="flex flex-col gap-3">
{covers.map((cover) => (
<a key={cover} href="#h5-download" className="block h-10 overflow-hidden rounded-xl">
<img src={cover} alt="推广" className="h-full w-full object-cover" />
</a>
))}
</div>
);
}
export function H5ThemeTile({ title, count, cover }: { title: string; count: string; cover: string }) {
return (
<a data-component="h5-theme-tile" href="#h5-themes" className="relative block aspect-[168/94] overflow-hidden rounded-lg">
<img src={cover} alt="" className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_66%_60%,transparent,rgba(0,0,0,0.44))]" />
<div className="absolute bottom-2 left-2">
<h3 className="text-sm leading-5 font-medium text-[#F2F2FA]">{title}</h3>
<span className="mt-1 inline-flex rounded bg-black/50 px-1.5 py-0.5 text-xs leading-[18px] text-[#F68804]">{count}</span>
</div>
</a>
);
}
export function H5HotPickCard({ pick }: { pick: (typeof h5HotPicks)[number] }) {
const rankClass = pickRankStyle[pick.rank - 1] ?? "bg-[linear-gradient(180deg,rgba(0,0,0,0.3)_0%,rgba(0,0,0,0.5)_100%)]";
return (
<article data-component="h5-hot-pick-card" className="flex min-w-0 flex-col gap-2 rounded-lg bg-[#121729] px-2 py-4">
<div className="relative aspect-[152/83] overflow-hidden rounded-lg">
<img src={pick.cover} alt="" className="absolute inset-0 h-full w-full object-cover" />
<span className={`absolute top-0 left-0 flex h-[26px] w-7 items-center justify-center text-sm font-semibold text-white ${rankClass}`}>
{pick.rank}
</span>
<span className="absolute top-0 right-1 flex h-3.5 w-4 items-center justify-center rounded bg-[linear-gradient(139deg,#FFC41C_0%,#FF7226_100%)] text-[8px] font-bold text-white">
¥
</span>
<span className="absolute right-0 bottom-0 rounded-tl-lg bg-black/60 px-2 py-0.5 text-[10px] leading-4 text-white">{pick.episodes}</span>
</div>
<div className="flex min-w-0 flex-col items-start gap-2">
<span className="rounded bg-black/40 px-1 text-[10px] leading-[18px] text-[#F68804]">{pick.label}</span>
<h3 className="w-full truncate text-base leading-[22px] font-semibold text-[#F2F2FA]">{pick.title}</h3>
<p className="line-clamp-2 h-9 text-xs leading-[18px] text-[#A3A3B8]">{pick.summary}</p>
<div className="flex w-full items-center justify-between gap-2">
<div className="min-w-0 text-xs leading-[18px]">
<p className="truncate text-[#F68804]">{pick.category}</p>
<p className="flex items-center gap-[3px] text-[#EF2385]">
<img src="/images/hot.svg" alt="" className="h-3 w-[9px]" />
{pick.heat}
</p>
</div>
<button
type="button"
className="cursor-pointer h-8 shrink-0 rounded-lg border border-[#F12B9E]/10 bg-[#F12B9E]/10 px-2 text-xs text-white"
>
立即观看
</button>
</div>
</div>
</article>
);
}
export function H5BoardCard({ board }: { board: RankBoard }) {
return (
<article data-component="h5-board-card" className="relative flex aspect-[168/264] flex-col justify-between overflow-hidden rounded-lg px-[9px] py-[17px]">
<img src={board.cover} alt="" className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-[linear-gradient(207deg,transparent,rgba(8,15,43,0.54))]" />
<div className="relative">
<h3 className="text-lg leading-6 font-semibold text-white [text-shadow:0_4px_4px_rgba(0,0,0,0.25)]">{board.title}</h3>
<p className="text-xs leading-[18px] text-white/80">{board.subtitle}</p>
</div>
<div className="relative flex flex-col gap-0.5">
{board.items.map((item) => (
<a
key={`${board.id}-${item.rank}`}
href="#h5-ranking"
className="flex items-center gap-1 rounded bg-[#26384B]/30 px-2 py-0.5"
>
<img src={boardBadges[item.rank - 1]} alt={String(item.rank)} className="size-[15px]" />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs leading-[18px] text-white">{item.title}</span>
<span className="block text-[10px] leading-[18px] text-[#F9C006]">{item.heat}</span>
</span>
<img src="/images/up.svg" alt="" className="size-[9px]" />
</a>
))}
</div>
</article>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useState } from "react";
import { Autoplay, EffectCoverflow } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import type { Swiper as SwiperInstance } from "swiper";
import "swiper/css";
import "swiper/css/effect-coverflow";
import { ClientOnly } from "@/components/common/client-only";
import { h5FeaturedSlides } from "./data";
const count = h5FeaturedSlides.length;
const loopSlides = count < 6 ? [...h5FeaturedSlides, ...h5FeaturedSlides] : h5FeaturedSlides;
export function H5Featured() {
const [swiper, setSwiper] = useState<SwiperInstance | null>(null);
const [active, setActive] = useState(0);
const slide = h5FeaturedSlides[active];
return (
<div data-component="h5-featured" className="flex flex-col items-center gap-4">
<div className="w-full overflow-hidden">
<ClientOnly
fallback={
<img
src={h5FeaturedSlides[0].cover}
alt={h5FeaturedSlides[0].title}
className="mx-auto h-[346px] w-[247px] rounded-2xl object-cover"
/>
}
>
<Swiper
modules={[Autoplay, EffectCoverflow]}
effect="coverflow"
grabCursor
centeredSlides
slidesPerView="auto"
spaceBetween={-48}
loop
slideToClickedSlide
speed={500}
autoplay={{ delay: 5000, disableOnInteraction: false, pauseOnMouseEnter: true }}
coverflowEffect={{
rotate: 0,
stretch: 0,
depth: 100,
modifier: 1.4,
scale: 0.86,
slideShadows: false,
}}
onSwiper={setSwiper}
onSlideChange={(instance) => setActive(instance.realIndex % count)}
className="h5-featured-swiper !overflow-visible"
>
{loopSlides.map((item, index) => (
<SwiperSlide
key={`${item.id}-${index}`}
className="!box-border !h-[346px] !w-[247px] overflow-hidden rounded-2xl"
>
<img src={item.cover} alt={item.title} className="size-full object-cover" />
<span className="pointer-events-none absolute inset-0 bg-black/45 transition-opacity duration-300 [.swiper-slide-active_&]:opacity-0" />
</SwiperSlide>
))}
</Swiper>
</ClientOnly>
</div>
<div className="flex w-full flex-col items-center gap-2 text-center">
<h3 className="text-lg leading-6 font-semibold text-white">{slide.title}</h3>
<p className="line-clamp-2 text-sm leading-5 text-white/55">{slide.summary}</p>
</div>
<div className="flex items-center gap-1.5">
{h5FeaturedSlides.map((item, index) => (
<button
key={item.id}
type="button"
aria-label={`第${index + 1}部精选`}
onClick={() => swiper?.slideToLoop(index)}
className={`h-2 cursor-pointer rounded-full transition-all ${
index === active ? "w-5 bg-[#F12B9E]" : "w-2 bg-white/25"
}`}
/>
))}
</div>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Autoplay } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import { ClientOnly } from "@/components/common/client-only";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { dramaHref } from "@/components/short-drama/data";
import type { HomeBannerData } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
const pad = (value: number) => String(value).padStart(2, "0");
function bannerImage(item: HomeBannerData) {
return item.banner || item.cover || null;
}
function ApiHeroSlide({ item, focusable }: { item: HomeBannerData; focusable: boolean }) {
return (
<Link
href={dramaHref(item.id, item.slug)}
tabIndex={focusable ? 0 : -1}
className="absolute inset-0 block"
aria-label={item.title ? `查看《${item.title}》` : "查看详情"}
>
<DecryptedImage
src={bannerImage(item)}
fallback={IMG_PLACEHOLDER}
alt={item.title || ""}
priority
className="absolute inset-0 h-full w-full object-cover"
/>
<div data-component="h5-hero" className="absolute inset-0 bg-[radial-gradient(ellipse_at_80%_20%,transparent_20%,rgba(0,0,0,0.75))]" />
<div className="absolute inset-x-4 bottom-4 flex flex-col items-start gap-2 pr-14">
<h2 className="w-full truncate text-xl leading-7 font-bold text-white">{item.title}</h2>
{item.desc ? <p className="line-clamp-2 text-sm leading-5 text-white/80">{item.desc}</p> : null}
<span className="flex h-10 w-[116px] items-center justify-center rounded-[22px] bg-[#F12B9E] text-base font-medium text-white">
立即观看
</span>
</div>
</Link>
);
}
export function H5Hero({ banners }: { banners?: HomeBannerData[] | null }) {
const [active, setActive] = useState(0);
const items = banners?.length ? banners : null;
if (!items) return null;
const loop = items.length > 1;
return (
<section className="relative aspect-[343/195] w-full overflow-hidden rounded-xl">
<ClientOnly fallback={<ApiHeroSlide item={items[0]} focusable />}>
<Swiper
modules={[Autoplay]}
loop={loop}
autoplay={loop ? { delay: 5000, disableOnInteraction: false } : false}
onSlideChange={(instance) => setActive(instance.realIndex)}
className="size-full"
>
{items.map((item, index) => (
<SwiperSlide key={item.id} className="relative">
<ApiHeroSlide item={item} focusable={index === active} />
</SwiperSlide>
))}
</Swiper>
</ClientOnly>
<div className="pointer-events-none absolute right-3 bottom-3 rounded bg-black/50 px-2 py-0.5 text-xs text-white">
{pad(active + 1)}/{pad(items.length)}
</div>
</section>
);
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Autoplay, EffectFade } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import type { Swiper as SwiperInstance } from "swiper";
import "swiper/css";
import "swiper/css/effect-fade";
import { ClientOnly } from "@/components/common/client-only";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { dramaHref } from "@/components/short-drama/data";
import type { HomeBannerData } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { ChevronDownIcon, ChevronUpIcon } from "./icons";
function bannerImage(item: HomeBannerData) {
return item.banner || item.cover || null;
}
function HeroSlide({ item }: { item: HomeBannerData }) {
const tags = [...(item.themeNames ?? []), ...(item.tagNames ?? [])].filter(Boolean).slice(0, 6);
const href = dramaHref(item.id, item.slug);
return (
<Link href={href} className="absolute inset-0 block" aria-label={item.title ? `查看《${item.title}》` : "查看详情"}>
<DecryptedImage
src={bannerImage(item)}
fallback={IMG_PLACEHOLDER}
alt={item.title || ""}
priority
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 h-[206px] bg-[linear-gradient(180deg,transparent_60%,#000_100%)]" />
<div className="absolute inset-y-0 left-0 w-[42%] bg-[linear-gradient(90deg,#000_0%,transparent_100%)]" />
<div className="absolute inset-y-0 right-0 w-1/3 bg-[linear-gradient(270deg,#000_0%,transparent_100%)]" />
<div className="absolute top-[42%] left-[4%] flex w-[min(787px,52%)] -translate-y-1/2 flex-col items-start gap-4 xl:top-[343px] xl:translate-y-0">
<span className="rounded-full bg-[#F12B9E]/10 px-3 py-1 text-lg leading-6 text-[#F12B9E]">今日推荐</span>
<h2 className="text-4xl text-white">{item.title}</h2>
{tags.length ? (
<div className="flex flex-wrap gap-[7px]">
{tags.map((tag) => (
<span key={tag} className="rounded-lg bg-white/5 px-2.5 py-[5px] text-base text-white">
{tag}
</span>
))}
</div>
) : null}
{item.desc ? <p className="line-clamp-3 text-lg leading-7 text-white">{item.desc}</p> : null}
<span className="inline-flex h-[43px] w-[142px] items-center justify-center rounded-[9px] bg-[#F12B9E] text-lg leading-[18px] text-white shadow-[0_7px_17px_rgba(241,43,158,0.22)]">
播放正片
</span>
</div>
</Link>
);
}
export function HeroBanner({ banners }: { banners?: HomeBannerData[] | null }) {
const [swiper, setSwiper] = useState<SwiperInstance | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const items = banners?.length ? banners : null;
if (!items) return null;
const loop = items.length > 1;
const visibleThumbs = items.length
? [0, 1, 2].map((offset) => items[(activeIndex + offset) % items.length])
: [];
return (
<section data-component="hero-banner" className="overflow-hidden rounded-[20px] border border-[#333857] bg-[#090710]">
<div className="relative h-[520px] xl:h-[725px]">
<ClientOnly fallback={<HeroSlide item={items[0]} />}>
<Swiper
modules={[Autoplay, EffectFade]}
effect="fade"
fadeEffect={{ crossFade: true }}
loop={loop}
autoplay={loop ? { delay: 5000, disableOnInteraction: false, pauseOnMouseEnter: true } : false}
onSwiper={setSwiper}
onSlideChange={(instance) => setActiveIndex(instance.realIndex)}
className="size-full"
>
{items.map((item) => (
<SwiperSlide key={item.id} className="relative">
<HeroSlide item={item} />
</SwiperSlide>
))}
</Swiper>
</ClientOnly>
{items.length > 1 ? (
<div className="absolute top-1/2 right-8 z-10 hidden w-[208px] -translate-y-1/2 xl:block">
<button
type="button"
aria-label="上一张推荐"
onClick={() => swiper?.slidePrev()}
className="cursor-pointer absolute -top-6 left-1/2 z-10 flex size-11 -translate-x-1/2 items-center justify-center rounded-full bg-[#F12B9E]"
>
<ChevronUpIcon className="size-4 text-white" />
</button>
<div className="flex flex-col gap-5 pt-6">
{visibleThumbs.map((item, index) => (
<button
key={`${item.id}-${index}`}
type="button"
onClick={() => {
const target = items.findIndex((entry) => entry.id === item.id);
if (target < 0) return;
if (loop) swiper?.slideToLoop(target);
else swiper?.slideTo(target);
}}
className={`cursor-pointer overflow-hidden rounded-[10px] border-[3px] ${index === 0 ? "border-[#FF40A6]" : "border-white/20"}`}
>
<DecryptedImage
src={bannerImage(item)}
fallback={IMG_PLACEHOLDER}
alt={item.title || ""}
className="h-[117px] w-[208px] object-cover"
/>
</button>
))}
</div>
<button
type="button"
aria-label="下一张推荐"
onClick={() => swiper?.slideNext()}
className="cursor-pointer absolute -bottom-6 left-1/2 flex size-11 -translate-x-1/2 items-center justify-center rounded-full bg-[#F12B9E]"
>
<ChevronDownIcon className="size-4 text-white" />
</button>
</div>
) : null}
</div>
</section>
);
}
+14
View File
@@ -0,0 +1,14 @@
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} />;
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { useEffect } from "react";
import type { AdvitiseData, ConfigInfo, HomePlayletResponse } from "@/lib/api/types";
/** 调试:把首页接口数据打到浏览器控制台 */
export function HomeDataDebug({
home,
base,
}: {
home?: HomePlayletResponse | null;
base?: { advitise?: AdvitiseData; config?: ConfigInfo };
}) {
useEffect(() => {
console.log("[home] playlet/home", home);
console.log("[home] home/base", base);
}, [home, base]);
return null;
}
+354
View File
@@ -0,0 +1,354 @@
import Link from "next/link";
import { CardTags, pickPlayletCardTags, pickVideoCardTags } from "@/components/common/card-tags";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
MEDIA_CARD_CONTAINER,
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_TITLE_H5,
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 { 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 { CornerFlagBadge, resolveContentBadge } from "./section-heading";
import { isVideoDataType, unwrapHomeModuleList, videoCoverSrc } from "./home-module-style";
/** 样式 A · 视频:横版大卡(553 × 封面355 + 信息120) */
function StyleAVideoCard({ item }: { item: VideoInfo }) {
const href = videoHref(String(item.id), item.slug);
const tags = pickVideoCardTags(item);
const heat = formatHeat(item.watchNum);
return (
<article
className={`${MEDIA_CARD_CONTAINER} flex h-full w-[553px] shrink-0 flex-col group rounded-lg bg-[#110F1F] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]`}
>
<Link href={href} className="relative block h-[355px] overflow-hidden rounded-t-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"
/>
</Link>
<div className="flex min-h-[120px] flex-col gap-1.5 p-4">
<Link href={href} className={MEDIA_CARD_TITLE_PC}>
{item.title || "未命名"}
</Link>
<p className={`${MEDIA_CARD_DESC} font-medium text-white/60`}>&nbsp;</p>
<div className={`mt-auto flex items-center justify-between gap-2 font-medium ${MEDIA_CARD_META}`}>
<CardTags kind="video" tags={tags} className="text-white/60" />
<span className="shrink-0 text-[#F92882]">{heat}</span>
</div>
</div>
</article>
);
}
/** 样式 A · 短剧:竖版卡(324 × 封面432 + 信息区 ≈538) */
function StyleAPlayletCard({ item }: { item: PlayletData }) {
const href = dramaHref(item.id, item.slug);
const tags = pickPlayletCardTags(item);
const heat = formatHeat(item.heatValue ?? item.countBrowse);
const episodes = item.totalEpisode ? `${item.totalEpisode}集` : "";
const badge = resolveContentBadge(item);
return (
<article
className={`${MEDIA_CARD_CONTAINER} relative flex h-full w-[324px] shrink-0 flex-col group rounded-lg bg-[#110F1F] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]`}
>
<Link href={href} className="relative block h-[432px] w-[324px] overflow-hidden rounded-t-lg">
<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"
/>
<CornerFlagBadge tone={badge} className="absolute top-5 right-4 z-[1]" />
{episodes ? (
<span
className={`absolute right-2 bottom-2 rounded-md bg-black/60 px-3 py-2 font-medium text-white ${MEDIA_CARD_EPISODES}`}
>
{episodes}
</span>
) : null}
</Link>
<div className="flex flex-col gap-1.5 px-2.5 py-3">
<Link href={href} className={MEDIA_CARD_TITLE_PC}>
{item.title || "未命名"}
</Link>
<p className={`${MEDIA_CARD_DESC} text-white/60`}>{item.desc || "\u00A0"}</p>
<div className={`flex items-center justify-between gap-2 ${MEDIA_CARD_META}`}>
<CardTags kind="playlet" tags={tags} className="text-white/60" />
<span className="shrink-0 text-[#F92882]">{heat}</span>
</div>
</div>
</article>
);
}
function BrowseMoreButton({ href }: { href: string }) {
return (
<Link
href={href}
className="inline-flex items-center justify-center gap-1.5 rounded-[40px] border border-[#F12B9E] px-4 py-4 text-2xl leading-[21px] text-[#F12B9E] transition-opacity hover:opacity-90"
>
浏览更多
<svg viewBox="0 0 24 24" className="size-6" aria-hidden="true">
<circle cx="12" cy="12" r="9.25" fill="none" stroke="currentColor" strokeWidth="1.5" />
<path d="M10.5 8.5L14 12l-3.5 3.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Link>
);
}
/** 样式 A/B · 视频横版主题块 */
function StyleAVideoTheme({
title,
subtitle,
moreHref,
}: {
title: string;
subtitle: string;
moreHref: string;
}) {
return (
<article className="relative min-h-[475px] w-[554px] shrink-0 self-stretch overflow-hidden rounded-lg bg-[#0C1A26]">
<img
src="/images/style_a_bg.png"
alt=""
className="pointer-events-none absolute bottom-0 left-0 w-[329px] max-w-none select-none"
/>
<div className="absolute top-[97px] left-12 z-[1] max-w-[420px]">
<h3 className="overflow-visible break-words bg-gradient-to-r from-[#EFF3F3] to-[#F12B9E] bg-clip-text font-['Noto_Sans_SC',sans-serif] text-[54px] leading-[64px] font-bold text-transparent">
{title}
</h3>
<p className="mt-4 font-['Noto_Sans_SC',sans-serif] text-2xl leading-[32px] font-normal text-[#DDE0E3]">
{subtitle}
</p>
</div>
<div className="absolute bottom-[72px] left-12 z-[1]">
<BrowseMoreButton href={moreHref} />
</div>
</article>
);
}
/** 样式 A · 短剧竖版主题块 */
function StyleAPlayletTheme({
title,
subtitle,
moreHref,
}: {
title: string;
subtitle: string;
moreHref: string;
}) {
return (
<article className="relative min-h-[432px] w-[324px] shrink-0 self-stretch overflow-hidden rounded-lg bg-[#091622]">
<img
src="/images/style_a_bg.png"
alt=""
className="pointer-events-none absolute bottom-0 left-0 w-[279px] max-w-none select-none"
/>
<div className="absolute top-[92px] left-12 z-[1] w-[240px]">
<h3 className="overflow-visible break-words bg-gradient-to-r from-[#EFF3F3] to-[#F12B9E] bg-clip-text font-['Noto_Sans_SC',sans-serif] text-[54px] leading-[64px] font-bold text-transparent">
{title}
</h3>
<p className="mt-4 font-['Noto_Sans_SC',sans-serif] text-2xl leading-8 font-normal text-[#DDE0E3]">
{subtitle}
</p>
</div>
<div className="absolute bottom-[72px] left-12 z-[1]">
<BrowseMoreButton href={moreHref} />
</div>
</article>
);
}
/** 样式 A/B 共用:A 主题在右,B 主题在左 */
export function HomeModuleStyleAB({
module,
themeFirst = false,
}: {
module: HomeModuleData;
themeFirst?: boolean;
}) {
const moreHref = morePageHref(module);
const title = (module.title || module.label || "推荐").trim();
const subtitle = (module.desc || "副标题,我是一个副标题").trim();
const { videos, playlets } = unwrapHomeModuleList(module.list, module.dataType);
const isVideo = isVideoDataType(module.dataType) || (videos.length > 0 && playlets.length === 0);
const styleKey = themeFirst ? "b" : "a";
if (isVideo) {
const row = videos.slice(0, 2);
const theme = (
<StyleAVideoTheme key="theme" title={title} subtitle={subtitle} moreHref={moreHref} />
);
const cards = row.map((item) => <StyleAVideoCard key={item.id} item={item} />);
return (
<div data-component={`home-module-style-${styleKey}-video`} className="flex w-full items-stretch gap-5">
{themeFirst ? [theme, ...cards] : [...cards, theme]}
</div>
);
}
const row = playlets.slice(0, 4);
const theme = (
<StyleAPlayletTheme key="theme" title={title} subtitle={subtitle} moreHref={moreHref} />
);
const cards = row.map((item) => <StyleAPlayletCard key={item.id} item={item} />);
return (
<div data-component={`home-module-style-${styleKey}-playlet`} className="flex w-full items-stretch gap-5">
{themeFirst ? [theme, ...cards] : [...cards, theme]}
</div>
);
}
/** 样式 A:卡片 + 主题(主题在右) */
export function HomeModuleStyleA({ module }: { module: HomeModuleData }) {
return <HomeModuleStyleAB module={module} themeFirst={false} />;
}
/** H5 · 短剧样式 A/B:标题 + 两列竖卡(168×224 封面,共 4 张) */
function H5StyleABPlayletCard({ item }: { item: PlayletData }) {
const href = dramaHref(item.id, item.slug);
const tags = pickPlayletCardTags(item);
const heat = formatHeat(item.heatValue ?? item.countBrowse);
const episodes = item.totalEpisode ? `${item.totalEpisode}集` : "";
const badge = resolveContentBadge(item);
return (
<article
data-component="h5-home-module-style-ab-playlet-card"
className={`${MEDIA_CARD_CONTAINER} w-full group rounded-lg bg-[#110F1F] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]`}
>
<Link href={href} className="block">
<div className="relative aspect-[168/224] overflow-hidden rounded-t-lg">
<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"
/>
<CornerFlagBadge tone={badge} className="absolute top-2 right-2 z-[1]" badgeClassName="h-6" />
{episodes ? (
<span
className={`absolute right-2 bottom-2 rounded-lg bg-[#08060D]/88 px-1.5 py-0.5 text-white ${MEDIA_CARD_EPISODES_H5}`}
>
{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.desc || "\u00A0"}</p>
</div>
</Link>
<div className={MEDIA_CARD_FOOTER_H5}>
<CardTags kind="playlet" tags={tags} className="min-w-0 text-[#A89EB5]" />
<span className="shrink-0 text-[#F12B9E]">{heat}</span>
</div>
</article>
);
}
/** H5 · 短剧样式 A/B 共用布局(无主题块) */
export function HomeModuleStyleABH5Playlet({ module }: { module: HomeModuleData }) {
const moreHref = morePageHref(module);
const title = (module.title || module.label || "推荐").trim();
const { playlets } = unwrapHomeModuleList(module.list, module.dataType);
if (!playlets.length) return null;
return (
<div data-component="home-module-style-ab-h5-playlet" className="flex w-full flex-col items-start gap-[18px]">
<H5SectionHeading title={title} moreHref={moreHref} />
<div className="grid w-full grid-cols-2 gap-x-[7px] gap-y-3">
{playlets.slice(0, 4).map((item) => (
<H5StyleABPlayletCard key={item.id} item={item} />
))}
</div>
</div>
);
}
/** H5 · 视频样式 A/B:横版卡(168×96 封面) */
function H5StyleABVideoCard({ item }: { item: VideoInfo }) {
const href = videoHref(String(item.id), item.slug);
const tags = pickVideoCardTags(item);
const heat = formatHeat(item.watchNum);
return (
<article
data-component="h5-home-module-style-ab-video-card"
className={`${MEDIA_CARD_CONTAINER} w-full group rounded-lg bg-[#110F1F] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]`}
>
<Link href={href} className="block">
<div className="relative aspect-[168/96] overflow-hidden rounded-t-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"
/>
</div>
<div className={MEDIA_CARD_INFO_H5}>
<h3 className={MEDIA_CARD_TITLE_H5}>{item.title || "未命名"}</h3>
<p className={`${MEDIA_CARD_DESC_SM} text-[#8C859C]`}>&nbsp;</p>
</div>
</Link>
<div className={MEDIA_CARD_FOOTER_H5}>
<CardTags kind="video" tags={tags} className="min-w-0 text-[#A89EB5]" />
<span className="shrink-0 text-[#F12B9E]">{heat}</span>
</div>
</article>
);
}
/** H5 · 视频样式 A/B 共用布局:标题 + 两列三卡(共 6 张横版) */
export function HomeModuleStyleABH5Video({ module }: { module: HomeModuleData }) {
const moreHref = morePageHref(module);
const title = (module.title || module.label || "推荐").trim();
const { videos } = unwrapHomeModuleList(module.list, module.dataType);
const items = videos.slice(0, 6);
if (!items.length) return null;
const mid = Math.ceil(items.length / 2);
const left = items.slice(0, mid);
const right = items.slice(mid);
return (
<div data-component="home-module-style-ab-h5-video" className="flex w-full flex-col items-start gap-[18px]">
<H5SectionHeading title={title} moreHref={moreHref} />
<div className="flex w-full items-start gap-[7px]">
<div className="flex min-w-0 flex-1 flex-col gap-3">
{left.map((item) => (
<H5StyleABVideoCard key={item.id} item={item} />
))}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3">
{right.map((item) => (
<H5StyleABVideoCard key={item.id} item={item} />
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,8 @@
import type { HomeModuleData } from "@/lib/api/types";
import { HomeModuleStyleAB } from "./home-module-style-a";
/** 样式 B:主题 + 卡片(主题在左,与 A 镜像) */
export function HomeModuleStyleB({ module }: { module: HomeModuleData }) {
return <HomeModuleStyleAB module={module} themeFirst />;
}
+137
View File
@@ -0,0 +1,137 @@
import Link from "next/link";
import { CardTags, pickPlayletCardTags, pickVideoCardTags } from "@/components/common/card-tags";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
MEDIA_CARD_CONTAINER,
MEDIA_CARD_DESC,
MEDIA_CARD_EPISODES,
MEDIA_CARD_META,
MEDIA_CARD_TITLE_PC,
} from "@/components/common/media-card-hover";
import { formatHeat } from "@/components/ranking/data";
import { dramaHref } from "@/components/short-drama/data";
import { videoHref } from "@/components/video/video-card";
import type { HomeModuleData, PlayletData, VideoInfo } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { CornerFlagBadge, resolveContentBadge } from "./section-heading";
import { isVideoDataType, unwrapHomeModuleList, videoCoverSrc } from "./home-module-style";
/** 样式 C · 视频横版卡(410 × 封面215 + 信息93) */
function StyleCVideoCard({ item }: { item: VideoInfo }) {
const href = videoHref(String(item.id), item.slug);
const tags = pickVideoCardTags(item);
const heat = formatHeat(item.watchNum);
return (
<article
className={`${MEDIA_CARD_CONTAINER} relative flex w-[410px] shrink-0 flex-col group rounded-lg bg-[#110F1F] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]`}
>
<Link href={href} className="relative block h-[215px] overflow-hidden rounded-t-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"
/>
</Link>
<div className="flex min-h-[93px] flex-col justify-center gap-1 px-[7px] py-[9px]">
<Link href={href} className={MEDIA_CARD_TITLE_PC}>
{item.title || "未命名"}
</Link>
<p className={`${MEDIA_CARD_DESC} text-white/60`}>&nbsp;</p>
<div className={`flex items-center justify-between gap-2 font-medium ${MEDIA_CARD_META}`}>
<CardTags kind="video" tags={tags} className="text-white/60" />
<span className="shrink-0 text-[#F92882]">{heat}</span>
</div>
</div>
</article>
);
}
/** 样式 C · 短剧竖版卡(266 × 封面355) */
function StyleCPlayletCard({ item }: { item: PlayletData }) {
const href = dramaHref(item.id, item.slug);
const tags = pickPlayletCardTags(item);
const heat = formatHeat(item.heatValue ?? item.countBrowse);
const episodes = item.totalEpisode ? `${item.totalEpisode}集` : "";
const badge = resolveContentBadge(item);
return (
<article
className={`${MEDIA_CARD_CONTAINER} relative flex w-[266px] shrink-0 flex-col group rounded-lg bg-[#110F1F] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]`}
>
<Link href={href} className="relative block h-[355px] overflow-hidden rounded-t-lg">
<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"
/>
<CornerFlagBadge tone={badge} />
{episodes ? (
<span
className={`absolute right-2 bottom-2 rounded-md bg-black/60 px-3 py-2 font-medium text-white ${MEDIA_CARD_EPISODES}`}
>
{episodes}
</span>
) : null}
</Link>
<div className="flex flex-col gap-1.5 px-2.5 py-3">
<Link href={href} className={MEDIA_CARD_TITLE_PC}>
{item.title || "未命名"}
</Link>
<p className={`${MEDIA_CARD_DESC} text-white/60`}>{item.desc || "\u00A0"}</p>
<div className={`flex items-center justify-between gap-2 ${MEDIA_CARD_META}`}>
<CardTags kind="playlet" tags={tags} className="text-white/60" />
<span className="shrink-0 text-[#F92882]">{heat}</span>
</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;
}
/** 样式 C:标题区外置;视频 4×2 横版 / 短剧 6×2 竖版 */
export function HomeModuleStyleC({ 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 rowItems = videos.slice(0, 8);
const rows = chunkRows(rowItems, 4);
return (
<div data-component="home-module-style-c-video" className="flex flex-col gap-5">
{rows.map((row, rowIndex) => (
<div key={rowIndex} className="flex items-start gap-5">
{row.map((item) => (
<StyleCVideoCard key={item.id} item={item} />
))}
</div>
))}
</div>
);
}
const rowItems = playlets.slice(0, 12);
const rows = chunkRows(rowItems, 6);
return (
<div data-component="home-module-style-c-playlet" className="flex flex-col gap-5">
{rows.map((row, rowIndex) => (
<div key={rowIndex} className="flex w-full items-center justify-between gap-5">
{row.map((item) => (
<StyleCPlayletCard key={item.id} item={item} />
))}
</div>
))}
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import Link from "next/link";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { MEDIA_CARD_TITLE_PC } from "@/components/common/media-card-hover";
import { morePageHref } from "@/components/more/href";
import { topicDetailHref } from "@/components/topic/href";
import type { HomeModuleData, HomeTopicData } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { H5SectionHeading } from "./h5-cards";
import { isVideoDataType, topicCountLabel, topicCoverSrc } from "./home-module-style";
function StyleDThemeCard({ topic, dataType }: { topic: HomeTopicData; dataType?: number | null }) {
const href = topicDetailHref(topic, isVideoDataType(dataType) ? 2 : 1);
const countLabel = topicCountLabel(topic, dataType);
const cover = topicCoverSrc(topic);
return (
<Link
href={href}
data-property-1="PC 横版4幅"
className="group relative block h-[231px] w-[410px] shrink-0 rounded-lg transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]"
>
<div className="absolute inset-0 overflow-hidden rounded-lg">
<DecryptedImage
src={cover}
fallback={IMG_PLACEHOLDER}
alt={topic.name || ""}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-105"
/>
<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>
{/* 设计:数量角标在上(top≈141),标题在下(top≈175) */}
<div className="absolute bottom-6 left-[35px] z-[1] flex max-w-[340px] flex-col items-start gap-1.5">
{countLabel ? (
<span className="inline-flex h-[26px] items-center justify-center rounded bg-black/50 px-2.5 text-xs leading-[18px] text-[#F68804]">
{countLabel}
</span>
) : null}
<h3 className={`text-[#F2F2FA] ${MEDIA_CARD_TITLE_PC}`}>{topic.name}</h3>
</div>
</Link>
);
}
/** 样式 D:统一主题 · PC 横版前 4 幅(topicList) */
export function HomeModuleStyleD({ module }: { module: HomeModuleData }) {
const topics = (module.topicList ?? []).filter((topic) => topic.name).slice(0, 4);
if (!topics.length) return null;
return (
<div data-component="home-module-style-d" className="flex w-full items-start justify-start gap-5">
{topics.map((topic) => (
<StyleDThemeCard key={topic.id} topic={topic} dataType={module.dataType} />
))}
</div>
);
}
/** H5 · 样式 D 主题卡(168×94) */
function H5StyleDThemeCard({ topic, dataType }: { topic: HomeTopicData; dataType?: number | null }) {
const href = topicDetailHref(topic, isVideoDataType(dataType) ? 2 : 1);
const countLabel = topicCountLabel(topic, dataType);
const cover = topicCoverSrc(topic);
return (
<Link
href={href}
data-component="h5-home-module-style-d-card"
className="group relative block aspect-[168/94] w-full rounded-lg transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]"
>
<div className="absolute inset-0 overflow-hidden rounded-lg">
<DecryptedImage
src={cover}
fallback={IMG_PLACEHOLDER}
alt={topic.name || ""}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-105"
/>
<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.44)_100%)]" />
</div>
<div className="absolute bottom-2 left-2 right-2 z-[1] flex flex-col items-start gap-1">
{countLabel ? (
<span className="inline-flex items-center justify-center rounded bg-black/50 px-2.5 py-1 text-xs leading-[18px] text-[#F68804]">
{countLabel}
</span>
) : null}
<h3 className="truncate text-sm leading-5 font-medium text-[#F2F2FA]">{topic.name}</h3>
</div>
</Link>
);
}
/** H5 · 样式 D:标题 + 两列前四主题横卡 */
export function HomeModuleStyleDH5({ module }: { module: HomeModuleData }) {
const moreHref = morePageHref(module);
const title = (module.title || module.label || "推荐").trim();
const topics = (module.topicList ?? []).filter((topic) => topic.name).slice(0, 4);
if (!topics.length) return null;
return (
<div data-component="home-module-style-d-h5" className="flex w-full flex-col items-start gap-[18px]">
<H5SectionHeading title={title} moreHref={moreHref} />
<div className="grid w-full grid-cols-2 gap-2">
{topics.map((topic) => (
<H5StyleDThemeCard key={topic.id} topic={topic} dataType={module.dataType} />
))}
</div>
</div>
);
}
+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>
);
}
+261
View File
@@ -0,0 +1,261 @@
import Link from "next/link";
import { CardTags, pickPlayletCardTags, pickVideoCardTags } from "@/components/common/card-tags";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
MEDIA_CARD_CONTAINER,
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_TITLE_H5,
MEDIA_CARD_TITLE_PC,
} from "@/components/common/media-card-hover";
import { RankNumber } from "@/components/common/rank-number";
import { formatHeat } from "@/components/ranking/data";
import { morePageHref } from "@/components/more/href";
import { dramaHref } from "@/components/short-drama/data";
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 { CornerFlagBadge, resolveContentBadge } from "./section-heading";
import { isVideoDataType, unwrapHomeModuleList, videoCoverSrc } from "./home-module-style";
function RankNo({ index, className }: { index: number; className?: string }) {
return (
<span className={`shrink-0 font-extrabold text-[#F12B9E] ${className ?? ""}`}>
{String(index + 1).padStart(2, "0")}
</span>
);
}
/** 样式 F · 视频排行横卡(410 × 封面215 + 信息93,带序号) */
function StyleFVideoCard({ item, index }: { item: VideoInfo; index: number }) {
const href = videoHref(String(item.id), item.slug);
const tags = pickVideoCardTags(item);
const heat = formatHeat(item.watchNum);
return (
<article
className={`${MEDIA_CARD_CONTAINER} relative flex w-[410px] shrink-0 flex-col group rounded-lg bg-[#110F1F] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]`}
>
<Link href={href} className="relative block h-[215px] overflow-hidden rounded-t-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"
/>
</Link>
<div className="flex min-h-[93px] flex-col justify-center gap-1 px-[7px] py-[9px]">
<div className="flex items-center gap-1.5">
<RankNo index={index} className="media-card-title shrink-0" />
<Link href={href} className={`min-w-0 flex-1 ${MEDIA_CARD_TITLE_PC}`}>
{item.title || "未命名"}
</Link>
</div>
<p className={`${MEDIA_CARD_DESC} text-white/60`}>&nbsp;</p>
<div className={`flex items-center justify-between gap-2 font-medium ${MEDIA_CARD_META}`}>
<CardTags kind="video" tags={tags} className="text-white/60" />
<span className="shrink-0 text-[#F92882]">{heat}</span>
</div>
</div>
</article>
);
}
/** 样式 F · 短剧排行竖卡(266 × 封面355,带序号) */
function StyleFPlayletCard({ item, index }: { item: PlayletData; index: number }) {
const href = dramaHref(item.id, item.slug);
const tags = pickPlayletCardTags(item);
const heat = formatHeat(item.heatValue ?? item.countBrowse);
const episodes = item.totalEpisode ? `${item.totalEpisode}集` : "";
const badge = resolveContentBadge(item);
return (
<article
className={`${MEDIA_CARD_CONTAINER} relative flex w-[266px] shrink-0 flex-col group rounded-lg bg-[#110F1F] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]`}
>
<Link href={href} className="relative block h-[355px] overflow-hidden rounded-t-lg">
<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"
/>
<CornerFlagBadge tone={badge} />
{episodes ? (
<span
className={`absolute right-2 bottom-2 rounded-md bg-black/60 px-3 py-2 font-medium text-white ${MEDIA_CARD_EPISODES}`}
>
{episodes}
</span>
) : null}
</Link>
<div className="flex flex-col gap-1.5 px-2.5 py-3">
<div className="flex items-center gap-1">
<RankNo index={index} className="media-card-title shrink-0" />
<Link href={href} className={`min-w-0 flex-1 ${MEDIA_CARD_TITLE_PC}`}>
{item.title || "未命名"}
</Link>
</div>
<p className={`${MEDIA_CARD_DESC} text-white/60`}>{item.desc || "\u00A0"}</p>
<div className={`flex items-center justify-between gap-2 ${MEDIA_CARD_META}`}>
<CardTags kind="playlet" tags={tags} className="text-white/60" />
<span className="shrink-0 text-[#F92882]">{heat}</span>
</div>
</div>
</article>
);
}
/** 样式 F:今日排行 · 视频横版 4 卡 / 短剧竖版 6 卡(带序号) */
export function HomeModuleStyleF({ module }: { module: HomeModuleData }) {
const { videos, playlets } = unwrapHomeModuleList(module.list, module.dataType);
const isVideo = isVideoDataType(module.dataType) || (videos.length > 0 && playlets.length === 0);
if (isVideo) {
return (
<div data-component="home-module-style-f-video" className="flex w-full items-start gap-5">
{videos.slice(0, 4).map((item, index) => (
<StyleFVideoCard key={item.id} item={item} index={index} />
))}
</div>
);
}
return (
<div data-component="home-module-style-f-playlet" className="flex w-full items-center justify-between gap-5">
{playlets.slice(0, 6).map((item, index) => (
<StyleFPlayletCard key={item.id} item={item} index={index} />
))}
</div>
);
}
/** H5 · 短剧样式 F:竖卡 + 标题前序号(两列六卡) */
function H5StyleFPlayletCard({ item, index }: { item: PlayletData; index: number }) {
const href = dramaHref(item.id, item.slug);
const tags = pickPlayletCardTags(item);
const heat = formatHeat(item.heatValue ?? item.countBrowse);
const episodes = item.totalEpisode ? `${item.totalEpisode}集` : "";
const badge = resolveContentBadge(item);
return (
<article
data-component="h5-home-module-style-f-playlet-card"
className={`${MEDIA_CARD_CONTAINER} w-full group rounded-lg bg-[#110F1F] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]`}
>
<Link href={href} className="block">
<div className="relative aspect-[168/224] overflow-hidden rounded-t-lg">
<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"
/>
<CornerFlagBadge tone={badge} className="absolute top-2 right-2 z-[1]" badgeClassName="h-6" />
{episodes ? (
<span
className={`absolute right-2 bottom-2 rounded-lg bg-[#08060D]/88 px-1.5 py-0.5 text-white ${MEDIA_CARD_EPISODES_H5}`}
>
{episodes}
</span>
) : null}
</div>
<div className={MEDIA_CARD_INFO_H5}>
<h3 className={`flex items-center gap-1 ${MEDIA_CARD_TITLE_H5}`}>
<RankNumber rank={index + 1} className="text-sm leading-5" />
<span className="min-w-0 truncate">{item.title || "未命名"}</span>
</h3>
<p className={`${MEDIA_CARD_DESC_SM} text-[#8C859C]`}>{item.desc || "\u00A0"}</p>
</div>
</Link>
<div className={MEDIA_CARD_FOOTER_H5}>
<CardTags kind="playlet" tags={tags} className="min-w-0 text-[#A89EB5]" />
<span className="shrink-0 text-[#F12B9E]">{heat}</span>
</div>
</article>
);
}
/** H5 · 短剧样式 F:标题 + 两列六卡排行 */
export function HomeModuleStyleFH5Playlet({ module }: { module: HomeModuleData }) {
const moreHref = morePageHref(module);
const title = (module.title || module.label || "推荐").trim();
const { playlets } = unwrapHomeModuleList(module.list, module.dataType);
if (!playlets.length) return null;
return (
<div data-component="home-module-style-f-h5-playlet" className="flex w-full flex-col items-start gap-[18px]">
<H5SectionHeading title={title} moreHref={moreHref} />
<div className="grid w-full grid-cols-2 gap-x-[7px] gap-y-3">
{playlets.slice(0, 6).map((item, index) => (
<H5StyleFPlayletCard key={item.id} item={item} index={index} />
))}
</div>
</div>
);
}
/** H5 · 视频样式 F:横版卡 + 标题前序号 */
function H5StyleFVideoCard({ item, index }: { item: VideoInfo; index: number }) {
const href = videoHref(String(item.id), item.slug);
const tags = pickVideoCardTags(item);
const heat = formatHeat(item.watchNum);
return (
<article
data-component="h5-home-module-style-f-video-card"
className={`${MEDIA_CARD_CONTAINER} w-full group rounded-lg bg-[#110F1F] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]`}
>
<Link href={href} className="block">
<div className="relative aspect-[168/96] overflow-hidden rounded-t-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"
/>
</div>
<div className={MEDIA_CARD_INFO_H5}>
<h3 className={`flex items-center gap-1 ${MEDIA_CARD_TITLE_H5}`}>
<RankNumber rank={index + 1} className="text-sm leading-5" />
<span className="min-w-0 truncate">{item.title || "未命名"}</span>
</h3>
<p className={`${MEDIA_CARD_DESC_SM} text-[#8C859C]`}>&nbsp;</p>
</div>
</Link>
<div className={MEDIA_CARD_FOOTER_H5}>
<CardTags kind="video" tags={tags} className="min-w-0 text-[#A89EB5]" />
<span className="shrink-0 text-[#F12B9E]">{heat}</span>
</div>
</article>
);
}
/** H5 · 视频样式 F:标题 + 两列六卡横版排行 */
export function HomeModuleStyleFH5Video({ module }: { module: HomeModuleData }) {
const moreHref = morePageHref(module);
const title = (module.title || module.label || "推荐").trim();
const { videos } = unwrapHomeModuleList(module.list, module.dataType);
if (!videos.length) return null;
return (
<div data-component="home-module-style-f-h5-video" className="flex w-full flex-col items-start gap-[18px]">
<H5SectionHeading title={title} moreHref={moreHref} />
<div className="grid w-full grid-cols-2 gap-x-[7px] gap-y-3">
{videos.slice(0, 6).map((item, index) => (
<H5StyleFVideoCard key={item.id} item={item} index={index} />
))}
</div>
</div>
);
}
+478
View File
@@ -0,0 +1,478 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Autoplay, EffectCoverflow } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import type { Swiper as SwiperInstance } from "swiper";
import "swiper/css";
import "swiper/css/effect-coverflow";
import {
cardTagCanLink,
pickPlayletCardTags,
pickVideoCardTags,
type CardTagItem,
} from "@/components/common/card-tags";
import { ClientOnly } from "@/components/common/client-only";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
MEDIA_CARD_DESC_MD,
MEDIA_CARD_META,
MEDIA_CARD_TITLE_PC_LG,
MEDIA_CARD_TITLE_PC_LG_CLAMP,
} 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 } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { H5SectionHeading } from "./h5-cards";
import { isVideoDataType, unwrapHomeModuleList, videoCoverSrc } from "./home-module-style";
type SlideItem = {
key: string;
href: string;
title: string;
desc: string;
cover: string | null;
tags: CardTagItem[];
heat: string;
};
function NavButton({
label,
onClick,
side,
}: {
label: string;
onClick: () => void;
side: "left" | "right";
}) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className={`absolute top-1/2 z-20 flex size-11 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border border-[#47475C] bg-[#1B1B2B] text-white ${
side === "left" ? "left-3" : "right-3"
}`}
>
<svg viewBox="0 0 20 20" className={`size-5 ${side === "left" ? "rotate-180" : ""}`} aria-hidden="true">
<path d="M7.5 5L12.5 10L7.5 15" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
);
}
function VideoSlide({ item, active }: { item: SlideItem; active: boolean }) {
return (
<article className="relative size-full overflow-hidden rounded-2xl">
<Link href={item.href} className="absolute inset-0 block">
<DecryptedImage
src={item.cover}
fallback={IMG_PLACEHOLDER}
alt={item.title}
className="absolute inset-0 size-full object-cover"
/>
<div
className={`absolute inset-0 transition-colors duration-300 ${
active
? "bg-[linear-gradient(179deg,rgba(36,36,36,0.32)_18%,rgba(0,0,0,0.69)_83%)]"
: "bg-black/50"
}`}
/>
</Link>
<div
className={`absolute inset-x-0 bottom-0 z-[1] flex min-h-[135px] flex-col justify-center gap-1.5 bg-[linear-gradient(0deg,rgba(17,15,31,0.4)_0%,transparent_100%)] px-6 py-4 transition-opacity duration-300 ${
active ? "opacity-100" : "pointer-events-none opacity-0"
}`}
>
<Link href={item.href} className={`${MEDIA_CARD_TITLE_PC_LG} text-white`}>
{item.title}
</Link>
{item.desc ? (
<p className={`truncate font-medium text-white/60 ${MEDIA_CARD_DESC_MD}`}>{item.desc}</p>
) : null}
<div className={`flex min-w-0 items-center justify-between gap-2 overflow-hidden font-medium ${MEDIA_CARD_META}`}>
<SlideTags tags={item.tags} kind="video" spaced />
{item.heat ? (
<span className={`shrink-0 text-[#F92882] ${MEDIA_CARD_DESC_MD}`}>{item.heat}</span>
) : null}
</div>
</div>
</article>
);
}
function SlideTags({
tags,
kind,
spaced = false,
}: {
tags: CardTagItem[];
kind: "playlet" | "video";
/** 视频稿:标签间距 gap,不用间隔点 */
spaced?: boolean;
}) {
// Coverflow 的 ClientOnly fallback 与 Swiper 正片都会走到这里;
// 用纯展示避免 CardTags 测量逻辑在首屏水合时放大差异。
if (!tags.length) return <div className={`min-w-0 flex-1 font-medium text-white/60 ${MEDIA_CARD_META}`} />;
return (
<div
className={`flex min-w-0 flex-1 items-center overflow-hidden font-medium text-white/60 ${MEDIA_CARD_META} ${
spaced ? "gap-2" : "gap-1"
}`}
>
{tags.slice(0, 3).map((tag, index) => (
<span key={`${tag.slug ?? tag.id ?? tag.name}-${index}`} className="flex min-w-0 items-center gap-1">
{!spaced && index > 0 ? <span className="shrink-0 text-current/40">·</span> : null}
{cardTagCanLink(tag) ? (
<Link href={tagPageHref(tag, kind)} className="min-w-0 truncate transition-colors hover:text-[#F12B9E]">
{tag.name}
</Link>
) : (
<span className="min-w-0 truncate">{tag.name}</span>
)}
</span>
))}
</div>
);
}
function PlayletSlide({ item, active }: { item: SlideItem; active: boolean }) {
return (
<article className="relative size-full overflow-hidden rounded-2xl">
<Link href={item.href} className="absolute inset-0 block">
<DecryptedImage
src={item.cover}
fallback={IMG_PLACEHOLDER}
alt={item.title}
className="absolute inset-0 size-full object-cover"
/>
<div
className={`absolute inset-0 transition-colors duration-300 ${
active ? "bg-black/0" : "bg-black/55"
}`}
/>
</Link>
<div
className={`absolute inset-x-0 bottom-0 z-[1] flex min-h-[159px] flex-col justify-end gap-5 bg-[linear-gradient(0deg,rgba(17,15,31,0.55)_0%,transparent_100%)] px-6 py-4 transition-opacity duration-300 ${
active ? "opacity-100" : "pointer-events-none opacity-0"
}`}
>
<Link href={item.href} className={`${MEDIA_CARD_TITLE_PC_LG_CLAMP} text-white`}>
{item.title}
</Link>
<div className="flex flex-col gap-1.5">
{item.desc ? (
<p className={`line-clamp-2 text-white/60 ${MEDIA_CARD_DESC_MD}`}>{item.desc}</p>
) : null}
<SlideTags tags={item.tags} kind="playlet" />
</div>
</div>
</article>
);
}
function ensureLoopSlides(slides: SlideItem[], minCount: number) {
if (!slides.length) return slides;
if (slides.length >= minCount) return slides;
const out = [...slides];
while (out.length < minCount) {
out.push(...slides.map((item, i) => ({ ...item, key: `${item.key}-loop${out.length + i}` })));
}
return out;
}
function StyleGCoverflow({
slides,
variant,
}: {
slides: SlideItem[];
variant: "video" | "playlet";
}) {
const [swiper, setSwiper] = useState<SwiperInstance | null>(null);
const isVideo = variant === "video";
// 短剧左右各 3 张侧卡,至少准备 3+1+3 张供 coverflow 循环
const loopSlides = ensureLoopSlides(slides, isVideo ? 6 : 9);
if (!slides.length) return null;
return (
<div
data-component={`home-module-style-g-${variant}`}
className={`relative flex items-center overflow-hidden rounded-2xl bg-[#030406] ${
isVideo ? "h-[365px] px-[21px] py-3" : "h-[624px] px-1 py-3"
}`}
>
<ClientOnly
fallback={
<div
className={`mx-auto overflow-hidden rounded-2xl shadow-[0_12px_48px_rgba(0,0,0,0.55)] ${
isVideo ? "h-[341px] w-[min(600px,52%)]" : "h-[600px] w-[450px]"
}`}
>
{isVideo ? (
<VideoSlide item={slides[0]} active />
) : (
<PlayletSlide item={slides[0]} active />
)}
</div>
}
>
<Swiper
modules={[Autoplay, EffectCoverflow]}
effect="coverflow"
grabCursor
centeredSlides
slidesPerView="auto"
// 短剧:卡片宽 450,间距约 -200 → 步进 ~250,保证左右各露出 3 张
spaceBetween={isVideo ? -180 : -200}
loop={loopSlides.length >= 4}
loopAdditionalSlides={isVideo ? 2 : 3}
slideToClickedSlide
watchSlidesProgress
speed={550}
autoplay={{ delay: 5000, disableOnInteraction: false, pauseOnMouseEnter: true }}
coverflowEffect={
isVideo
? {
rotate: 0,
stretch: 48,
depth: 120,
modifier: 1.2,
scale: 0.78,
slideShadows: false,
}
: {
// scale=0.84 → 距中心 1/2/3 步约为 0.84 / 0.68 / 0.52,对齐稿面三层侧卡
rotate: 0,
stretch: 0,
depth: 140,
modifier: 1,
scale: 0.84,
slideShadows: false,
}
}
onSwiper={setSwiper}
className="h-full w-full !overflow-visible"
>
{loopSlides.map((item, index) => (
<SwiperSlide
key={`${item.key}-${index}`}
className={`!box-border overflow-hidden rounded-2xl transition-[filter,opacity] duration-300 [&.swiper-slide-active]:z-10 [&.swiper-slide-active]:shadow-[0_16px_48px_rgba(0,0,0,0.65)] ${
isVideo
? "!h-[341px] !w-[min(600px,52%)]"
: "!h-[600px] !w-[450px] [&.swiper-slide-prev]:brightness-[0.8] [&.swiper-slide-next]:brightness-[0.8]"
}`}
>
{({ isActive }) =>
isVideo ? (
<VideoSlide item={item} active={isActive} />
) : (
<PlayletSlide item={item} active={isActive} />
)
}
</SwiperSlide>
))}
</Swiper>
</ClientOnly>
<NavButton label="上一项" onClick={() => swiper?.slidePrev()} side="left" />
<NavButton label="下一项" onClick={() => swiper?.slideNext()} side="right" />
</div>
);
}
function useStyleGSlides(module: HomeModuleData) {
return useMemo(() => {
const { videos, playlets } = unwrapHomeModuleList(module.list, module.dataType);
const isVideo = isVideoDataType(module.dataType) || (videos.length > 0 && playlets.length === 0);
if (isVideo) {
return {
variant: "video" as const,
items: videos.map((item) => ({
key: `v-${item.id}`,
href: videoHref(String(item.id), item.slug),
title: item.title || "未命名",
desc: (item.desc || "").trim(),
cover: videoCoverSrc(item),
tags: pickVideoCardTags(item),
heat: formatHeat(item.watchNum),
})),
};
}
return {
variant: "playlet" as const,
items: playlets.map((item) => ({
key: `p-${item.id}`,
href: dramaHref(item.id, item.slug),
title: item.title || "未命名",
desc: item.desc || "",
cover: item.cover || null,
tags: pickPlayletCardTags(item),
heat: formatHeat(item.heatValue ?? item.countBrowse),
})),
};
}, [module.dataType, module.list]);
}
/** 样式 G:精选 coverflow · 视频横版 / 短剧竖版 */
export function HomeModuleStyleG({ module }: { module: HomeModuleData }) {
const slides = useStyleGSlides(module);
return <StyleGCoverflow slides={slides.items} variant={slides.variant} />;
}
/** H5 · 样式 G 封面卡(短剧竖版 / 视频横版,文案在卡外) */
function H5GSlide({ item, active }: { item: SlideItem; active: boolean }) {
return (
<Link href={item.href} className="relative block size-full overflow-hidden rounded-3xl">
<DecryptedImage
src={item.cover}
fallback={IMG_PLACEHOLDER}
alt={item.title}
className="absolute inset-0 size-full object-cover"
/>
<div
className={`absolute inset-0 transition-colors duration-300 ${active ? "bg-transparent" : "bg-black/50"}`}
/>
</Link>
);
}
function H5StyleGCoverflow({
slides,
variant,
}: {
slides: SlideItem[];
variant: "video" | "playlet";
}) {
const [activeIndex, setActiveIndex] = useState(0);
const isVideo = variant === "video";
const loopSlides = ensureLoopSlides(slides, 6);
const realCount = slides.length;
const active = slides[activeIndex % Math.max(realCount, 1)] ?? slides[0];
if (!slides.length) return null;
return (
<div data-component={`home-module-style-g-h5-${variant}`} className="flex w-full flex-col items-center gap-[18px]">
<ClientOnly
fallback={
<div
className={`mx-auto overflow-hidden rounded-3xl ${
isVideo ? "aspect-[280/158] w-[280px]" : "h-[346px] w-[247px]"
}`}
>
<H5GSlide item={slides[0]} active />
</div>
}
>
<div className={`relative w-full overflow-hidden ${isVideo ? "h-[180px]" : "h-[346px]"}`}>
<Swiper
modules={[Autoplay, EffectCoverflow]}
effect="coverflow"
grabCursor
centeredSlides
slidesPerView="auto"
spaceBetween={isVideo ? -72 : -100}
loop={loopSlides.length >= 4}
loopAdditionalSlides={2}
slideToClickedSlide
watchSlidesProgress
speed={500}
autoplay={{ delay: 5000, disableOnInteraction: false }}
coverflowEffect={
isVideo
? {
rotate: 0,
stretch: 24,
depth: 100,
modifier: 1.15,
scale: 0.82,
slideShadows: false,
}
: {
// 侧卡约 189/247 ≈ 0.76
rotate: 0,
stretch: 0,
depth: 120,
modifier: 1,
scale: 0.76,
slideShadows: false,
}
}
onSlideChange={(s) => setActiveIndex(s.realIndex % realCount)}
className="h-full w-full !overflow-visible"
>
{loopSlides.map((item, index) => (
<SwiperSlide
key={`${item.key}-${index}`}
className={`!box-border overflow-hidden rounded-3xl transition-[filter] duration-300 [&.swiper-slide-active]:z-10 ${
isVideo
? "!h-[158px] !w-[280px]"
: "!h-[346px] !w-[247px] [&.swiper-slide-prev]:brightness-90 [&.swiper-slide-next]:brightness-90"
}`}
>
{({ isActive }) => <H5GSlide item={item} active={isActive} />}
</SwiperSlide>
))}
</Swiper>
</div>
</ClientOnly>
{active ? (
<div className="flex w-full flex-col items-center gap-3 overflow-hidden">
<Link href={active.href} className="w-full truncate text-center text-base leading-[22px] font-medium text-white">
{active.title}
</Link>
{active.desc ? (
<p className="line-clamp-3 w-full text-justify text-xs leading-[18px] text-white/55">{active.desc}</p>
) : null}
</div>
) : null}
{realCount > 1 ? (
<div className="flex items-center justify-center gap-2.5">
{slides.slice(0, Math.min(realCount, 8)).map((item, i) => {
const on = i === activeIndex % realCount;
return (
<span
key={item.key}
className={
on
? "h-2 w-5 rounded bg-[#F12B9E]"
: "size-2 rounded-full bg-white/30"
}
/>
);
})}
</div>
) : null}
</div>
);
}
/** H5 · 样式 G:背景图 + 标题 + coverflow(短剧竖版 / 视频横版) */
export function HomeModuleStyleGH5({ module }: { module: HomeModuleData }) {
const moreHref = morePageHref(module);
const title = (module.title || module.label || "推荐").trim();
const slides = useStyleGSlides(module);
if (!slides.items.length) return null;
return (
<div
data-component="home-module-style-g-h5"
className="-mx-4 flex flex-col items-stretch gap-[18px] bg-black bg-[url(/images/swiper_bg.png)] bg-cover bg-top bg-no-repeat px-4 py-6"
>
<H5SectionHeading title={title} moreHref={moreHref} />
<H5StyleGCoverflow slides={slides.items} variant={slides.variant} />
</div>
);
}
+229
View File
@@ -0,0 +1,229 @@
import Link from "next/link";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { formatHeat } from "@/components/ranking/data";
import { morePageHref } from "@/components/more/href";
import { topicDetailHref } from "@/components/topic/href";
import type { HomeModuleData, HomeTopicData, PlayletData, VideoInfo } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { H5SectionHeading } from "./h5-cards";
import { isVideoDataType, topicCoverSrc } from "./home-module-style";
const RANK_BADGES = ["/images/num1.png", "/images/num2.png", "/images/num3.png"];
function isVideoItem(item: PlayletData | VideoInfo): item is VideoInfo {
if (!item || typeof item !== "object") return false;
return "coverImg" in item || "tagsName" in item || "watchNum" in item;
}
function isPlayletItem(item: PlayletData | VideoInfo): item is PlayletData {
if (!item || typeof item !== "object" || isVideoItem(item)) return false;
return "cover" in item || "tagNames" in item || "totalEpisode" in item || "desc" in item;
}
function heatLabel(count?: number | null) {
return formatHeat(count).replace(/^(.+)\s热度$/, "热度 $1");
}
function resolveTopicEntry(item: unknown): PlayletData | VideoInfo | null {
if (!item || typeof item !== "object") return null;
const raw = item as PlayletData | VideoInfo | { playlet?: PlayletData; video?: VideoInfo };
if ("video" in raw && raw.video) return raw.video;
if ("playlet" in raw && raw.playlet) return raw.playlet;
if (isPlayletItem(raw as PlayletData | VideoInfo) || isVideoItem(raw as PlayletData | VideoInfo)) {
return raw as PlayletData | VideoInfo;
}
return null;
}
/** 榜单条目仅展示;点击统一进专题详情 */
function topicItems(topic: HomeTopicData) {
const list = topic.list ?? [];
return list
.map((item) => resolveTopicEntry(item))
.filter(Boolean)
.slice(0, 3)
.map((item, index) => {
if (isPlayletItem(item!)) {
return {
key: `p-${item.id}`,
rank: index + 1,
title: item.title || "未命名",
heat: heatLabel(item.heatValue ?? item.countBrowse),
};
}
if (isVideoItem(item!)) {
return {
key: `v-${item.id}`,
rank: index + 1,
title: item.title || "未命名",
heat: heatLabel(item.watchNum),
};
}
return null;
})
.filter(Boolean) as Array<{
key: string;
rank: number;
title: string;
heat: string;
}>;
}
function StyleHBoardCard({
topic,
subtitle,
dataType,
}: {
topic: HomeTopicData;
subtitle: string;
dataType?: number | null;
}) {
const boardHref = topicDetailHref(topic, isVideoDataType(dataType) ? 2 : 1);
const items = topicItems(topic);
const cover = topicCoverSrc(topic);
return (
<Link
href={boardHref}
className="group relative flex h-[644px] w-[410px] shrink-0 flex-col justify-between overflow-hidden rounded-[20px] px-[22px] py-[41px] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]"
>
<div className="absolute inset-0 overflow-hidden rounded-[20px]">
<DecryptedImage
src={cover}
fallback={IMG_PLACEHOLDER}
alt={topic.name || ""}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-105"
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.2)_0%,rgba(0,0,0,0.1)_100%)]" />
<div className="absolute inset-0 bg-[linear-gradient(207deg,transparent_0%,rgba(8,15,43,0.54)_100%)]" />
</div>
<div className="relative z-[1] min-w-0">
<h3 className="style-h-board-title line-clamp-2 font-semibold break-words text-white [text-shadow:0_10px_9px_rgba(0,0,0,0.25)]">
{topic.name || "榜单"}
</h3>
<p className="style-h-board-sub mt-1 truncate text-white/80">{subtitle}</p>
</div>
<div className="relative z-[1] flex min-w-0 flex-col gap-2">
{items.map((item) => (
<div
key={item.key}
className="flex min-w-0 items-center gap-2.5 overflow-hidden rounded-[10px] bg-[rgba(38,56,75,0.3)] px-4 py-2"
>
<img
src={RANK_BADGES[item.rank - 1] || RANK_BADGES[2]}
alt={String(item.rank)}
className="size-8 shrink-0"
/>
<span className="min-w-0 flex-1 overflow-hidden">
<span className="style-h-board-item-title block truncate whitespace-nowrap text-white">{item.title}</span>
<span className="style-h-board-item-heat mt-0.5 block truncate whitespace-nowrap text-[#F9C006]">
{item.heat}
</span>
</span>
<img src="/images/up.svg" alt="" className="size-4 shrink-0 opacity-90" />
</div>
))}
</div>
</Link>
);
}
/** 样式 H:统一主题样式2 · 口碑榜竖版 4 卡(topicList) */
export function HomeModuleStyleH({ module }: { module: HomeModuleData }) {
const topics = (module.topicList ?? []).filter((topic) => topic.name).slice(0, 4);
const subtitle = (module.label || "海角榜单").trim();
if (!topics.length) return null;
return (
<div data-component="home-module-style-h" className="flex w-full items-start gap-5 overflow-hidden">
{topics.map((topic) => (
<StyleHBoardCard key={topic.id} topic={topic} subtitle={subtitle} dataType={module.dataType} />
))}
</div>
);
}
/** H5 · 样式 H 榜单卡(168×264) */
function H5StyleHBoardCard({
topic,
subtitle,
dataType,
}: {
topic: HomeTopicData;
subtitle: string;
dataType?: number | null;
}) {
const boardHref = topicDetailHref(topic, isVideoDataType(dataType) ? 2 : 1);
const items = topicItems(topic);
const cover = topicCoverSrc(topic);
return (
<Link
href={boardHref}
data-component="h5-home-module-style-h-card"
className="group relative flex aspect-[168/264] w-full flex-col justify-between overflow-hidden rounded-lg px-[9px] py-[17px] transition-shadow duration-300 hover:shadow-[0_0_20px_#FF76C6]"
>
<div className="absolute inset-0 overflow-hidden rounded-lg">
<DecryptedImage
src={cover}
fallback={IMG_PLACEHOLDER}
alt={topic.name || ""}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-105"
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.2)_0%,rgba(0,0,0,0.1)_100%)]" />
<div className="absolute inset-0 bg-[linear-gradient(207deg,transparent_0%,rgba(8,15,43,0.54)_100%)]" />
</div>
<div className="relative min-w-0">
<h3 className="line-clamp-2 text-lg leading-6 font-semibold break-words text-white [text-shadow:0_4px_4px_rgba(0,0,0,0.25)]">
{topic.name || "榜单"}
</h3>
<p className="truncate text-xs leading-[18px] text-white/80">{subtitle}</p>
</div>
<div className="relative flex min-w-0 flex-col gap-0.5">
{items.map((item) => (
<div
key={item.key}
className="flex min-w-0 items-center gap-1 overflow-hidden rounded bg-[rgba(38,56,75,0.3)] px-2 py-0.5"
>
<img
src={RANK_BADGES[item.rank - 1] || RANK_BADGES[2]}
alt={String(item.rank)}
className="size-[15px] shrink-0"
/>
<span className="min-w-0 flex-1 overflow-hidden">
<span className="block truncate whitespace-nowrap text-xs leading-[18px] text-white">{item.title}</span>
<span className="block truncate whitespace-nowrap text-[10px] leading-[18px] text-[#F9C006]">{item.heat}</span>
</span>
<img src="/images/up.svg" alt="" className="size-[9px] shrink-0" />
</div>
))}
</div>
</Link>
);
}
/** H5 · 样式 H:标题 + 两列四榜单卡 */
export function HomeModuleStyleHH5({ module }: { module: HomeModuleData }) {
const moreHref = morePageHref(module);
const title = (module.title || module.label || "推荐").trim();
const subtitle = (module.label || "海角榜单").trim();
const topics = (module.topicList ?? []).filter((topic) => topic.name).slice(0, 4);
if (!topics.length) return null;
return (
<div data-component="home-module-style-h-h5" className="flex w-full flex-col items-start gap-3 overflow-hidden">
<H5SectionHeading title={title} moreHref={moreHref} />
<div className="grid w-full grid-cols-2 gap-3">
{topics.map((topic) => (
<H5StyleHBoardCard key={topic.id} topic={topic} subtitle={subtitle} dataType={module.dataType} />
))}
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import type {
HomeModuleData,
HomeTopicData,
PlayletContentListItem,
PlayletData,
VideoInfo,
} from "@/lib/api/types";
/** 首页模块样式:接口 style 字段 */
export type HomeModuleStyle = "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "unknown";
export function normalizeHomeModuleStyle(style?: string | null): HomeModuleStyle {
const raw = (style || "").trim().toLowerCase();
if (!raw) return "unknown";
if (raw === "a" || raw === "1" || raw === "样式a" || raw === "style_a" || raw === "stylea") return "A";
if (raw === "b" || raw === "2" || raw === "样式b") return "B";
if (raw === "c" || raw === "3" || raw === "样式c") return "C";
if (raw === "d" || raw === "4" || raw === "样式d") return "D";
if (raw === "e" || raw === "5" || raw === "样式e") return "E";
if (raw === "f" || raw === "6" || raw === "样式f") return "F";
if (raw === "g" || raw === "7" || raw === "样式g") return "G";
if (raw === "h" || raw === "8" || raw === "样式h") return "H";
return "unknown";
}
/** dataType:1 短剧 / 2 视频 */
export function isVideoDataType(dataType?: number | null) {
return dataType === 2;
}
/**
* 专题作品数:按 dataType 优先对应 count;为 0/缺失时回退另一字段或 list.length。
* 接口常把 videoCount 写成 0 却塞了 list,直接显示「0部」会误导。
*/
export function resolveTopicWorkCount(topic: HomeTopicData, dataType?: number | null): number | null {
const isVideo = isVideoDataType(dataType);
const primary = isVideo ? topic.videoCount : topic.playletCount;
const secondary = isVideo ? topic.playletCount : topic.videoCount;
const fromList = topic.list?.length;
for (const n of [primary, secondary, fromList]) {
if (n != null && Number.isFinite(n) && n > 0) return n;
}
return null;
}
/** 样式 D 等:有数量才返回文案,否则不展示角标 */
export function topicCountLabel(topic: HomeTopicData, dataType?: number | null, suffix = "部剧情") {
const count = resolveTopicWorkCount(topic, dataType);
if (count == null) return null;
return `${count}${suffix}`;
}
/** 视频封面:兼容 coverImg / cover */
export function videoCoverSrc(item: Pick<VideoInfo, "coverImg" | "cover"> | null | undefined) {
return item?.coverImg || item?.cover || null;
}
/**
* 专题封面:优先 topic.list[0](短剧 cover / 视频 coverImg|cover),再回退 topic.icon。
* 首页样式 D/H、专题更多页共用。
*/
export function topicCoverSrc(topic: HomeTopicData): string | null {
const first = topic.list?.[0] as
| PlayletData
| VideoInfo
| { playlet?: PlayletData; video?: VideoInfo }
| undefined;
if (first && typeof first === "object") {
if ("video" in first && first.video) return videoCoverSrc(first.video);
if ("playlet" in first && first.playlet?.cover) return first.playlet.cover;
if ("coverImg" in first || ("tagsName" in first && !("tagNames" in first))) {
return videoCoverSrc(first as VideoInfo);
}
if ("cover" in first && first.cover) {
return videoCoverSrc(first as VideoInfo) || (first as PlayletData).cover || null;
}
}
return topic.icon?.trim() || null;
}
function isWrappedContentItem(
item: PlayletData | VideoInfo | PlayletContentListItem,
): item is PlayletContentListItem {
if (item == null || typeof item !== "object") return false;
// 仅认真正的 ContentListItem 包装;裸视频也可能带 type,不能靠 type 判断
return (
("playlet" in item && item.playlet != null) ||
("video" in item && item.video != null) ||
("advertise" in item && item.advertise != null)
);
}
/**
* 解包首页模块 list:兼容裸 PlayletData/VideoInfo 与 { playlet }/{ video } 包装。
* 视频首页模块常返回 cover(非 coverImg),需结合 dataType 分流。
*/
export function unwrapHomeModuleList(
list: HomeModuleData["list"] | undefined | null,
dataType?: number | null,
): {
videos: VideoInfo[];
playlets: PlayletData[];
} {
const videos: VideoInfo[] = [];
const playlets: PlayletData[] = [];
for (const item of list ?? []) {
if (!item || typeof item !== "object") continue;
if (isWrappedContentItem(item)) {
if (item.video?.id != null) videos.push(item.video);
else if (item.playlet?.id != null) playlets.push(item.playlet);
continue;
}
const id = (item as { id?: number | null }).id;
if (id == null) continue;
if (dataType === 2) {
videos.push(item as VideoInfo);
continue;
}
if (dataType === 1) {
playlets.push(item as PlayletData);
continue;
}
// 无 dataType:优先视频特征字段,再回退短剧 cover
if ("coverImg" in item || "tagsName" in item || "watchNum" in item) {
videos.push(item as VideoInfo);
continue;
}
if ("cover" in item || "tagNames" in item || "totalEpisode" in item || "desc" in item) {
playlets.push(item as PlayletData);
}
}
return { videos, playlets };
}
+400
View File
@@ -0,0 +1,400 @@
import Link from "next/link";
import { CardTags, pickPlayletCardTags, pickVideoCardTags } from "@/components/common/card-tags";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
MEDIA_CARD_CONTAINER,
MEDIA_CARD_DESC,
MEDIA_CARD_EPISODES,
MEDIA_CARD_EPISODES_H5,
MEDIA_CARD_META,
MEDIA_CARD_META_H5,
MEDIA_CARD_TITLE_PC,
MEDIA_CARD_TITLE_PC_MD,
} 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 { topicDetailHref } from "@/components/topic/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 { HomeModuleStyleA, HomeModuleStyleABH5Playlet, HomeModuleStyleABH5Video } from "./home-module-style-a";
import { HomeModuleStyleB } from "./home-module-style-b";
import { HomeModuleStyleC } from "./home-module-style-c";
import { HomeModuleStyleD, HomeModuleStyleDH5 } from "./home-module-style-d";
import { HomeModuleStyleE, HomeModuleStyleEH5Playlet, HomeModuleStyleEH5Video } from "./home-module-style-e";
import { HomeModuleStyleF, HomeModuleStyleFH5Playlet, HomeModuleStyleFH5Video } from "./home-module-style-f";
import { HomeModuleStyleG, HomeModuleStyleGH5 } from "./home-module-style-g";
import { HomeModuleStyleH, HomeModuleStyleHH5 } from "./home-module-style-h";
import {
isVideoDataType,
normalizeHomeModuleStyle,
topicCountLabel,
topicCoverSrc,
unwrapHomeModuleList,
videoCoverSrc,
} from "./home-module-style";
import { SectionHeading } from "./section-heading";
function PlayletTile({ item, compact }: { item: PlayletData; compact?: boolean }) {
const tags = pickPlayletCardTags(item);
const heat = formatHeat(item.heatValue ?? item.countBrowse);
const episodes = item.totalEpisode ? `${item.totalEpisode}集` : "";
const detailHref = dramaHref(item.id, item.slug);
if (compact) {
return (
<article className={`${MEDIA_CARD_CONTAINER} overflow-hidden rounded-lg bg-[#110F1F]`}>
<Link href={detailHref} className="block">
<div className="relative aspect-[3/4] overflow-hidden">
<DecryptedImage
src={item.cover || null}
fallback={IMG_PLACEHOLDER}
alt={item.title || ""}
className="absolute inset-0 h-full w-full object-cover"
/>
{episodes ? (
<span
className={`absolute right-2 bottom-2 rounded bg-black/60 px-1.5 py-0.5 text-white ${MEDIA_CARD_EPISODES_H5}`}
>
{episodes}
</span>
) : null}
</div>
<div className="px-1.5 pt-1.5">
<h3 className="truncate text-sm leading-5 font-medium text-white">{item.title}</h3>
</div>
</Link>
<div className={`flex items-center justify-between gap-1 px-1.5 pt-0.5 pb-1.5 ${MEDIA_CARD_META_H5}`}>
<CardTags tags={tags} kind="playlet" className="min-w-0 text-white/60" />
<span className="shrink-0 font-medium text-[#F92882]">{heat}</span>
</div>
</article>
);
}
return (
<article className={`${MEDIA_CARD_CONTAINER} overflow-hidden rounded-lg bg-[#110F1F]`}>
<Link href={detailHref} className="block">
<div className="relative h-[215px] overflow-hidden">
<DecryptedImage
src={item.cover || null}
fallback={IMG_PLACEHOLDER}
alt={item.title || ""}
className="absolute inset-0 h-full w-full object-cover"
/>
{episodes ? (
<span
className={`absolute right-4 bottom-4 rounded-md bg-black/60 px-3 py-2 font-medium text-white ${MEDIA_CARD_EPISODES}`}
>
{episodes}
</span>
) : null}
</div>
<div className="flex flex-col gap-1.5 px-2 pt-2">
<h3 className={MEDIA_CARD_TITLE_PC_MD}>{item.title}</h3>
{item.desc ? <p className={`${MEDIA_CARD_DESC} text-white/60`}>{item.desc}</p> : null}
</div>
</Link>
<div className={`flex items-center justify-between gap-2 px-2 pt-1 pb-2 ${MEDIA_CARD_META}`}>
<CardTags tags={tags} kind="playlet" className="text-white/60" />
<span className="shrink-0 font-medium text-[#F92882]">{heat}</span>
</div>
</article>
);
}
function VideoTile({ item, compact }: { item: VideoInfo; compact?: boolean }) {
const tags = pickVideoCardTags(item);
const heat = formatHeat(item.watchNum);
const detailHref = videoHref(String(item.id), item.slug);
if (compact) {
return (
<article className={`${MEDIA_CARD_CONTAINER} overflow-hidden rounded-lg bg-[#110F1F]`}>
<Link href={detailHref} className="block">
<div className="relative aspect-[7/4] overflow-hidden">
<DecryptedImage
src={videoCoverSrc(item)}
fallback={IMG_PLACEHOLDER}
alt={item.title || ""}
className="absolute inset-0 h-full w-full object-cover"
/>
</div>
<div className="px-1.5 pt-1.5">
<h3 className="truncate text-sm leading-5 font-medium text-white">{item.title}</h3>
</div>
</Link>
<div className={`flex items-center justify-between gap-1 px-1.5 pt-0.5 pb-1.5 ${MEDIA_CARD_META_H5}`}>
<CardTags tags={tags} kind="video" className="min-w-0 text-white/60" />
<span className="shrink-0 font-medium text-[#F92882]">{heat}</span>
</div>
</article>
);
}
return (
<article className={`${MEDIA_CARD_CONTAINER} overflow-hidden rounded-lg bg-[#110F1F]`}>
<Link href={detailHref} className="block">
<div className="relative h-48 overflow-hidden">
<DecryptedImage
src={videoCoverSrc(item)}
fallback={IMG_PLACEHOLDER}
alt={item.title || ""}
className="absolute inset-0 h-full w-full object-cover"
/>
</div>
<div className="flex flex-col gap-1.5 px-2 pt-2">
<h3 className={MEDIA_CARD_TITLE_PC_MD}>{item.title}</h3>
</div>
</Link>
<div className={`flex items-center justify-between gap-2 px-2 pt-1 pb-2 ${MEDIA_CARD_META}`}>
<CardTags tags={tags} kind="video" className="text-white/60" />
<span className="shrink-0 font-medium text-[#F92882]">{heat}</span>
</div>
</article>
);
}
/** 渲染接口 modules;空数组时不展示 */
export function HomeModules({
modules,
variant = "pc",
}: {
modules?: HomeModuleData[] | null;
variant?: "pc" | "h5";
}) {
const list = modules?.filter((module) => module.title || module.list?.length || module.topicList?.length) ?? [];
if (!list.length) return null;
const compact = variant === "h5";
return (
<>
{list.map((module) => {
const style = normalizeHomeModuleStyle(module.style);
const title = module.title || module.label || "推荐";
const moreHref = morePageHref(module);
const { playlets, videos } = unwrapHomeModuleList(module.list, module.dataType);
const topics = module.topicList?.filter((topic) => topic.name) ?? [];
const useVideo = isVideoDataType(module.dataType) || (videos.length > 0 && playlets.length === 0);
// PC 样式 A/B:横版 2+主题 / 竖版 4+主题(A 主题在右,B 主题在左)
if (!compact && (style === "A" || style === "B")) {
return (
<section key={module.id} data-component="home-module" className="flex flex-col gap-5">
{style === "B" ? <HomeModuleStyleB module={module} /> : <HomeModuleStyleA module={module} />}
</section>
);
}
// H5 样式 A/B/C:短剧两列四竖卡 / 视频两列六横卡(无主题块,C 同 A)
if (compact && (style === "A" || style === "B" || style === "C")) {
return (
<section key={module.id} data-component="home-module">
{useVideo ? <HomeModuleStyleABH5Video module={module} /> : <HomeModuleStyleABH5Playlet module={module} />}
</section>
);
}
// PC 样式 C:标准标题 + 视频 4×2 横版 / 短剧 6×2 竖版
if (!compact && style === "C") {
return (
<section key={module.id} data-component="home-module" className="flex flex-col gap-5">
<SectionHeading
title={title}
subtitle={module.desc || "正在热播的好故事,发现值得追的下一集"}
moreHref={moreHref}
/>
<HomeModuleStyleC module={module} />
</section>
);
}
// PC 样式 D:统一主题 · 横版前 4 幅(topicList,标题与内容间距 32)
if (!compact && style === "D") {
return (
<section key={module.id} data-component="home-module" className="flex flex-col gap-8">
<SectionHeading
title={title}
subtitle={module.desc || "正在热播的好故事,发现值得追的下一集"}
moreHref={moreHref}
/>
<HomeModuleStyleD module={module} />
</section>
);
}
// H5 样式 D:标题 + 两列前四主题横卡(168×94)
if (compact && style === "D") {
return (
<section key={module.id} data-component="home-module">
<HomeModuleStyleDH5 module={module} />
</section>
);
}
// PC 样式 E:标题间距 32;视频竖排大卡 4×2 / 短剧横排大卡 3×2
if (!compact && style === "E") {
return (
<section key={module.id} data-component="home-module" className="flex flex-col gap-8">
<SectionHeading
title={title}
subtitle={module.desc || "正在热播的好故事,发现值得追的下一集"}
moreHref={moreHref}
/>
<HomeModuleStyleE module={module} />
</section>
);
}
// H5 样式 E:短剧横版热榜 / 视频竖版热榜(三行两卡)
if (compact && style === "E") {
return (
<section key={module.id} data-component="home-module">
{useVideo ? <HomeModuleStyleEH5Video module={module} /> : <HomeModuleStyleEH5Playlet module={module} />}
</section>
);
}
// PC 样式 F:今日排行 · 视频横版 4 卡 / 短剧竖版 6 卡(标题前带序号)
if (!compact && style === "F") {
return (
<section key={module.id} data-component="home-module" className="flex flex-col gap-8">
<SectionHeading
title={title}
subtitle={module.desc || "正在热播的好故事,发现值得追的下一集"}
moreHref={moreHref}
/>
<HomeModuleStyleF module={module} />
</section>
);
}
// H5 样式 F:短剧竖卡 / 视频横卡(两列六卡,标题前序号)
if (compact && style === "F") {
return (
<section key={module.id} data-component="home-module">
{useVideo ? <HomeModuleStyleFH5Video module={module} /> : <HomeModuleStyleFH5Playlet module={module} />}
</section>
);
}
// PC 样式 G:精选 coverflow · 视频横版中心卡 / 短剧竖版中心卡
if (!compact && style === "G") {
return (
<section key={module.id} data-component="home-module" className="flex flex-col gap-5">
<SectionHeading
title={title}
subtitle={module.desc || "更多人生片段,等你慢慢发现"}
moreHref={moreHref}
/>
<HomeModuleStyleG module={module} />
</section>
);
}
// H5 样式 G:swiper_bg 背景 + coverflow(短剧竖版 / 视频横版)
if (compact && style === "G") {
return (
<section key={module.id} data-component="home-module">
<HomeModuleStyleGH5 module={module} />
</section>
);
}
// PC 样式 H:统一主题样式2 · 口碑榜竖版 4 卡(topicList + 前三名)
if (!compact && style === "H") {
return (
<section key={module.id} data-component="home-module" className="flex flex-col gap-5">
<SectionHeading
title={title}
subtitle={module.desc || "按心情选一部,让好故事陪伴你"}
moreHref={moreHref}
/>
<HomeModuleStyleH module={module} />
</section>
);
}
// H5 样式 H:标题 + 两列四榜单卡
if (compact && style === "H") {
return (
<section key={module.id} data-component="home-module">
<HomeModuleStyleHH5 module={module} />
</section>
);
}
return (
<section key={module.id} data-component="home-module" className={`flex flex-col ${compact ? "gap-3" : "gap-5"}`}>
{compact ? (
<H5SectionHeading title={title} moreHref={moreHref} />
) : (
<SectionHeading
title={title}
subtitle={module.desc || "正在热播的好故事,发现值得追的下一集"}
moreHref={moreHref}
/>
)}
{useVideo && videos.length ? (
<div className={compact ? "grid grid-cols-2 gap-[7px]" : "grid grid-cols-2 gap-5 md:grid-cols-3 lg:grid-cols-4"}>
{videos.map((item) => (
<VideoTile key={item.id} item={item} compact={compact} />
))}
</div>
) : null}
{!useVideo && playlets.length ? (
<div className={compact ? "grid grid-cols-2 gap-[7px]" : "grid grid-cols-2 gap-5 md:grid-cols-3 lg:grid-cols-4"}>
{playlets.map((item) => (
<PlayletTile key={item.id} item={item} compact={compact} />
))}
</div>
) : null}
{topics.length ? (
<div className={compact ? "grid grid-cols-2 gap-[7px]" : "grid grid-cols-2 gap-5 lg:grid-cols-4"}>
{topics.map((topic) => {
const countText = topicCountLabel(topic, module.dataType, "部");
return (
<Link
key={topic.id}
href={topicDetailHref(topic, isVideoDataType(module.dataType) ? 2 : 1)}
className={`relative overflow-hidden rounded-lg bg-[#110F1F] ${compact ? "aspect-[16/10]" : "h-[231px]"}`}
>
<DecryptedImage
src={topicCoverSrc(topic)}
fallback={IMG_PLACEHOLDER}
alt={topic.name || ""}
className="absolute inset-0 h-full w-full object-cover object-center"
/>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_66%_60%,transparent,rgba(0,0,0,0.55))]" />
<div className={compact ? "absolute bottom-3 left-3" : "absolute bottom-6 left-8"}>
<h3 className={compact ? "text-base font-medium text-[#F2F2FA]" : `text-[#F2F2FA] ${MEDIA_CARD_TITLE_PC}`}>
{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>
) : null}
</section>
);
})}
</>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { AdBanners, AdIcons } from "@/components/common/ads";
import { H5FriendLinks } from "@/components/common/friend-links";
import type { HomePlayletResponse } from "@/lib/api/types";
import { DownloadBannerH5 } from "./page-sections";
import { H5Hero } from "./h5-hero";
import { HomeModules } from "./home-modules";
import { NoticeMarquee } from "./notice-marquee";
export function HomePageH5({ home }: { home?: HomePlayletResponse | null }) {
const hasModules = Boolean(home?.modules?.some((module) => module.list?.length || module.topicList?.length));
return (
<main data-component="home-page-h5" className="flex w-full min-w-0 flex-col gap-[18px] overflow-x-clip px-4 py-3 lg:hidden">
<H5Hero banners={home?.homeBannerData} />
<NoticeMarquee items={home?.announcements} variant="h5" />
<AdIcons variant="h5" advitise={home?.advitise} />
<AdBanners variant="h5" advitise={home?.advitise} />
{hasModules ? <HomeModules modules={home?.modules} variant="h5" /> : null}
<H5FriendLinks advitise={home?.advitise} />
<DownloadBannerH5 />
</main>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { AdIcons } from "@/components/common/ads";
import { FriendLinks } from "@/components/common/friend-links";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { HomePlayletResponse } from "@/lib/api/types";
import { FloatingActions } from "./floating-actions";
import { HeroBanner } from "./hero-banner";
import { HomeModules } from "./home-modules";
import { HomePageH5 } from "./home-page-h5";
import { NoticeMarquee } from "./notice-marquee";
import { DownloadBanner } from "./page-sections";
const HOME_H1_FALLBACK = "海角社区 - 热门短剧在线看";
export function HomePage({
home,
heading,
}: {
home?: HomePlayletResponse | null;
/** 与首页 TDK 对齐的主标题(H1) */
heading?: string | null;
}) {
const hasModules = Boolean(home?.modules?.some((module) => module.list?.length || module.topicList?.length));
const h1 = heading?.trim() || HOME_H1_FALLBACK;
return (
<>
<SeoBreadcrumb items={[{ name: "首页", href: "/" }]} />
<h1 className="sr-only">{h1}</h1>
<HomePageH5 home={home} />
<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} />
{hasModules ? <HomeModules modules={home?.modules} /> : null}
<DownloadBanner />
<FriendLinks advitise={home?.advitise} />
</main>
<FloatingActions />
</>
);
}

Some files were not shown because too many files have changed in this diff Show More