@@ -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>
|
||||
))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 || ""),
|
||||
};
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 = "";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`}> </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]`}> </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 />;
|
||||
}
|
||||
@@ -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`}> </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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`}> </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]"> </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]"> </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]"> </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>
|
||||
);
|
||||
}
|
||||
@@ -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`}> </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]`}> </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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user