整体UI调整

This commit is contained in:
谢宇宁
2026-10-26 19:51:59 +07:00
parent c8895f407c
commit eeb9986923
112 changed files with 1507 additions and 4255 deletions
+1 -2
View File
@@ -18,8 +18,7 @@ const sections: { title: string; paragraphs: string[]; slogan?: boolean }[] = [
{
title: "联系我们",
paragraphs: [
"商务合作:business@qimengshorts.com",
"用户反馈:support@qimengshorts.com",
"官方TG:@jbd125",
"服务时间:工作日 10:00–18:00",
],
},
-52
View File
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore
import { getAgeGateOpen, subscribeAgeGate } from "@/components/age-gate/age-gate-dialog";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { adTrackProps } from "@/components/common/ads/pick";
import { useChromeless } from "@/components/layout/route-visibility";
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
@@ -200,34 +199,6 @@ export function AdDialogHost({
}, [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 = (
@@ -252,10 +223,6 @@ export function AdDialogHost({
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
@@ -263,24 +230,6 @@ export function AdDialogHost({
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>
@@ -290,7 +239,6 @@ export function AdDialogHost({
<button
type="button"
aria-label="关闭广告"
data-dialog-ad-close=""
onClick={showNext}
className="size-9 cursor-pointer"
>
+19 -52
View File
@@ -7,6 +7,7 @@ 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";
import { OFFICIAL_TG_URL, SITE_DOMAIN } from "@/lib/seo/metadata";
/** 对齐 FOYA latestAddrPop(1041/1038):倒计时结束后才允许关闭,默认 3 秒 */
const CLOSE_DELAY_SECONDS = 3;
@@ -17,47 +18,26 @@ export type AddressDialogInfo = {
permanent: string;
latest: string;
telegram: string;
email: string;
mirrors: string[];
publishPage: string;
};
/** 从 home/base 的 announcement / config 组装地址弹窗文案(不改字段名映射,只做展示拼装) */
/**
* 组装地址弹窗文案:地址固定本站域名、联系方式固定官方 TG,不取接口字段
* (保留参数兼容调用方)。
*/
export function buildAddressDialogInfo(
announcement?: AnnouncementInfo | null,
config?: ConfigInfo | null,
_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;
void _announcement;
void _config;
return {
permanent: permanent || latest,
latest: latest || permanent,
telegram,
email,
mirrors,
publishPage,
permanent: SITE_DOMAIN,
latest: SITE_DOMAIN,
telegram: OFFICIAL_TG_URL,
mirrors: [],
publishPage: "/way-home",
};
}
@@ -208,28 +188,17 @@ function AddressCard({
{info.latest ? <p>最新地址: {info.latest}</p> : null}
{info.telegram ? (
<p>
TG交流群:{" "}
官方TG:{" "}
<a
href={info.telegram}
target="_blank"
rel="noopener noreferrer"
className="text-[#FF2D55] hover:underline lg:font-medium"
>
点击加入官方TG群!
点击联系官方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>
@@ -301,10 +270,8 @@ function saveAddressImage(info: AddressDialogInfo) {
...(info.permanent ? [[{ text: `永久地址: ${info.permanent}` }]] : []),
...(info.latest ? [[{ text: `最新地址: ${info.latest}` }]] : []),
...(info.telegram
? [[{ text: "TG交流群: " }, { text: info.telegram, color: ACCENT }]]
? [[{ text: "官方TG: " }, { text: "@jbd125", color: ACCENT }]]
: []),
...(info.email ? [[{ text: `官方邮箱: ${info.email}` }]] : []),
...(info.email ? [[{ text: "(发送任意邮件,自动回复最新地址)" }]] : []),
...(info.publishPage
? [[{ text: "地址发布页: " }, { text: info.publishPage, color: ACCENT }]]
: []),
@@ -353,7 +320,7 @@ function saveAddressImage(info: AddressDialogInfo) {
ctx.fillStyle = "#FFFFFF";
ctx.font = '500 20px "PingFang SC", "Microsoft YaHei", sans-serif';
ctx.fillText("海角社区 · 最新地址", padding, padding + titleHeight / 2);
ctx.fillText("黄果黑料 · 最新地址", padding, padding + titleHeight / 2);
ctx.font = font;
let y = padding + titleHeight + 18 + lineHeight / 2;
@@ -368,7 +335,7 @@ function saveAddressImage(info: AddressDialogInfo) {
}
const link = document.createElement("a");
link.download = "海角社区地址.png";
link.download = "黄果黑料地址.png";
link.href = canvas.toDataURL("image/png");
link.click();
}
@@ -1,223 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef, type ReactNode } from "react";
import { resolveChannel } from "@/lib/analytics/channel";
import {
externalAd,
inferPage,
initializeTracker,
navigation,
pageClick,
pageView,
setPage,
track,
tracker,
type TrackerBoot,
} from "@/components/analytics/tracker";
const SDK_SRC = "/js/web-sdk-v1.2.0.js?v=1";
function loadScript(src: string): Promise<void> {
return new Promise((resolve) => {
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
if (existing) {
resolve();
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => resolve();
document.head.append(script);
});
}
function bootThirdParty() {
const google = (process.env.NEXT_PUBLIC_GTAG_ID || "").trim();
const yandexRaw = (process.env.NEXT_PUBLIC_YANDEX_METRIKA_ID || "").trim();
const yandex = yandexRaw ? Number(yandexRaw) : 0;
// layout 已注入 GA4 时勿重复加载
if (
google &&
!document.querySelector(`script[src*="googletagmanager.com/gtag/js?id=${google}"]`)
) {
window.dataLayer ||= [];
window.gtag ||= function gtag(...args: unknown[]) {
window.dataLayer!.push(args);
};
window.gtag("js", new Date());
window.gtag("config", google);
void loadScript(`https://www.googletagmanager.com/gtag/js?id=${google}`);
}
if (yandex) {
window.ym ||= Object.assign(
function ym(...args: unknown[]) {
(window.ym!.a ||= []).push(args);
},
{ a: [] as unknown[][], l: Date.now() },
);
window.ym(yandex, "init", {
ssr: true,
webvisor: true,
clickmap: true,
ecommerce: "dataLayer",
referrer: document.referrer,
url: location.href,
accurateTrackBounce: true,
trackLinks: true,
});
void loadScript(`https://mc.yandex.ru/metrika/tag.js?id=${yandex}`);
}
}
export function TrackerProvider({
boot: explicitBoot,
children,
}: {
boot?: TrackerBoot;
children?: ReactNode;
}) {
const pathname = usePathname();
const readyRef = useRef(false);
const lastPathRef = useRef("");
useEffect(() => {
let disposed = false;
const channel = resolveChannel();
const inferred = inferPage(pathname || location.pathname);
const boot: TrackerBoot = {
appId: explicitBoot?.appId || process.env.NEXT_PUBLIC_TRACKER_APP_ID || "JHA-1046",
encryptedConfig:
explicitBoot?.encryptedConfig ||
process.env.NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG ||
"",
appVersion: explicitBoot?.appVersion || "1.0.0",
pageKey: explicitBoot?.pageKey || inferred.key,
pageName: explicitBoot?.pageName || inferred.name,
userType: explicitBoot?.userType || "normal",
uid: explicitBoot?.uid || "",
channel: explicitBoot?.channel || channel,
};
void loadScript(SDK_SRC)
.then(() => initializeTracker(boot))
.then(() => {
if (disposed) return;
readyRef.current = true;
lastPathRef.current = pathname || location.pathname;
pageView();
bootThirdParty();
});
const click = (event: MouseEvent) => {
if (!(event.target instanceof Element)) return;
const target = event.target;
if (
!target.closest(
"[data-ad-close],[data-dialog-ad-close],[data-download-close],[data-login-close]",
)
) {
pageClick(event);
}
const anchor = target.closest<HTMLAnchorElement>(
"a[data-navigation-key],a[data-nav-key]",
);
if (anchor) navigation(anchor);
const ad = target.closest<HTMLElement>('[data-track-ad="external"][data-ad-id]');
if (ad) externalAd(ad, "click");
};
document.addEventListener("click", click, true);
const observer =
typeof IntersectionObserver === "undefined"
? null
: new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
externalAd(entry.target as HTMLElement, "show");
observer?.unobserve(entry.target);
}
}
},
{ threshold: 0.1 },
);
const observed = new WeakSet<Element>();
const observe = () =>
document
.querySelectorAll('[data-track-ad="external"][data-ad-id]:not([data-ad-clone])')
.forEach((node) => {
if (!observed.has(node)) {
observed.add(node);
if (observer) observer.observe(node);
else externalAd(node as HTMLElement, "show");
}
});
observe();
const mutations = new MutationObserver(observe);
mutations.observe(document.body, { childList: true, subtree: true });
const external = (event: Event) => {
const detail = (event as CustomEvent<{ element: HTMLElement; eventType: string }>).detail;
if (detail?.element) externalAd(detail.element, detail.eventType);
};
const advertising = (event: Event) => {
const detail = (event as CustomEvent<{ element: HTMLElement; eventType: string }>).detail;
const node = detail?.element;
if (!node) return;
void track("advertising", {
event_type: detail.eventType,
advertising_key: node.dataset.advertisingKey || "dialog_ad",
advertising_name: node.dataset.advertisingName || "弹窗广告",
advertising_id: node.dataset.advertisingId || node.dataset.dialogAdId || "",
});
};
document.addEventListener("external-ad:track", external);
document.addEventListener("dialog-ad:track", advertising);
const flush = () => {
void tracker.flush();
};
window.addEventListener("pagehide", flush);
return () => {
disposed = true;
observer?.disconnect();
mutations.disconnect();
document.removeEventListener("click", click, true);
document.removeEventListener("external-ad:track", external);
document.removeEventListener("dialog-ad:track", advertising);
window.removeEventListener("pagehide", flush);
};
// 仅随 boot 关键字段变化重建;pathname 由下方 effect 处理
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
explicitBoot?.appId,
explicitBoot?.encryptedConfig,
explicitBoot?.uid,
explicitBoot?.channel,
explicitBoot?.userType,
]);
useEffect(() => {
if (!readyRef.current) return;
const path = pathname || "";
if (!path || path === lastPathRef.current) return;
lastPathRef.current = path;
const next = inferPage(path);
setPage(next, true);
}, [pathname]);
return children;
}
export function useTracker() {
return tracker;
}
export default TrackerProvider;
-36
View File
@@ -1,36 +0,0 @@
import { connection } from "next/server";
import { getHomeBaseCached } from "@/lib/api/home";
import { TrackerProvider } from "./TrackerProvider";
/**
* 服务端拉取 reportSdkConfig,客户端初始化 Web SDK。
* 须放在 <Suspense> 内,并用 connection() 排除在 prerender shell 外。
*/
export async function TrackerHost() {
await connection();
let encryptedConfig =
process.env.NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG?.trim() ||
process.env.TRACKER_ENCRYPTED_CONFIG?.trim() ||
"";
try {
const base = await getHomeBaseCached();
// 接口字段在 config.reportSdkConfig
const fromApi = base.config?.reportSdkConfig?.trim();
if (fromApi) encryptedConfig = fromApi;
} catch {
// 接口失败时用环境变量兜底
}
return (
<TrackerProvider
boot={{
appId: process.env.NEXT_PUBLIC_TRACKER_APP_ID || "JHA-1046",
encryptedConfig,
appVersion: "1.0.0",
}}
/>
);
}
-570
View File
@@ -1,570 +0,0 @@
/** 数据上报字段(对齐 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),
};
@@ -1,168 +0,0 @@
"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);
}
@@ -3,7 +3,6 @@
import { useEffect, useState } from "react";
import { ensureGuestSession } from "@/lib/auth/session";
import { tracker } from "@/lib/tracker";
/**
* 仅在挂载后异步拉游客会话,不参与 SSR / 首屏阻塞。
@@ -19,8 +18,6 @@ export function GuestAuthBootstrap() {
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,
+2 -2
View File
@@ -3,7 +3,7 @@
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 { useMockUser } from "@/components/auth/use-mock-user";
import {
ACCOUNT_RULE_HINT,
CREDENTIAL_MAX_LEN,
@@ -156,7 +156,7 @@ export function LoginDialogHost() {
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" />
<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">
+1 -11
View File
@@ -2,7 +2,7 @@ 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";
import { adHref, pickBannerAds } from "./pick";
type AdBannersProps = {
variant?: "pc" | "h5";
@@ -39,11 +39,6 @@ export function AdBanners({
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}
@@ -74,11 +69,6 @@ export function AdBanners({
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}
+1 -6
View File
@@ -3,7 +3,7 @@ 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";
import { adContentKey, adHref, pickIconAds } from "./pick";
type AdIconsProps = {
variant?: "pc" | "h5";
@@ -54,11 +54,6 @@ export function AdIcons({
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}
-18
View File
@@ -36,21 +36,3 @@ export function adHref(link?: string, fallback = "#download") {
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 || ""),
};
}
+73
View File
@@ -0,0 +1,73 @@
import type { BadgeTone } from "@/components/home/data";
/**
* 黄果黑料卡片 / 详情页公用视觉:琥珀色强调(区别于其它导航站的品红主题)。
* 卡片不做整块底色,封面 + 正文排版;hover 只给封面描边、标题变色。
*/
export const HL_ACCENT = "#FFB020";
export const HL_COVER =
"relative block overflow-hidden rounded-md bg-[#17161D] outline outline-1 -outline-offset-1 outline-white/5 transition-[outline-color] duration-300 group-hover:outline-[#FFB020]/80";
export const HL_COVER_IMG =
"absolute inset-0 h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.04]";
export const HL_TITLE_LINK = "transition-colors duration-200 hover:text-[#FFB020] group-hover:text-[#FFB020]";
const badgeText: Record<BadgeTone, string> = {
hot: "热播",
fresh: "新上架",
original: "独家",
};
/** 封面左上角琥珀角标(文字,不用图片角标) */
export function HlBadge({ tone, small = false }: { tone?: BadgeTone | null; small?: boolean }) {
if (!tone) return null;
return (
<span
className={`absolute top-0 left-0 z-[1] rounded-br-md bg-[#FFB020] font-semibold text-[#1A1206] ${
small ? "px-1.5 py-0.5 text-[10px] leading-[14px]" : "px-2 py-0.5 text-xs leading-[18px]"
}`}
>
{badgeText[tone]}
</span>
);
}
/** 接口时间 → YYYY-MM-DD;无效返回空串。优先直接截取,避免服务端 / 浏览器时区不同导致水合不一致 */
export function formatDay(raw?: string | null) {
const text = raw?.trim();
if (!text) return "";
const head = /^\d{4}-\d{2}-\d{2}/.exec(text);
if (head) return head[0];
const date = new Date(/^\d+$/.test(text) ? Number(text) : text);
if (Number.isNaN(date.getTime())) return "";
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
}
/** 秒 → m:ss / h:mm:ss;无效返回空串 */
export function formatDuration(seconds?: number | null) {
const total = Math.floor(Number(seconds) || 0);
if (total <= 0) return "";
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = (value: number) => String(value).padStart(2, "0");
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
/** 卡片元信息里的小眼睛图标 */
export function EyeIcon({ className = "size-3.5" }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" fill="none" className={className} aria-hidden="true">
<path
d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8Z"
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
/>
<circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.3" />
</svg>
);
}
+6 -6
View File
@@ -165,11 +165,11 @@ function DownloadPanel({ onClose }: { onClose: () => void }) {
<section
role="dialog"
aria-modal="true"
aria-label="下载海角社区 App"
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>
<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
@@ -181,7 +181,7 @@ function DownloadPanel({ onClose }: { onClose: () => void }) {
{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} />
@@ -194,10 +194,10 @@ function DownloadPanel({ onClose }: { onClose: () => void }) {
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" />
<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
下载黄果黑料 App
</h2>
<p className="text-sm leading-5 text-[#A49DB4]">扫码安装,继续追剧不丢进度</p>
</div>
+2 -11
View File
@@ -90,8 +90,8 @@ export function getEpisodeState(episode: number, detail: DramaDetail): EpisodeSt
/** 猜你喜欢侧栏展示条数(详情 / 播放) */
export const RELATED_DISPLAY_COUNT = 8;
/** 更多好剧推荐条数(三列网格 3×3) */
export const RECOMMEND_DISPLAY_COUNT = 9;
/** 同类短剧推荐条数(PC 6 列 × 2 行) */
export const RECOMMEND_DISPLAY_COUNT = 12;
/** 猜你喜欢:过滤广告后保留接口原字段 */
export function mapPlayletGuessList(data: PlayletGuessListResp | null | undefined): RelatedDrama[] {
@@ -198,15 +198,6 @@ export function mapPlayletInfo(data: QueryPlayletInfoResp | null | undefined): D
};
}
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;
+117 -220
View File
@@ -1,36 +1,41 @@
"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 { 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 { EpisodeDirectory } from "@/components/media-page/episode-directory";
import {
DramaRecommend,
HlAction,
HlPrimaryLink,
InfoList,
SectionTitle,
TagLinks,
tagLinkItems,
} from "@/components/media-page/parts";
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 { CommentPanel, playletCommentTarget } from "@/components/common/comments";
import { PageBreadcrumb } from "@/components/seo/page-breadcrumb";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import {
formatPlays,
playHref,
totalEpisodeOf,
updateStatusLabel,
yearFromOnlineAt,
type DramaDetail,
type RelatedDrama,
} from "./data";
import { EpisodePanel, H5EpisodeStrip, H5MorePlaylets } from "./episodes";
import { RelatedAside } from "./related-aside";
async function ensurePlayletAuthToken() {
let token = getAuthToken();
@@ -50,49 +55,12 @@ function resolvePlayletLikeFavorite(data: Awaited<ReturnType<typeof getPlayletIn
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,
@@ -100,8 +68,6 @@ export function DramaDetailPage({
}: {
detail: DramaDetail;
related?: RelatedDrama[];
/** 服务端渲染的热门推荐侧栏 */
relatedAside?: ReactNode;
/** 服务端渲染的 PC banner */
promo?: ReactNode;
/** 服务端渲染的 PC icon */
@@ -115,10 +81,11 @@ export function DramaDetailPage({
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 tags = tagLinkItems(pickPlayletCardTags(detail), "playlet");
const totalEpisode = totalEpisodeOf(detail);
const playEpisode = (episode: number) => router.push(playHref(detail.id, episode, detail.slug));
const firstHref = playHref(detail.id, 1, detail.slug);
const title = detail.title || "短剧";
const desc = detail.desc?.trim() || "";
const likeFavoriteKey = `${detail.id}:${detail.isLike}:${detail.isFavorite}`;
const [likeFavoriteSync, setLikeFavoriteSync] = useState(likeFavoriteKey);
@@ -211,205 +178,135 @@ export function DramaDetailPage({
}
};
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" />
const crumbs = [
{ name: "首页", href: "/" },
{ name: "短剧库", href: "/short-drama/hot" },
{ name: title },
];
const infoRows = [
{ label: "集数", value: `全 ${totalEpisode} 集` },
{ label: "状态", value: updateStatusLabel(detail.updateStatus) },
{ label: "上线", value: yearFromOnlineAt(detail.onlineAt) },
{ label: "题材", value: detail.themeNames?.slice(0, 3).join(" / ") },
{ label: "主演", value: detail.anchor?.trim() },
{ label: "热度", value: formatPlays(detail.countBrowse ?? detail.heatValue) },
];
const actions = (compact?: boolean) => (
<>
<HlAction kind="like" label={liked ? "已点赞" : "点赞"} active={liked} onClick={liking ? undefined : toggleLike} compact={compact} />
<HlAction
kind="collect"
label={collected ? "已收藏" : "收藏"}
active={collected}
onClick={collecting ? undefined : toggleCollect}
compact={compact}
/>
<HlAction kind="share" label="分享" onClick={share} compact={compact} />
</>
);
return (
<main data-component="hl-drama-detail" className="relative flex-1 bg-[#0C0B10]">
<SeoBreadcrumb items={crumbs} />
{/* PC */}
<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)]"
className="relative hidden flex-col gap-10 pt-8 pb-14 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 || "短剧详情" },
]}
/>
<PageBreadcrumb items={crumbs} />
<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>
<article className="grid grid-cols-[minmax(0,1fr)_340px] gap-14 border-t-2 border-[#FFB020] pt-10">
<div className="flex min-w-0 flex-col gap-6">
<p className="text-sm leading-5 tracking-[0.2em] text-[#FFB020]">短剧 · {updateStatusLabel(detail.updateStatus)}</p>
<h1 className="text-[44px] leading-[56px] font-bold text-white">{title}</h1>
<InfoList rows={infoRows} />
<TagLinks tags={tags} />
{desc ? (
<section className="flex flex-col gap-3">
<SectionTitle>剧情介绍</SectionTitle>
<p className="max-w-[1100px] text-[15px] leading-7 whitespace-pre-line text-[#B5B3BE]">{desc}</p>
</section>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-3">
<HlPrimaryLink href={firstHref}>播放第 1 集</HlPrimaryLink>
{actions()}
<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"
className="ml-2 cursor-pointer text-sm text-[#8E8C99] underline-offset-4 hover:text-[#FFB020] hover:underline"
>
下载
下载 APP 离线观看
</button>
</div>
</div>
</section>
<figure className="flex flex-col gap-3">
<DecryptedImage
src={detail.cover}
fallback={IMG_PLACEHOLDER}
alt={`${title}海报`}
priority
className="aspect-[2/3] w-full rounded-md object-cover"
/>
<figcaption className="text-center text-xs leading-[18px] text-[#6F6C7B]">《{title}》海报</figcaption>
</figure>
</article>
{adIcons ?? <AdIcons variant="pc" />}
<EpisodePanel detail={detail} current={1} onSelect={playEpisode} />
<EpisodeDirectory detail={detail} variant="panel" />
<div className="flex items-start gap-6">
<CommentPanel target={playletCommentTarget(detail.id)} />
{relatedAside ?? <RelatedAside items={related} />}
</div>
<DramaRecommend items={related} />
{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}
{/* H5 */}
<div className="relative flex flex-col gap-6 px-4 pt-3 pb-10 lg:hidden">
<div className="flex h-9 items-center">
<BackButton fallback="/short-drama/hot" />
</div>
<article className="flex flex-col gap-4">
<div className="flex gap-4">
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
<p className="text-[11px] leading-4 tracking-[0.15em] text-[#FFB020]">短剧 · {updateStatusLabel(detail.updateStatus)}</p>
<h1 className="line-clamp-3 text-xl leading-7 font-bold text-white">{title}</h1>
<InfoList rows={infoRows.slice(0, 4)} compact />
</div>
<DecryptedImage
src={detail.cover}
fallback={IMG_PLACEHOLDER}
alt={`${title}海报`}
priority
className="aspect-[2/3] w-[104px] shrink-0 self-start rounded-md object-cover"
/>
</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}
<TagLinks tags={tags} compact />
<div className="flex flex-col gap-2">
<HlPrimaryLink href={firstHref} compact className="w-full">
播放第 1 集
</HlPrimaryLink>
<div className="grid grid-cols-3 gap-2">{actions(true)}</div>
</div>
{desc ? (
<section className="flex flex-col gap-2">
<SectionTitle compact>剧情介绍</SectionTitle>
<p className="text-[13px] leading-[22px] whitespace-pre-line text-[#A9A7B3]">{desc}</p>
</section>
) : null}
</article>
{adGrid ?? <AdIcons variant="h5" />}
<H5EpisodeStrip detail={detail} onSelect={playEpisode} />
<EpisodeDirectory detail={detail} variant="h5" />
<H5MorePlaylets items={related} />
<DramaRecommend items={related} compact />
{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>
+65 -139
View File
@@ -2,36 +2,31 @@
import { toast } from "@heroui/react";
import Link from "next/link";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useEffect, 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 { pickPlayletCardTags } from "@/components/common/card-tags";
import {
dramaDetailHref,
getEpisodeState,
playHref,
totalEpisodeOf,
updateStatusLabel,
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 { EpisodeDirectory } from "@/components/media-page/episode-directory";
import { DramaRecommend, HlAction, TagLinks, tagLinkItems } from "@/components/media-page/parts";
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 };
@@ -67,46 +62,11 @@ 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,
}: {
@@ -114,7 +74,6 @@ export function DramaPlayPage({
episode: number;
src: string;
related?: RelatedDrama[];
relatedAside?: ReactNode;
adIcons?: ReactNode;
promo?: ReactNode;
}) {
@@ -127,24 +86,11 @@ export function DramaPlayPage({
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 title = detail.title || "短剧";
const totalEpisode = totalEpisodeOf(detail);
const prevHref = episode > 1 ? playHref(detail.id, episode - 1, detail.slug) : null;
const nextHref = episode < totalEpisode ? playHref(detail.id, episode + 1, detail.slug) : null;
const tags = tagLinkItems(pickPlayletCardTags(detail), "playlet");
const likeFavoriteKey = `${detail.id}:${detail.isLike}:${detail.isFavorite}:${detail.countCollect}:${detail.displayCountLike}`;
const [likeFavoriteSync, setLikeFavoriteSync] = useState(likeFavoriteKey);
@@ -268,107 +214,89 @@ export function DramaPlayPage({
}
};
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);
const crumbs = [
{ name: "首页", href: "/" },
{ name: "短剧库", href: "/short-drama/hot" },
{ name: title, href: dramaDetailHref(detail) },
{ name: `第${episode}集` },
];
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}
<main data-component="hl-drama-play" className="relative flex-1 bg-[#0C0B10]">
<SeoBreadcrumb items={crumbs} />
<div
{...(expanded ? {} : pcScaleProps)}
className={`relative hidden flex-col gap-6 pb-8 lg:flex ${expanded ? "" : `pt-6 ${pcFrame}`}`}
className={`relative hidden flex-col gap-8 pb-14 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}
{!expanded ? <PageBreadcrumb items={crumbs} /> : null}
<div className={expanded ? "contents" : "flex flex-col gap-4"}>
<div className={expanded ? "contents" : "flex flex-col gap-8"}>
<div className={expanded ? "contents" : "flex h-[648px] gap-5"}>
<PcPlayer
key={`${episode}-${playSrc}`}
src={playSrc}
poster={detail.cover || ""}
title={`${detail.title || ""} 第${episode}集`}
title={`${title} 第${episode}集`}
locked={locked}
episode={episode}
dramaId={detail.slug?.trim() || String(detail.id)}
dramaTitle={detail.title || ""}
dramaTitle={title}
layout={expanded ? "theater" : "inline"}
onToggleExpand={toggleExpanded}
onPlaySuccess={reportWatchHistory}
/>
{!expanded ? <PlayEpisodePanel detail={detail} current={episode} /> : null}
{!expanded ? <EpisodeDirectory detail={detail} current={episode} variant="side" /> : null}
</div>
<div
{...(expanded ? pcScaleProps : {})}
className={expanded ? `flex flex-col gap-4 ${pcFrame}` : "contents"}
className={expanded ? `flex flex-col gap-8 ${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}集
<article className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-10 gap-y-5 border-t-2 border-[#FFB020] pt-8">
<div className="flex min-w-0 flex-col gap-3">
<p className="text-sm leading-5 tracking-[0.2em] text-[#FFB020]">
第 {episode} / {totalEpisode} 集 · {updateStatusLabel(detail.updateStatus)}
</p>
<h1 className="text-4xl leading-[48px] font-bold text-white">
{title}
<span className="ml-3 text-[#FFB020]">第{episode}集</span>
</h1>
<TagLinks tags={tags} />
{detail.desc?.trim() ? (
<p className="line-clamp-3 max-w-[1100px] text-[15px] leading-7 text-[#B5B3BE]">{detail.desc.trim()}</p>
) : null}
</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"
<div className="flex shrink-0 gap-3 pt-7">
<HlAction kind="like" label={liked ? "已点赞" : "点赞"} active={liked} onClick={toggleLike} />
<HlAction
kind="collect"
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" />
<HlAction kind="share" label="分享" onClick={share} />
</div>
</section>
<nav aria-label="分集导航" className="col-span-2 flex items-center gap-3 text-sm">
{prevHref ? (
<Link href={prevHref} className="rounded-md border border-white/12 px-4 py-2 text-[#D8D6DF] hover:border-[#FFB020] hover:text-[#FFB020]">
‹ 上一集:第{episode - 1}集
</Link>
) : null}
{nextHref ? (
<Link href={nextHref} className="rounded-md border border-white/12 px-4 py-2 text-[#D8D6DF] hover:border-[#FFB020] hover:text-[#FFB020]">
下一集:第{episode + 1}集 ›
</Link>
) : null}
<Link href={dramaDetailHref(detail)} className="ml-auto text-[#8E8C99] hover:text-[#FFB020]">
《{title}》剧情介绍与全部分集 ›
</Link>
</nav>
</article>
<div className="flex items-start gap-6">
<CommentPanel target={commentTarget} />
{relatedAside ?? <RelatedAside items={related} />}
</div>
{expanded ? <EpisodeDirectory detail={detail} current={episode} variant="panel" /> : null}
{adIcons ?? <AdIcons variant="pc" />}
<DramaRecommend items={related} />
{promo ?? <AdBanners variant="pc" />}
</div>
@@ -389,8 +317,6 @@ export function DramaPlayPage({
onLike={toggleLike}
onCollect={toggleCollect}
onShare={share}
commentTarget={commentTarget}
onPlaySuccess={reportWatchHistory}
/>
<div className="max-lg:hidden">
+80 -314
View File
@@ -5,25 +5,24 @@ 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 { pickPlayletCardTags } from "@/components/common/card-tags";
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 { EpisodeDirectory } from "@/components/media-page/episode-directory";
import { DramaRecommend, HlAction, TagLinks, tagLinkItems } from "@/components/media-page/parts";
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;
type Sheet = "speed" | null;
/** 播完自动下一集倒计时(秒) */
const AUTO_NEXT_SECONDS = 3;
@@ -64,7 +63,6 @@ type H5PlayProps = {
onLike: () => void;
onCollect: () => void;
onShare: () => void;
commentTarget: CommentTarget | null;
onPlaySuccess?: () => void;
};
@@ -75,7 +73,7 @@ function LockedNotice() {
<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"
className="cursor-pointer inline-flex h-9 items-center justify-center rounded-md bg-[#FFB020] px-6 text-sm font-semibold text-[#1A1206]"
>
下载APP观看完整剧集
</button>
@@ -140,147 +138,6 @@ function SheetHead({ title, onClose, children }: { title: ReactNode; onClose: ()
);
}
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,
@@ -294,14 +151,12 @@ export function H5Play({
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);
@@ -309,10 +164,14 @@ export function H5Play({
const [shouldAutoplay] = useState(consumeAutoplayFlag);
const closeSheet = () => setSheet(null);
const title = detail.title || "短剧";
const totalEpisode = totalEpisodeOf(detail);
const nextEpisode = episode + 1;
const hasNext = nextEpisode <= totalEpisode;
const nextHref = hasNext ? playHref(detail.id, nextEpisode, detail.slug) : null;
const prevHref = episode > 1 ? playHref(detail.id, episode - 1, detail.slug) : null;
const tags = tagLinkItems(pickPlayletCardTags(detail), "playlet");
const desc = detail.desc?.trim() || "";
useEffect(() => {
if (!sheet) return;
@@ -357,47 +216,11 @@ export function H5Play({
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">
<div data-component="hl-drama-play-h5" className="flex flex-col bg-[#0C0B10] lg:hidden">
<div className="sticky top-0 z-30 aspect-[3/4] max-h-[72vh] w-full overflow-hidden bg-black">
{locked ? (
<DecryptedImage
src={detail.cover}
alt={detail.title}
className="absolute inset-0 size-full object-cover"
/>
<DecryptedImage src={detail.cover} alt={`${title} 第${episode}集`} className="absolute inset-0 size-full object-cover" />
) : (
<XgPlayer
src={src}
@@ -410,146 +233,93 @@ export function H5Play({
}}
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">
<div className="pointer-events-none absolute inset-x-0 top-0 h-20 bg-[linear-gradient(180deg,rgba(0,0,0,0.7)_0%,rgba(0,0,0,0)_100%)]" />
<div className="absolute inset-x-0 top-0 z-20 flex items-center gap-2 px-3 pt-[calc(8px+env(safe-area-inset-top))] pb-2">
<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>
<p className="min-w-0 flex-1 truncate text-sm leading-5 text-white/90">
第 {episode} / {totalEpisode} 集
</p>
<button
type="button"
onClick={() => setSheet("speed")}
className="cursor-pointer flex items-center gap-1 rounded bg-black/40 px-2 py-1 text-xs leading-[18px] font-medium text-white"
>
<img src="/images/speed.svg" alt="" className="size-4" />
{rate === 1 ? "1.0" : rate}x
</button>
</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"
className="absolute inset-x-3 bottom-3 z-20 flex cursor-pointer items-center justify-between rounded-md bg-black/60 px-3 py-2 text-sm leading-5"
>
<span className="text-white">自动播放下一集</span>
<span className="text-[#FFAB6B]">倒计时{autoNextSeconds}s</span>
<span className="text-white">即将播放第 {nextEpisode} 集</span>
<span className="text-[#FFB020]">{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>
<div className="flex flex-col gap-6 px-4 pt-4 pb-10">
<article className="flex flex-col gap-3">
<p className="text-[11px] leading-4 tracking-[0.15em] text-[#FFB020]">
短剧 · {updateStatusLabel(detail.updateStatus)} · 共{totalEpisode}集
</p>
<h1 className="text-lg leading-7 font-bold text-white">
{title}
<span className="ml-2 text-[#FFB020]">第{episode}集</span>
</h1>
<TagLinks tags={tags} compact />
{desc ? (
<div className="flex items-end gap-1.5">
<p className={`min-w-0 flex-1 text-[13px] leading-[22px] text-[#A9A7B3] ${introOpen ? "" : "line-clamp-2"}`}>{desc}</p>
<button
type="button"
onClick={() => setIntroOpen((open) => !open)}
className="cursor-pointer shrink-0 text-xs leading-[22px] text-[#FFB020]"
>
{introOpen ? "收起" : "展开"}
</button>
</div>
) : null}
<div className="flex items-center gap-2">
<HlAction kind="like" label={formatCount(likeCount)} active={liked} onClick={onLike} compact />
<HlAction kind="collect" label={formatCount(collectCount)} active={collected} onClick={onCollect} compact />
<HlAction kind="share" label="分享" onClick={onShare} compact />
</div>
<nav aria-label="分集导航" className="grid grid-cols-2 gap-2 text-xs leading-[18px]">
{prevHref ? (
<Link href={prevHref} className="flex items-center gap-1 rounded-md border border-white/12 px-3 py-2 text-[#D8D6DF]">
<ChevronIcon direction="left" className="size-3.5" />
上一集 · 第{episode - 1}集
</Link>
) : (
<span />
)}
{nextHref ? (
<Link
href={nextHref}
className="flex items-center justify-end gap-1 rounded-md border border-white/12 px-3 py-2 text-[#D8D6DF]"
>
下一集 · 第{nextEpisode}集
<ChevronIcon direction="right" className="size-3.5" />
</Link>
) : null}
</nav>
</article>
<BottomSheet open={sheet === "episodes"} onClose={closeSheet} label="选集">
<EpisodeSheet
detail={detail}
episode={episode}
collected={collected}
related={related}
onCollect={onCollect}
onClose={closeSheet}
/>
</BottomSheet>
<EpisodeDirectory detail={detail} current={episode} variant="h5" />
<DramaRecommend items={related} compact />
</div>
<BottomSheet open={sheet === "speed"} onClose={closeSheet} label="倍速" className="gap-3 px-4 py-[18px]">
<SheetHead title="倍速" onClose={closeSheet} />
@@ -565,17 +335,13 @@ export function H5Play({
setRate(item);
closeSheet();
}}
className={`cursor-pointer flex h-12 items-center justify-between text-base leading-[22px] ${item === rate ? "text-[#F12B9E]" : "text-white"}`}
className={`cursor-pointer flex h-12 items-center justify-between text-base leading-[22px] ${item === rate ? "text-[#FFB020]" : "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>
);
}
-3
View File
@@ -177,7 +177,6 @@ type PcPlayerProps = PlayerProps & {
layout: PlayerLayout;
onToggleExpand?: () => void;
onPlaySuccess?: () => void;
analyticsKind?: "drama" | "video";
};
export function PcPlayer({
@@ -191,7 +190,6 @@ export function PcPlayer({
layout,
onToggleExpand,
onPlaySuccess,
analyticsKind = "drama",
}: PcPlayerProps) {
const playerRef = useRef<XgPlayerInstance | null>(null);
const toggleRef = useRef(onToggleExpand);
@@ -245,7 +243,6 @@ export function PcPlayer({
playerRef={playerRef}
onReady={onReady}
onPlaySuccess={onPlaySuccess}
analyticsKind={analyticsKind}
clickToPlay
className="absolute inset-0"
options={{ cssFullscreen: Boolean(onToggleExpand) }}
-449
View File
@@ -1,449 +0,0 @@
"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>
);
}
@@ -1,81 +0,0 @@
/** 与 swagger QuestionnaireSubmitReq 枚举文案保持一致 */
export const questionTypes = [
"播放卡顿/缓冲",
"无法播放",
"画质模糊",
"音画不同步",
"加载失败",
"闪退/崩溃",
"登录异常",
"其他问题",
] as const;
export const frequencies = ["每次都会", "经常出现", "偶尔出现", "仅现一次"] as const;
export const deviceTypes = ["iPhone", "Android", "iPad", "其他"] as const;
export const networkTypes = ["WI-FI", "5G", "4G", "其他"] as const;
export const operators = ["电信", "联通", "移动", "广电"] as const;
export const provinces = [
"北京",
"天津",
"河北",
"山西",
"内蒙古",
"辽宁",
"吉林",
"黑龙江",
"上海",
"江苏",
"浙江",
"安徽",
"福建",
"江西",
"山东",
"河南",
"湖北",
"湖南",
"广东",
"广西",
"海南",
"重庆",
"四川",
"贵州",
"云南",
"西藏",
"陕西",
"甘肃",
"青海",
"宁夏",
"新疆",
"香港",
"澳门",
"台湾",
"海外",
] as const;
export function detectDeviceType(): (typeof deviceTypes)[number] {
if (typeof navigator === "undefined") return "其他";
const ua = navigator.userAgent;
if (/iPad/i.test(ua)) return "iPad";
if (/iPhone/i.test(ua)) return "iPhone";
if (/Android/i.test(ua)) return "Android";
return "其他";
}
export function detectNetworkType(): (typeof networkTypes)[number] {
if (typeof navigator === "undefined") return "其他";
const connection = (
navigator as Navigator & {
connection?: { type?: string; effectiveType?: string };
}
).connection;
const type = (connection?.type || "").toLowerCase();
const effective = (connection?.effectiveType || "").toLowerCase();
if (type === "wifi" || effective.includes("wifi")) return "WI-FI";
if (effective === "5g" || (type === "cellular" && effective.includes("5g"))) return "5G";
if (effective === "4g" || effective === "3g" || type === "cellular") return "4G";
return "WI-FI";
}
-38
View File
@@ -1,38 +0,0 @@
import { FeedbackForm } from "@/components/feedback/feedback-form";
import { InfoPageShell } from "@/components/info/info-page-layout";
const notes = [
{ title: "处理时间", text: "一般问题将在 1–3 个工作日内查看。" },
{ title: "紧急问题", text: "账号安全或付费异常,请在描述中注明。" },
];
export function FeedbackPage({ gmailAddr }: { gmailAddr?: string | null }) {
const email = gmailAddr?.trim();
return (
<InfoPageShell data-component="feedback-page" title="问题反馈" subtitle="告诉我们您遇到的问题或建议">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:gap-6">
<FeedbackForm />
<aside className="flex flex-col gap-[5px] rounded-2xl bg-[#13111C] px-4 py-3 lg:w-80 lg:shrink-0 lg:gap-[18px] lg:bg-[#0E0D17] lg:p-[22px] lg:outline lg:outline-1 lg:-outline-offset-1 lg:outline-[#2E293D]">
<h2 className="text-sm leading-5 font-bold text-[#FAF7FF] lg:text-xl lg:leading-[26px] lg:font-semibold lg:text-[#F5F2FC]">
反馈说明
</h2>
{notes.map((note) => (
<div key={note.title} className="flex flex-col lg:gap-1.5">
<h3 className="hidden text-sm leading-5 text-[#F5F2FC] lg:block">{note.title}</h3>
<p className="text-xs leading-[18px] text-[#9E94AD] lg:text-sm lg:leading-5 lg:text-[#948CA8]">
<span className="lg:hidden">{note.title}:</span>
{note.text}
</p>
</div>
))}
{email ? (
<a href={`mailto:${email}`} className="text-xs leading-[18px] text-[#F12B9E]">
联系邮箱:{email}
</a>
) : null}
</aside>
</div>
</InfoPageShell>
);
}
-43
View File
@@ -1,43 +0,0 @@
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>
);
}
+1 -1
View File
@@ -243,7 +243,7 @@ const boardItems: RankItem[] = [
export const rankBoards: RankBoard[] = [1, 2, 3, 4].map((index) => ({
id: `board-${index}`,
title: "一周口碑电影榜",
subtitle: "海角榜单",
subtitle: "黄果榜单",
cover: cover(`haijiao-board-${index}`, 410, 644),
items: boardItems,
}));
+2 -2
View File
@@ -135,7 +135,7 @@ function StyleHBoardCard({
/** 样式 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();
const subtitle = (module.label || "黄果榜单").trim();
if (!topics.length) return null;
return (
@@ -212,7 +212,7 @@ function H5StyleHBoardCard({
export function HomeModuleStyleHH5({ module }: { module: HomeModuleData }) {
const moreHref = morePageHref(module);
const title = (module.title || module.label || "推荐").trim();
const subtitle = (module.label || "海角榜单").trim();
const subtitle = (module.label || "黄果榜单").trim();
const topics = (module.topicList ?? []).filter((topic) => topic.name).slice(0, 4);
if (!topics.length) return null;
+10 -39
View File
@@ -9,13 +9,12 @@ import {
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 { TopicListCard } from "@/components/topic/topic-list-card";
import { videoHref } from "@/components/video/video-card";
import type { HomeModuleData, PlayletData, VideoInfo } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
@@ -32,8 +31,6 @@ import { HomeModuleStyleH, HomeModuleStyleHH5 } from "./home-module-style-h";
import {
isVideoDataType,
normalizeHomeModuleStyle,
topicCountLabel,
topicCoverSrc,
unwrapHomeModuleList,
videoCoverSrc,
} from "./home-module-style";
@@ -355,41 +352,15 @@ export function HomeModules({
</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 className={compact ? "grid grid-cols-2 gap-x-2.5 gap-y-4" : "grid grid-cols-2 gap-x-5 gap-y-7 lg:grid-cols-4"}>
{topics.map((topic) => (
<TopicListCard
key={topic.id}
topic={topic}
dataType={isVideoDataType(module.dataType) ? 2 : 1}
compact={compact}
/>
))}
</div>
) : null}
</section>
+1 -1
View File
@@ -10,7 +10,7 @@ import { HomePageH5 } from "./home-page-h5";
import { NoticeMarquee } from "./notice-marquee";
import { DownloadBanner } from "./page-sections";
const HOME_H1_FALLBACK = "海角社区 - 热门短剧在线看";
const HOME_H1_FALLBACK = "黄果黑料 - 热门短剧在线看";
export function HomePage({
home,
-8
View File
@@ -44,11 +44,3 @@ export function PlayIcon({ className }: IconProps) {
);
}
export function HistoryIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" strokeWidth="1.6" />
<path d="M12 8v4.2l2.6 1.6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}
+7 -7
View File
@@ -12,11 +12,11 @@ export function DownloadBanner() {
target="_blank"
rel="noopener noreferrer"
className="block w-full"
aria-label="下载海角社区 App"
aria-label="下载黄果黑料 App"
>
<img
src="/images/download_banner.png"
alt="在海角社区APP上继续追剧,离线缓存随时看"
alt="在黄果黑料APP上继续追剧,离线缓存随时看"
className="h-auto w-full"
/>
</a>
@@ -25,11 +25,11 @@ export function DownloadBanner() {
type="button"
onClick={() => openAppDownload()}
className="block w-full cursor-pointer"
aria-label="下载海角社区 App"
aria-label="下载黄果黑料 App"
>
<img
src="/images/download_banner.png"
alt="在海角社区APP上继续追剧,离线缓存随时看"
alt="在黄果黑料APP上继续追剧,离线缓存随时看"
className="h-auto w-full"
/>
</button>
@@ -41,7 +41,7 @@ export function DownloadBanner() {
export function DownloadBannerH5() {
const href = useAppDownloadHref();
const className = "block w-full overflow-hidden rounded-xl";
const img = <img src="/images/download_banner_h5.png" alt="在海角社区APP上继续追剧" className="h-auto w-full" />;
const img = <img src="/images/download_banner_h5.png" alt="在黄果黑料APP上继续追剧" className="h-auto w-full" />;
if (href) {
return (
@@ -52,7 +52,7 @@ export function DownloadBannerH5() {
target="_blank"
rel="noopener noreferrer"
className={className}
aria-label="下载海角社区 App"
aria-label="下载黄果黑料 App"
>
{img}
</a>
@@ -66,7 +66,7 @@ export function DownloadBannerH5() {
type="button"
onClick={() => openAppDownload()}
className={`cursor-pointer ${className}`}
aria-label="下载海角社区 App"
aria-label="下载黄果黑料 App"
>
{img}
</button>
@@ -58,8 +58,6 @@ function DesktopNavItemInner({
<Link
href={parentHref}
className={linkClass(active)}
data-navigation-key={category.urlValue || `nav_${category.id || category.name}`}
data-navigation-name={category.name || ""}
>
<span>{category.name}</span>
<ActiveMark active={active} />
@@ -71,8 +69,6 @@ function DesktopNavItemInner({
key={item.id}
href={resolveNavHref(item.urlValue)}
className="block px-4 py-2 text-sm text-[#948CA8] hover:bg-white/5 hover:text-white"
data-navigation-key={item.urlValue || `nav_item_${item.id || item.name}`}
data-navigation-name={item.name || ""}
>
{item.name}
</Link>
@@ -90,8 +86,6 @@ function DesktopNavItemInner({
target="_blank"
rel="noopener noreferrer"
className={linkClass(false)}
data-navigation-key={category.urlValue || `nav_${category.id || category.name}`}
data-navigation-name={category.name || ""}
>
<span>{category.name}</span>
</a>
@@ -102,8 +96,6 @@ function DesktopNavItemInner({
<Link
href={href}
className={linkClass(active)}
data-navigation-key={category.urlValue || `nav_${category.id || category.name}`}
data-navigation-name={category.name || ""}
>
<span>{category.name}</span>
<ActiveMark active={active} />
-13
View File
@@ -1,13 +0,0 @@
import { infoLinkHref } from "@/components/layout/info-links";
export type FooterContactLink = {
key: string;
href: string;
text: string;
};
/** 页脚「客户服务」:仅帮助中心、问题反馈 */
export const FOOTER_CUSTOMER_SERVICE_LINKS: FooterContactLink[] = [
{ key: "help", href: infoLinkHref("帮助中心"), text: "帮助中心" },
{ key: "feedback", href: infoLinkHref("问题反馈"), text: "问题反馈" },
];
+2 -30
View File
@@ -3,19 +3,12 @@ import type { AppTranslationInfo, TranslationInfo } from "@/lib/api/types";
/** 接口失败时兜底:PC「关于平台」(与 home/base → bottonTranslations 对齐) */
export const BOTTOM_NAV_FALLBACK_NAMES = ["用户条款", "隐私政策", "联系我们", "常见问题"] as const;
/** PC「客户服务」从底部导航拆出的名称(含意见反馈别名) */
export const CUSTOMER_SERVICE_NAMES = ["帮助中心", "问题反馈"] as const;
/** 已下线的客服入口:接口仍下发时不展示 */
function isCustomerServiceName(name?: string | null) {
const value = name?.trim() || "";
return value === "帮助中心" || value === "问题反馈" || value === "意见反馈";
}
function isFeedbackName(name?: string | null) {
const value = name?.trim() || "";
return value === "问题反馈" || value === "意见反馈";
}
/**
* home/base → bottonTranslations 扁平化(底部导航)
* 优先取 categoryType=3(底部导航)的分类;无 type=3 时用全部(字段本身即底部导航)。
@@ -26,26 +19,5 @@ export function footerNavItems(bottonTranslations?: AppTranslationInfo | null):
const source = bottomCats.length ? bottomCats : categories;
return source
.flatMap((category) => category.translationList ?? [])
.filter((item): item is TranslationInfo => Boolean(item.name?.trim()));
}
/** PC「关于平台」:底部导航去掉帮助中心 / 问题反馈(意见反馈) */
export function footerAboutNavItems(bottonTranslations?: AppTranslationInfo | null): TranslationInfo[] {
return footerNavItems(bottonTranslations).filter((item) => !isCustomerServiceName(item.name));
}
/**
* PC「客户服务」:从底部导航拆出帮助中心、问题反馈/意见反馈;
* 保留接口 name / urlValue;缺项时用同名兜底。
*/
export function footerCustomerServiceNavItems(
bottonTranslations?: AppTranslationInfo | null,
): TranslationInfo[] {
const api = footerNavItems(bottonTranslations);
const help = api.find((item) => item.name?.trim() === "帮助中心");
const feedback = api.find((item) => isFeedbackName(item.name));
return [
help ?? ({ id: -100, name: "帮助中心" } satisfies TranslationInfo),
feedback ?? ({ id: -101, name: "问题反馈" } satisfies TranslationInfo),
];
.filter((item): item is TranslationInfo => Boolean(item.name?.trim()) && !isCustomerServiceName(item.name));
}
-3
View File
@@ -6,9 +6,6 @@ const infoLinkHrefs: Record<string, string> = {
用户条款: toRichTextPath("用户条款"),
隐私政策: toRichTextPath("隐私政策"),
常见问题: toRichTextPath("常见问题"),
帮助中心: "/help",
问题反馈: "/feedback",
意见反馈: "/feedback",
联系我们: toRichTextPath("联系我们"),
};
+2 -11
View File
@@ -4,15 +4,11 @@ import Form from "next/form";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { useMockUser } from "@/components/profile/use-mock-user";
import type { AppTranslationInfo, ConfigInfo, TranslationCategory } from "@/lib/api/types";
import { MobileMenu } from "./mobile-menu";
import { SiteSearchInput } from "./site-search-input";
const fallbackAvatar = "/images/default_avatar.svg";
export function MobileHeader({
categoryList,
bottonTranslations,
@@ -22,8 +18,6 @@ export function MobileHeader({
bottonTranslations?: AppTranslationInfo | null;
config?: ConfigInfo | null;
}) {
const { user } = useMockUser();
const avatar = user?.avatar || fallbackAvatar;
const [panel, setPanel] = useState<"menu" | "search" | null>(null);
const closePanel = useCallback(() => setPanel(null), []);
const toggle = (next: "menu" | "search") => setPanel((current) => (current === next ? null : next));
@@ -40,8 +34,8 @@ export function MobileHeader({
>
<img src="/images/menu.svg" alt="" className="size-full" />
</button>
<Link href="/" aria-label="海角社区" onClick={() => setPanel(null)}>
<img src="/logo.svg" alt="海角社区" className="h-6 w-auto" />
<Link href="/" aria-label="黄果黑料" onClick={() => setPanel(null)}>
<img src="/logo.svg" alt="黄果黑料" className="h-6 w-auto" />
</Link>
<div className="flex items-center gap-3">
<button
@@ -53,9 +47,6 @@ export function MobileHeader({
>
<img src="/images/search_h5.svg" alt="" className="size-full" />
</button>
<Link href="/profile" aria-label="我的" className="size-6 overflow-hidden rounded-full" onClick={() => setPanel(null)}>
<DecryptedImage src={avatar} fallback={fallbackAvatar} priority alt="" className="size-full object-cover" />
</Link>
</div>
</div>
<MobileMenu
+15 -63
View File
@@ -7,6 +7,7 @@ import { useEffect, useState, type ReactNode } from "react";
import { openAppDownload, useAppDownloadHref } from "@/components/download/download-dialog";
import { footerNavItems } from "@/components/layout/footer-nav-items";
import type { AppTranslationInfo, ConfigInfo, TranslationCategory, TranslationInfo } from "@/lib/api/types";
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL } from "@/lib/seo/metadata";
import { resolveAboutPlatformHref, resolveNavHref } from "./nav-href";
import { categoryHref, isNavActive } from "./nav-utils";
@@ -21,54 +22,18 @@ function withDividers(items: ReactNode[], divider: string) {
);
}
/** 仅接口有值才展示,不造假数据 */
function buildContacts(config?: ConfigInfo | null) {
const email = config?.gmailAddr?.trim();
const business = config?.telegram?.trim();
const group = config?.tgGroup?.trim() || config?.telegramAdd?.trim();
const items: { key: string; node: ReactNode }[] = [];
if (email) {
items.push({
key: "email",
node: (
<a href={`mailto:${email}`} className="text-xs leading-[18px] text-[#615970]">
E-mail:{email}
</a>
),
});
}
if (business) {
items.push({
key: "business",
node: (
<a href={business} target="_blank" rel="noopener noreferrer" className="text-xs leading-[18px] text-[#615970]">
商务合作: 立即联系
</a>
),
});
}
if (group) {
items.push({
key: "group",
node: (
<a href={group} target="_blank" rel="noopener noreferrer" className="text-xs leading-[18px] text-[#615970]">
官方群: 立即加入
</a>
),
});
}
return items;
}
function buildSocials(config?: ConfigInfo | null) {
const telegram = config?.tgGroup?.trim() || config?.telegramAdd?.trim() || config?.telegram?.trim();
const twitter = config?.twitterAddr?.trim();
return [
telegram ? { label: "Telegram", icon: "/images/footer_tg.svg", href: telegram } : null,
twitter ? { label: "X", icon: "/images/footer_x.svg", href: twitter } : null,
].filter(Boolean) as { label: string; icon: string; href: string }[];
}
/** 联系方式只保留官方 TG */
const contacts = [
{
key: "tg",
node: (
<a href={OFFICIAL_TG_URL} target="_blank" rel="noopener noreferrer" className="text-xs leading-[18px] text-[#615970]">
官方TG:{OFFICIAL_TG_HANDLE}
</a>
),
},
];
const socials = [{ label: "Telegram", icon: "/images/footer_tg.svg", href: OFFICIAL_TG_URL }];
function InfoNavLink({ item, onClose }: { item: TranslationInfo; onClose: () => void }) {
const href = resolveAboutPlatformHref(item);
@@ -93,7 +58,6 @@ export function MobileMenu({
onClose,
categoryList,
bottonTranslations,
config,
}: {
open: boolean;
onClose: () => void;
@@ -107,8 +71,6 @@ export function MobileMenu({
// 仅渲染接口 bottonTranslations,不补自定义项
const bottomNavLinks = footerNavItems(bottonTranslations);
const downloadHref = useAppDownloadHref();
const contacts = buildContacts(config);
const socials = buildSocials(config);
useEffect(() => {
if (!open) return;
@@ -148,8 +110,8 @@ export function MobileMenu({
</svg>
</button>
<Link href="/" onClick={onClose} aria-label="海角社区" className="shrink-0">
<img src="/logo.svg" alt="海角社区" className="h-[62px] w-[137px]" />
<Link href="/" onClick={onClose} aria-label="黄果黑料" className="shrink-0">
<img src="/logo.svg" alt="黄果黑料" className="h-[62px] w-[137px]" />
</Link>
<nav className="flex w-full flex-col gap-[11px]" aria-label="主导航">
@@ -244,16 +206,6 @@ export function MobileMenu({
</Link>
);
})}
<Link
href="/profile"
onClick={onClose}
aria-current={pathname === "/profile" || pathname.startsWith("/profile/") ? "page" : undefined}
className={`flex h-11 shrink-0 items-center rounded-[9px] bg-white/10 px-[18px] text-base leading-[22px] font-medium ${
pathname === "/profile" || pathname.startsWith("/profile/") ? "text-[#F12B9E]" : "text-white/90"
}`}
>
我的
</Link>
</nav>
{bottomNavLinks.length ? (
-2
View File
@@ -75,8 +75,6 @@ export function resolveAboutPlatformHref(item: { name?: string; urlValue?: strin
const fromNav = resolveNavHref(item.urlValue);
if (fromNav && fromNav !== "#") return fromNav;
if (name === "帮助中心") return "/help";
if (name === "问题反馈" || name === "意见反馈") return "/feedback";
if (name === "关于我们") return "/about";
if (name) return toRichTextPath(name);
return "#";
@@ -10,17 +10,9 @@ import {
} from "@/components/layout/nav-utils";
export const mobileChromelessPaths = [
"/profile",
"/history",
"/favorites",
"/likes",
"/settings",
"/settings/profile",
"/about",
"/terms",
"/privacy",
"/help",
"/feedback",
"/richtext",
];
+31 -77
View File
@@ -1,28 +1,16 @@
import Link from "next/link";
import {
BOTTOM_NAV_FALLBACK_NAMES,
footerAboutNavItems,
footerCustomerServiceNavItems,
footerNavItems,
} from "@/components/layout/footer-nav-items";
import { BOTTOM_NAV_FALLBACK_NAMES, footerNavItems } from "@/components/layout/footer-nav-items";
import { infoLinkHref } from "@/components/layout/info-links";
import { resolveAboutPlatformHref } from "@/components/layout/nav-href";
import type { AppTranslationInfo, ConfigInfo, TranslationInfo } from "@/lib/api/types";
import { OFFICIAL_TG_URL, SITE_DOMAIN, SITE_NAME, SITE_URL } from "@/lib/seo/metadata";
import { FooterDownloadButtons } from "./footer-download-buttons";
const fallbackSocialLinks = [
{ label: "商务", icon: "/images/footer_sw.svg", href: "/#community" },
{ label: "Telegram", icon: "/images/footer_tg.svg", href: "/#community" },
{ label: "X", icon: "/images/footer_x.svg", href: "/#community" },
];
/** 官方社群:只保留 TG */
const socialLinks = [{ label: "Telegram", icon: "/images/footer_tg.svg", href: OFFICIAL_TG_URL }];
const aboutLinksFallback = BOTTOM_NAV_FALLBACK_NAMES;
const officialLinksFallback = [
{ label: "官网", href: "https://HaijiaoPortal.com", text: "官网:HaijiaoPortal.com" },
{ label: "最新下载地址", href: "https://HaijiaoPortal.com", text: "最新下载地址:HaijiaoPortal.com" },
{ label: "永久地址", href: "https://HaijiaoPortal.com", text: "永久地址:HaijiaoPortal.com" },
];
function hrefOf(value?: string | null) {
const text = value?.trim();
@@ -36,43 +24,23 @@ type FooterProps = {
config?: ConfigInfo | null;
};
function buildSocialLinks(config?: ConfigInfo | null) {
const business = config?.telegram?.trim();
const telegram = config?.tgGroup?.trim() || config?.telegramAdd?.trim();
const twitter = config?.twitterAddr?.trim();
if (!business && !telegram && !twitter) return fallbackSocialLinks;
return [
business ? { label: "商务", icon: "/images/footer_sw.svg", href: business } : null,
telegram ? { label: "Telegram", icon: "/images/footer_tg.svg", href: telegram } : null,
twitter ? { label: "X", icon: "/images/footer_x.svg", href: twitter } : null,
].filter(Boolean) as { label: string; icon: string; href: string }[];
}
/** 官网 / 永久地址固定本站域名;下载地址仍取接口 appAddr(APP 落地页) */
function buildOfficialLines(config?: ConfigInfo | null) {
const lines = [
config?.webAddr?.trim()
? { label: "官网", href: hrefOf(config.webAddr)!, text: `官网:${config.webAddr.trim()}` }
: null,
config?.appAddr?.trim()
? { label: "最新下载地址", href: hrefOf(config.appAddr)!, text: `最新下载地址:${config.appAddr.trim()}` }
: null,
config?.foreverUrlAddr?.trim()
? {
label: "永久地址",
href: hrefOf(config.foreverUrlAddr)!,
text: `永久地址:${config.foreverUrlAddr.trim()}`,
}
: null,
].filter(Boolean) as { label: string; href: string; text: string }[];
return lines.length ? lines : officialLinksFallback;
return [
{ label: "官网", href: SITE_URL, text: `官网:${SITE_DOMAIN}` },
{
label: "最新下载地址",
href: hrefOf(config?.appAddr) || SITE_URL,
text: `最新下载地址:${config?.appAddr?.trim() || SITE_DOMAIN}`,
},
{ label: "永久地址", href: SITE_URL, text: `永久地址:${SITE_DOMAIN}` },
];
}
function FooterNavAnchor({ item }: { item: TranslationInfo }) {
const href = resolveAboutPlatformHref(item);
const external = /^https?:\/\//i.test(href);
const className = "text-sm leading-5 text-[#989898] hover:text-white";
const navKey = item.urlValue || `footer_${item.name || ""}`;
const navName = item.name || "";
if (external) {
return (
<a
@@ -80,15 +48,13 @@ function FooterNavAnchor({ item }: { item: TranslationInfo }) {
target="_blank"
rel="noopener noreferrer"
className={className}
data-navigation-key={navKey}
data-navigation-name={navName}
>
{item.name}
</a>
);
}
return (
<Link href={href} className={className} data-navigation-key={navKey} data-navigation-name={navName}>
<Link href={href} className={className}>
{item.name}
</Link>
);
@@ -116,19 +82,15 @@ export function SiteFooter({ bottonTranslations, config }: FooterProps) {
);
}
export function MobileFooter({ bottonTranslations, config }: FooterProps = {}) {
export function MobileFooter({ bottonTranslations }: FooterProps = {}) {
// 仅渲染接口 bottonTranslations,不补自定义项
const bottomNavLinks = footerNavItems(bottonTranslations);
const socialLinks = buildSocialLinks(config);
const latest = config?.webAddr?.trim() || config?.newUrlAddr?.trim();
const forever = config?.foreverUrlAddr?.trim();
const portal = config?.translationAddr?.trim() || config?.webAddr?.trim();
return (
<footer data-component="mobile-footer" className="flex flex-col items-center gap-[18px] bg-white/10 px-4 py-[18px] text-center lg:hidden">
<img src="/logo.svg" alt="海角社区" className="h-8 w-auto" />
<img src="/logo.svg" alt="黄果黑料" className="h-8 w-auto" />
<p className="text-sm leading-5 text-white/55">
Copyright @ 2026 {config?.webSiteName?.trim() || config?.webAddr?.trim() || "haijiao.com"} All rights reserved
Copyright @ 2026 {SITE_DOMAIN} All rights reserved
</p>
{bottomNavLinks.length ? (
<nav
@@ -149,36 +111,36 @@ export function MobileFooter({ bottonTranslations, config }: FooterProps = {}) {
</p>
<div className="flex flex-col gap-1.5 text-xs leading-[18px] text-white/55">
<p>
海角社区最新地址:
黄果黑料最新地址:
<a
href={hrefOf(latest) || "https://Haijiaohaijiao.so"}
href={SITE_URL}
target="_blank"
rel="noopener noreferrer"
className="text-[#F12B9E] underline-offset-2 hover:underline"
>
{latest || "https://Haijiaohaijiao.so"}
{SITE_URL}
</a>
</p>
<p>
海角社区永久入口:
黄果黑料永久入口:
<a
href={hrefOf(forever) || "https://Haijiaohaijiao.so"}
href={SITE_URL}
target="_blank"
rel="noopener noreferrer"
className="underline-offset-2 hover:underline"
>
{forever || "https://Haijiaohaijiao.so"}
{SITE_URL}
</a>
</p>
<p>
海角社区官方永久地址:
黄果黑料官方永久地址:
<a
href={hrefOf(portal) || "https://HaijiaoPortal.com"}
href={SITE_URL}
target="_blank"
rel="noopener noreferrer"
className="underline-offset-2 hover:underline"
>
{portal || "HaijiaoPortal.com"}
{SITE_DOMAIN}
</a>
</p>
</div>
@@ -203,18 +165,16 @@ export function MobileFooter({ bottonTranslations, config }: FooterProps = {}) {
}
function DesktopFooter({ bottonTranslations, config }: FooterProps) {
const aboutLinks = footerAboutNavItems(bottonTranslations);
const customerServiceLinks = footerCustomerServiceNavItems(bottonTranslations);
const socialLinks = buildSocialLinks(config);
const aboutLinks = footerNavItems(bottonTranslations);
const officialLines = buildOfficialLines(config);
const siteName = config?.dhSiteName?.trim() || config?.webSiteName?.trim() || "海角社区";
const siteName = SITE_NAME;
return (
<footer data-component="desktop-footer" className="design-gutter hidden bg-[#0E1012] py-[74px] lg:block">
<div className="mx-auto flex w-full max-w-[1700px] flex-col items-center gap-6">
<div className="flex w-full items-start justify-between gap-10">
<div className="flex w-64 flex-col gap-5">
<img src="/logo.svg" alt="海角社区" className="h-[43px] w-[95px]" />
<img src="/logo.svg" alt="黄果黑料" className="h-[43px] w-[95px]" />
<p className="text-lg leading-6 font-medium text-white">{siteName} 官方社群</p>
<div className="flex gap-6">
{socialLinks.map((link) => (
@@ -242,12 +202,6 @@ function DesktopFooter({ bottonTranslations, config }: FooterProps) {
) : (
<FooterColumn title="关于平台" links={aboutLinksFallback} />
)}
<div className="flex min-w-24 flex-col gap-4">
<h2 className="text-lg leading-6 font-medium text-white">客户服务</h2>
{customerServiceLinks.map((link) => (
<FooterNavAnchor key={link.id || link.name} item={link} />
))}
</div>
<div className="flex flex-col gap-4">
<h2 className="text-lg leading-6 font-medium text-white">官方入口</h2>
{officialLines.map((line) => (
@@ -269,7 +223,7 @@ function DesktopFooter({ bottonTranslations, config }: FooterProps) {
</div>
</div>
<p className="text-center text-sm leading-5 text-white/55">
Copyright @ 2026 {config?.webAddr?.trim() || "haijiao.com"} All rights reserved
Copyright @ 2026 {SITE_DOMAIN} All rights reserved
</p>
</div>
</footer>
+3 -10
View File
@@ -2,11 +2,10 @@ import Form from "next/form";
import Link from "next/link";
import type { ReactNode } from "react";
import { HistoryIcon, SearchIcon } from "@/components/home/icons";
import { SearchIcon } from "@/components/home/icons";
import type { AppTranslationInfo, ConfigInfo, TranslationCategory } from "@/lib/api/types";
import { HeaderShell } from "./header-shell";
import { HeaderUserChip } from "./header-user-chip";
import { MobileHeader } from "./mobile-header";
import { SiteSearchInput } from "./site-search-input";
@@ -28,8 +27,8 @@ export function SiteHeader({
<HeaderShell>
<MobileHeader categoryList={categories} bottonTranslations={bottonTranslations} config={config} />
<div className="design-gutter hidden h-[72px] w-full items-center gap-4 lg:flex xl:gap-6">
<Link href="/" className="shrink-0" aria-label="海角社区">
<img src="/logo.svg" alt="海角社区" className="h-[43px] w-[95px]" />
<Link href="/" className="shrink-0" aria-label="黄果黑料">
<img src="/logo.svg" alt="黄果黑料" className="h-[43px] w-[95px]" />
</Link>
{desktopNav}
<Form
@@ -44,12 +43,6 @@ export function SiteHeader({
className="site-search-input h-full w-full bg-transparent text-sm text-white outline-none placeholder:text-[#7A728C]/55"
/>
</Form>
<Link href="/history" className="flex shrink-0 items-center gap-1 text-sm text-white/90">
<HistoryIcon className="size-6" />
历史
</Link>
<span className="h-3 w-px bg-white/30" />
<HeaderUserChip />
</div>
</HeaderShell>
);
@@ -0,0 +1,133 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import {
getEpisodeChunks,
getEpisodeState,
playHref,
totalEpisodeOf,
updateStatusLabel,
type DramaDetail,
} from "@/components/drama-detail/data";
import { SectionTitle } from "./parts";
const CHUNK = 30;
type Variant = "panel" | "side" | "h5";
function LockMark() {
return (
<svg viewBox="0 0 12 12" fill="none" className="absolute top-1 right-1 size-2.5 opacity-70" aria-hidden="true">
<rect x="2" y="5" width="8" height="6" rx="1" stroke="currentColor" strokeWidth="1.2" />
<path d="M4 5V3.6a2 2 0 0 1 4 0V5" stroke="currentColor" strokeWidth="1.2" />
</svg>
);
}
const gridClass: Record<Variant, string> = {
panel: "grid grid-cols-[repeat(15,minmax(0,1fr))] gap-2.5",
side: "grid grid-cols-6 gap-2",
h5: "grid grid-cols-6 gap-2",
};
const cellClass: Record<Variant, string> = {
panel: "h-12 text-base",
side: "h-12 text-base",
h5: "h-10 text-sm",
};
/**
* 分集目录:每一集都是可抓取的 <a> 链接;
* 分段只切换显示(hidden),全部集数都在 HTML 里,便于搜索引擎发现分集 URL。
*/
export function EpisodeDirectory({
detail,
current,
variant,
title = "分集目录",
}: {
detail: DramaDetail;
/** 当前播放集;详情页不传 */
current?: number;
variant: Variant;
title?: string;
}) {
const total = totalEpisodeOf(detail);
const chunks = getEpisodeChunks(total, CHUNK);
const [active, setActive] = useState(() => (current ? Math.max(0, Math.floor((current - 1) / CHUNK)) : 0));
const compact = variant === "h5";
return (
<section
data-component="hl-episode-directory"
aria-label={`${detail.title || ""}${title}`}
className={`flex min-w-0 flex-col ${
variant === "side"
? "h-[648px] w-[528px] shrink-0 gap-4 rounded-lg border border-white/8 bg-[#121118] p-5"
: compact
? "gap-3"
: "gap-5"
}`}
>
<SectionTitle compact={compact || variant === "side"}>{title}</SectionTitle>
<p className={`text-[#8E8C99] ${compact ? "-mt-1 text-xs leading-[18px]" : "text-sm leading-5"}`}>
{updateStatusLabel(detail.updateStatus)} · 共 {total} 集
{current ? ` · 正在看第 ${current} 集` : ""}
</p>
{chunks.length > 1 ? (
<div role="tablist" className="flex shrink-0 gap-2 overflow-x-auto [scrollbar-width:none]">
{chunks.map((chunk, index) => (
<button
key={chunk.label}
type="button"
role="tab"
aria-selected={index === active}
onClick={() => setActive(index)}
className={`shrink-0 cursor-pointer rounded px-3 py-1 tabular-nums ${compact ? "text-xs" : "text-sm"} ${
index === active ? "bg-[#FFB020] text-[#1A1206]" : "bg-white/5 text-[#B5B3BE] hover:text-white"
}`}
>
{chunk.start}–{chunk.end}
</button>
))}
</div>
) : null}
<div className={variant === "side" ? "min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-width:thin]" : ""}>
{chunks.map((chunk, index) => (
<ol key={chunk.label} className={`${gridClass[variant]} ${index === active ? "" : "hidden"}`}>
{Array.from({ length: chunk.end - chunk.start + 1 }, (_, i) => chunk.start + i).map((episode) => {
const playing = episode === current;
const locked = getEpisodeState(episode, detail) === "locked";
return (
<li key={episode}>
<Link
href={playHref(detail.id, episode, detail.slug)}
scroll={variant !== "side"}
aria-current={playing ? "page" : undefined}
title={`${detail.title || ""} 第${episode}集`}
className={`relative flex items-center justify-center rounded tabular-nums transition-colors ${cellClass[variant]} ${
playing
? "bg-[#FFB020] font-semibold text-[#1A1206]"
: locked
? "bg-white/[0.03] text-[#5F5D69] hover:text-[#8E8C99]"
: "bg-white/[0.07] text-[#E4E2EA] hover:bg-[#FFB020]/15 hover:text-[#FFB020]"
}`}
>
{episode}
<span className="sr-only">集{locked ? "(APP 观看)" : ""}</span>
{locked && !playing ? <LockMark /> : null}
</Link>
</li>
);
})}
</ol>
))}
</div>
</section>
);
}
+247
View File
@@ -0,0 +1,247 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { cardTagCanLink, pickPlayletCardTags, type CardTagItem } from "@/components/common/card-tags";
import type { RelatedDrama } from "@/components/drama-detail/data";
import { resolveContentBadge } from "@/components/home/section-heading";
import { formatHeat } from "@/components/ranking/data";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { H5ShortDramaCard, ShortDramaCard } from "@/components/short-drama/short-drama-card";
import { tagPageHref, type TagContentKind } from "@/components/tag/href";
import { H5VideoCard, VideoCard } from "@/components/video/video-card";
/**
* 短剧详情 / 短剧播放 / 视频页共用的版块:
* 资料表(dl)、操作按钮、推荐网格(替代原评论区)、分区标题。
*/
/** 分区标题:左侧琥珀竖线 + h2,可带「查看更多」 */
export function SectionTitle({
children,
moreHref,
moreLabel = "查看更多",
compact,
}: {
children: ReactNode;
moreHref?: string;
moreLabel?: string;
compact?: boolean;
}) {
return (
<div className="flex items-center justify-between gap-3">
<h2
className={`flex items-center gap-2 font-semibold text-[#F4F3F7] before:block before:w-1 before:rounded-full before:bg-[#FFB020] ${
compact ? "text-base leading-[22px] before:h-4" : "text-2xl leading-8 before:h-6"
}`}
>
{children}
</h2>
{moreHref ? (
<Link
href={moreHref}
className={`shrink-0 text-[#8E8C99] hover:text-[#FFB020] ${compact ? "text-xs leading-[18px]" : "text-sm leading-5"}`}
>
{moreLabel} ›
</Link>
) : null}
</div>
);
}
export type InfoRow = { label: string; value: ReactNode };
/** 资料表:语义化 dl,便于搜索引擎理解字段 */
export function InfoList({ rows, compact }: { rows: InfoRow[]; compact?: boolean }) {
const list = rows.filter((row) => row.value !== null && row.value !== undefined && row.value !== "");
if (!list.length) return null;
return (
<dl
className={`grid gap-x-6 ${
compact ? "grid-cols-2 gap-y-1.5 text-xs leading-[18px]" : "grid-cols-[repeat(3,minmax(0,1fr))] gap-y-3 text-sm leading-5"
}`}
>
{list.map((row) => (
<div key={row.label} className="flex min-w-0 gap-2">
<dt className="shrink-0 text-[#6F6C7B]">{row.label}</dt>
<dd className="min-w-0 truncate text-[#D8D6DF]">{row.value}</dd>
</div>
))}
</dl>
);
}
export type TagLinkItem = { key: string; name: string; href?: string };
/** 卡片标签 → 标签链接(有 slug / id 才可跳转) */
export function tagLinkItems(tags: CardTagItem[], kind: TagContentKind): TagLinkItem[] {
return tags
.filter((tag) => tag.name?.trim())
.map((tag) => ({
key: tag.slug ?? String(tag.id ?? tag.name),
name: tag.name.trim(),
href: cardTagCanLink(tag) ? tagPageHref(tag, kind) : undefined,
}));
}
/** 标签链接行:#标签 */
export function TagLinks({
tags,
compact,
}: {
tags: TagLinkItem[];
compact?: boolean;
}) {
if (!tags.length) return null;
const cls = `rounded border border-[#FFB020]/25 text-[#D9C59A] ${compact ? "px-1.5 py-0.5 text-[11px] leading-4" : "px-2.5 py-1 text-xs leading-[18px]"}`;
return (
<ul className="flex flex-wrap gap-2">
{tags.map((tag) => (
<li key={tag.key}>
{tag.href ? (
<Link href={tag.href} className={`${cls} block transition-colors hover:border-[#FFB020] hover:text-[#FFB020]`}>
#{tag.name}
</Link>
) : (
<span className={`${cls} block`}>#{tag.name}</span>
)}
</li>
))}
</ul>
);
}
const actionIcons = {
like: (
<path
d="M8 13.6c-.2 0-.4-.07-.55-.2C5.3 11.6 2.2 9.4 2.2 6.5 2.2 4.6 3.6 3 5.3 3c1.1 0 2 .6 2.7 1.5C8.7 3.6 9.6 3 10.7 3c1.7 0 3.1 1.6 3.1 3.5 0 2.9-3.1 5.1-5.25 6.9-.15.13-.35.2-.55.2Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
),
collect: (
<path
d="m8 2.2 1.75 3.6 3.95.55-2.85 2.75.68 3.9L8 11.15 4.47 13l.68-3.9L2.3 6.35l3.95-.55L8 2.2Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
),
share: (
<path
d="M10.5 5.5 5.5 8l5 2.5M12 4.25a1.75 1.75 0 1 1-3.5 0 1.75 1.75 0 0 1 3.5 0Zm0 7.5a1.75 1.75 0 1 1-3.5 0 1.75 1.75 0 0 1 3.5 0ZM5.5 8a1.75 1.75 0 1 1-3.5 0 1.75 1.75 0 0 1 3.5 0Z"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
),
} as const;
/** 次要操作按钮:点赞 / 收藏 / 分享(描边方形,不用胶囊) */
export function HlAction({
kind,
label,
active = false,
onClick,
compact,
}: {
kind: keyof typeof actionIcons;
label: string;
active?: boolean;
onClick?: () => void;
compact?: boolean;
}) {
return (
<button
type="button"
aria-pressed={kind === "share" ? undefined : active}
onClick={onClick}
className={`inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-md border transition-colors ${
active ? "border-[#FFB020] bg-[#FFB020]/10 text-[#FFB020]" : "border-white/12 text-[#D8D6DF] hover:border-white/30"
} ${compact ? "h-9 px-3 text-xs" : "h-11 px-5 text-sm"}`}
>
<svg viewBox="0 0 16 16" fill={active && kind !== "share" ? "currentColor" : "none"} className={compact ? "size-4" : "size-[18px]"} aria-hidden="true">
{actionIcons[kind]}
</svg>
{label}
</button>
);
}
/** 主操作:琥珀实底按钮(链接) */
export function HlPrimaryLink({
href,
children,
compact,
className = "",
}: {
href: string;
children: ReactNode;
compact?: boolean;
className?: string;
}) {
return (
<Link
href={href}
className={`inline-flex items-center justify-center gap-2 rounded-md bg-[#FFB020] font-semibold text-[#1A1206] transition-colors hover:bg-[#FFC14D] ${
compact ? "h-10 px-4 text-sm" : "h-11 px-7 text-base"
} ${className}`}
>
<svg viewBox="0 0 16 16" className="size-3.5" aria-hidden="true">
<path d="M4.5 2.8v10.4L13 8 4.5 2.8Z" fill="currentColor" />
</svg>
{children}
</Link>
);
}
/** 推荐短剧:接口 PlayletData → 卡片数据 */
export function relatedToItem(item: RelatedDrama): ShortDramaItem {
return {
kind: "drama",
id: String(item.id),
slug: item.slug?.trim() || undefined,
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(),
};
}
/** 同类短剧推荐(替代原评论区):PC 6 列 / H5 2 列 */
export function DramaRecommend({ items, compact }: { items: RelatedDrama[]; compact?: boolean }) {
const list = items.filter((item) => item.id).map(relatedToItem);
if (!list.length) return null;
return (
<section aria-label="同类短剧推荐" className={`flex flex-col ${compact ? "gap-3" : "gap-6"}`}>
<SectionTitle moreHref="/short-drama/hot" moreLabel="短剧库" compact={compact}>
同类短剧推荐
</SectionTitle>
<div className={compact ? "grid grid-cols-3 gap-x-2.5 gap-y-4" : "grid grid-cols-[repeat(6,266px)] justify-between gap-y-8"}>
{list.map((item) =>
compact ? <H5ShortDramaCard key={item.id} item={item} /> : <ShortDramaCard key={item.id} item={item} />,
)}
</div>
</section>
);
}
/** 相关视频推荐(替代原评论区):PC 5 列 / H5 2 列 */
export function VideoRecommend({ items, compact }: { items: ShortDramaItem[]; compact?: boolean }) {
if (!items.length) return null;
return (
<section aria-label="相关视频推荐" className={`flex flex-col ${compact ? "gap-3" : "gap-6"}`}>
<SectionTitle moreHref="/video/hot" moreLabel="视频库" compact={compact}>
相关视频推荐
</SectionTitle>
<div className={compact ? "grid grid-cols-2 gap-x-2.5 gap-y-4" : "grid grid-cols-[repeat(5,324px)] justify-between gap-y-8"}>
{items.map((item) =>
compact ? <H5VideoCard key={item.id} item={item} /> : <VideoCard key={item.id} item={item} />,
)}
</div>
</section>
);
}
+5 -47
View File
@@ -1,20 +1,16 @@
import { AdBannersSlot, AdIconsSlot, FriendLinksSlot, H5FriendLinksSlot } from "@/components/common/ads/slots";
import Link from "next/link";
import type { ReactNode } from "react";
import { ChannelHeader, H5ChannelHeader } from "@/components/common/channel-header";
import { Pagination } from "@/components/common/pagination";
import { buildPageHrefMap } from "@/components/common/pagination-map";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { FloatingActions } from "@/components/home/floating-actions";
import { topicCountLabel, topicCoverSrc } from "@/components/home/home-module-style";
import { DownloadBanner } from "@/components/home/page-sections";
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 { topicDetailHref } from "@/components/topic/href";
import { TopicListCard } from "@/components/topic/topic-list-card";
import { H5VideoCard, VideoCard } from "@/components/video/video-card";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import type { MorePageData } from "./data";
import { moreListHref } from "./route";
@@ -47,48 +43,10 @@ function TopicGrid({
}) {
if (!topics.length) return null;
return (
<div className={compact ? "grid grid-cols-2 gap-[7px]" : "grid grid-cols-2 gap-5 lg:grid-cols-4"}>
{topics.map((topic) => {
const cover = topicCoverSrc(topic);
const countText = topicCountLabel(topic, dataType, "部");
return (
<Link
key={topic.id}
href={topicDetailHref(topic, dataType)}
className={`relative overflow-hidden rounded-lg bg-[#110F1F] ${compact ? "aspect-[16/10]" : "h-[231px]"}`}
>
<DecryptedImage
src={cover}
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_38.85%_58.45%_at_66.18%_60.1%,rgba(255,255,255,0)_0%,rgba(0,0,0,0.55)_100%)]" />
<div className={compact ? "absolute bottom-3 left-3 right-3" : "absolute bottom-6 left-8 right-8"}>
<h3
className={
compact
? "truncate text-base font-medium text-[#F2F2FA]"
: "truncate text-2xl leading-8 font-medium text-[#F2F2FA]"
}
>
{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 className={compact ? "grid grid-cols-2 gap-x-2.5 gap-y-4" : "grid grid-cols-2 gap-x-5 gap-y-7 lg:grid-cols-4"}>
{topics.map((topic) => (
<TopicListCard key={topic.id} topic={topic} dataType={dataType} compact={compact} />
))}
</div>
);
}
+15 -38
View File
@@ -5,10 +5,9 @@ import { DownloadTrigger } from "@/components/download/download-trigger";
import { MobileFooter } from "@/components/layout/site-footer";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { AppTranslationInfo, ConfigInfo } from "@/lib/api/types";
import { OFFICIAL_TG_URL, SITE_NAME, SITE_URL } from "@/lib/seo/metadata";
import { NavEmailCard } from "./nav-email-card";
const FALLBACK_LINE = "https://d81ub37cgqxo3.cloudfront.net";
import { NavTgCard } from "./nav-tg-card";
const guides = [
{ title: "使用方式", text: "优先使用线路一入口访问,如遇异常可切换线路2~3继续访问浏览。" },
@@ -42,41 +41,19 @@ function hrefOf(value?: string | null) {
return `https://${text}`;
}
function buildLines(config?: ConfigInfo | null) {
const site = config?.dhSiteName?.trim() || "海角社区";
const candidates = [
{ title: `${site} 精选全站导航中心-线路一`, url: config?.dhNewUrlAddr1 },
{ title: `${site} 精选全站导航中心-线路二`, url: config?.dhNewUrlAddr2 },
{ title: `${site} 精选全站导航中心-线路三`, url: config?.dhNewUrlAddr3 },
{ title: "永久域名(VPN访问)", url: config?.foreverUrlAddr },
]
.map((line) => ({ title: line.title, url: line.url?.trim() || "" }))
.filter((line) => line.url);
if (candidates.length) return candidates;
/** 线路固定本站域名,不取接口 dhNewUrlAddr* / foreverUrlAddr */
function buildLines() {
return [
{ title: "海角社区 精选全站导航中心-线路一", url: FALLBACK_LINE },
{ title: "海角社区 精选全站导航中心-线路二", url: FALLBACK_LINE },
{ title: "海角社区 精选全站导航中心-线路三", url: FALLBACK_LINE },
{ title: "永久域名(VPN访问)", url: FALLBACK_LINE },
{ title: `${SITE_NAME} 精选全站导航中心-线路一`, url: SITE_URL },
{ title: `${SITE_NAME} 精选全站导航中心-线路二`, url: SITE_URL },
{ title: `${SITE_NAME} 精选全站导航中心-线路三`, url: SITE_URL },
{ title: "永久域名(VPN访问)", url: SITE_URL },
];
}
function buildShortcuts(config?: ConfigInfo | null): Shortcut[] {
return [
{
label: "官方推特",
icon: "/images/nav_x.svg",
href: hrefOf(config?.twitterAddr) || "https://x.com/qimengshorts",
external: true,
},
{
label: "官方TG群",
icon: "/images/nav_tg.svg",
href: hrefOf(config?.tgGroup || config?.telegramAdd || config?.telegram) || "https://t.me/qimengshorts",
external: true,
},
{ label: "官方TG", icon: "/images/nav_tg.svg", href: OFFICIAL_TG_URL, external: true },
config?.appAddr?.trim()
? { label: "APP", icon: "/logo2.png", href: hrefOf(config.appAddr), external: true }
: { label: "APP", icon: "/logo2.png" },
@@ -85,7 +62,7 @@ function buildShortcuts(config?: ConfigInfo | null): Shortcut[] {
}
function AppBrand({ siteName }: { siteName?: string | null }) {
const name = siteName?.trim() || "海角社区";
const name = siteName?.trim() || "黄果黑料";
return (
<div className="flex items-center gap-3">
<img src="/logo2.png" alt="" className="size-10 rounded-[7px]" />
@@ -163,10 +140,10 @@ export function NavPage({
config?: ConfigInfo | null;
bottonTranslations?: AppTranslationInfo | null;
}) {
const lines = buildLines(config);
const lines = buildLines();
const shortcuts = buildShortcuts(config);
const siteName = config?.dhSiteName;
const webAddr = config?.webAddr?.trim() || config?.foreverUrlAddr?.trim() || "https://haijiaosq.net";
const siteName = SITE_NAME;
const webAddr = SITE_URL;
return (
<main data-component="nav-page" className="relative flex min-h-screen flex-1 flex-col overflow-hidden bg-black/70 text-white lg:bg-black/90">
@@ -247,11 +224,11 @@ export function NavPage({
alt=""
className="pointer-events-none absolute -top-[47px] -right-6 z-10 size-[107px] lg:right-3"
/>
<NavEmailCard gmailAddr={config?.gmailAddr} />
<NavTgCard />
<footer className="hidden flex-col items-center gap-6 py-6 text-center text-sm leading-5 font-medium lg:flex">
<AppBrand siteName={siteName} />
<p className="text-[#DCDCDC]">
2026 {siteName?.trim() || "海角社区"}精选全站导航中心 {webAddr} Inc. All Rights Reserved
2026 {siteName}精选全站导航中心 {webAddr} Inc. All Rights Reserved
</p>
<p className="text-[#F12B9E]">本站只为海外华人提供服务 非法律允许地区以及18岁以下用户禁止访问!</p>
</footer>
+15 -9
View File
@@ -3,11 +3,10 @@
import { useEffect, useState } from "react";
import { DownloadTrigger } from "@/components/download/download-trigger";
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL } from "@/lib/seo/metadata";
const FALLBACK_EMAIL = "xxxxxxx@proton.me";
export function NavEmailCard({ gmailAddr }: { gmailAddr?: string | null }) {
const email = gmailAddr?.trim() || FALLBACK_EMAIL;
/** 官方 TG 联系卡片(只保留 TG 联系方式,与黄果其它站点一致) */
export function NavTgCard() {
const [copied, setCopied] = useState(false);
useEffect(() => {
@@ -18,7 +17,7 @@ export function NavEmailCard({ gmailAddr }: { gmailAddr?: string | null }) {
const copy = async () => {
try {
await navigator.clipboard.writeText(email);
await navigator.clipboard.writeText(OFFICIAL_TG_HANDLE);
setCopied(true);
} catch {
setCopied(false);
@@ -28,13 +27,20 @@ export function NavEmailCard({ gmailAddr }: { gmailAddr?: string | null }) {
return (
<section className="flex flex-col gap-3 rounded-[20px] bg-[linear-gradient(140deg,#7B34FD_0%,#D2AFFD_31%,#D2AFFD_60%,#7B34FD_100%)] px-3 py-5 opacity-90">
<h2 className="text-xl leading-7 font-semibold text-black lg:text-center lg:font-medium lg:text-black/90">
发送<span className="text-[#1C3EFF] lg:text-[#1B3AE7]">任意消息</span>到邮箱
联系<span className="text-[#1C3EFF] lg:text-[#1B3AE7]">官方TG</span>
<br className="lg:hidden" />
自动获取<span className="text-[#1C3EFF] lg:text-[#1A36D1]">最新【免翻墙】地址</span>
获取<span className="text-[#1C3EFF] lg:text-[#1A36D1]">最新【免翻墙】地址</span>
</h2>
<div className="flex flex-col gap-4">
<div className="flex h-11 items-center justify-between gap-3 rounded-xl bg-[#FAFAFA] px-3">
<span className="truncate text-base leading-[22px] font-medium text-black">{email}</span>
<a
href={OFFICIAL_TG_URL}
target="_blank"
rel="noopener noreferrer"
className="truncate text-base leading-[22px] font-medium text-black hover:underline"
>
{OFFICIAL_TG_HANDLE}
</a>
<button
type="button"
onClick={copy}
@@ -44,7 +50,7 @@ export function NavEmailCard({ gmailAddr }: { gmailAddr?: string | null }) {
</button>
</div>
<DownloadTrigger className="flex h-10 items-center justify-center gap-2 rounded-xl bg-[linear-gradient(135deg,#F12B9E_0%,#E952E2_52%,#F12B9E_100%)] text-sm leading-5 font-medium text-white shadow-[inset_0_2px_2px_rgba(255,255,255,0.4)]">
下载海角社区精选全站导航中心APP
下载黄果黑料精选全站导航中心APP
<svg viewBox="0 0 24 24" className="size-6" fill="none" aria-hidden="true">
<path d="m9 6 6 6-6 6" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
+1 -13
View File
@@ -5,7 +5,6 @@ import type Player from "xgplayer";
import type { IPlayerOptions } from "xgplayer";
import "xgplayer/dist/index.min.css";
import { bindPlayerAnalytics } from "@/components/analytics/use-playback-analytics";
import { useDecryptedSrc } from "@/hooks/use-decrypted-src";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { isHLSURL, nativeHLS, SHELL_BROWSER } from "@/lib/media/playback";
@@ -63,8 +62,6 @@ type XgPlayerProps = {
onPlaySuccess?: () => void;
/** 当前集播放结束 */
onEnded?: () => void;
/** 播放埋点类型;不传则不上报播放行为 */
analyticsKind?: "drama" | "video";
/**
* 进入页不自动播,需点播放按钮。
* 默认 true;短剧连播下一集时可显式传 false。
@@ -81,7 +78,6 @@ export function XgPlayer({
onReady,
onPlaySuccess,
onEnded,
analyticsKind,
clickToPlay = true,
}: XgPlayerProps) {
const hostRef = useRef<HTMLDivElement>(null);
@@ -105,7 +101,6 @@ export function XgPlayer({
let player: Player | null = null;
let cancelled = false;
let playSucceeded = false;
let unbindAnalytics: (() => void) | undefined;
/** 用户点过播放后,不再拦截正常起播 */
let userStarted = !clickToPlay;
@@ -230,12 +225,6 @@ export function XgPlayer({
onEndedRef.current?.();
});
window.addEventListener("resize", syncZoom);
if (analyticsKind) {
unbindAnalytics = bindPlayerAnalytics(
player as { media?: HTMLVideoElement | null },
analyticsKind,
);
}
if (playerRef) playerRef.current = player;
onReadyRef.current?.(player);
if (clickToPlay) {
@@ -255,12 +244,11 @@ export function XgPlayer({
return () => {
cancelled = true;
unbindAnalytics?.();
window.removeEventListener("resize", syncZoom);
if (playerRef) playerRef.current = null;
player?.destroy();
};
}, [src, posterSrc, playerRef, analyticsKind, clickToPlay]);
}, [src, posterSrc, playerRef, clickToPlay]);
return <div data-component="xg-player" ref={hostRef} className={`xg-theme isolate ${className}`} />;
}
+1 -1
View File
@@ -19,7 +19,7 @@ const clauses: Clause[] = [
},
{
title: "五、您的权利",
text: "您可以依法访问、更正、删除个人信息,管理授权或注销账号。如需帮助,可通过 privacy@qimengshorts.com 联系我们。",
text: "您可以依法访问、更正、删除个人信息,管理授权或注销账号。如需帮助,可通过官方TG @jbd125 联系我们。",
},
];
-15
View File
@@ -29,18 +29,3 @@ export const loadRichText = cache(async (title: string): Promise<RichTextInfo |
return null;
}
});
/**
* 按候选 title 依次查询富文本,返回首个有内容的结果。
* 帮助中心等入口:上游未必有「帮助中心」标题,可回退「常见问题」等。
*/
export async function loadRichTextFirst(titles: string[]): Promise<RichTextInfo | null> {
for (const title of titles) {
const data = await loadRichText(title);
if (data?.content?.trim()) return data;
}
return null;
}
/** 帮助中心富文本 title 候选(与 swagger GET /api/app/richtext?title= 一致) */
export const HELP_RICHTEXT_TITLES = ["帮助中心", "常见问题", "帮助-鉴别指南"] as const;
-9
View File
@@ -14,7 +14,6 @@ import { H5VideoCard, VideoCard } from "@/components/video/video-card";
import { searchSummary, searchTypes, type SearchListResult } from "./data";
import { searchListHref } from "./route";
import { SearchTrack } from "./search-track";
function SearchIcon({ className = "" }: { className?: string }) {
return (
@@ -32,14 +31,6 @@ export function SearchPage({ list }: { list: SearchListResult }) {
return (
<main data-component="search-page" className="flex-1 bg-[#07060E] lg:bg-[linear-gradient(90deg,#0E0813_0%,#07060E_100%)]">
{query ? (
<SearchTrack
query={query}
resultCount={totalCount}
page={page}
contentType={isVideo ? "video" : "playlet"}
/>
) : null}
<div
id="search-top"
data-pc-scale
-32
View File
@@ -1,32 +0,0 @@
"use client";
import { useEffect } from "react";
import { tracker } from "@/lib/tracker";
/** 搜索结果页:上报 keywordSearch(仅首页) */
export function SearchTrack({
query,
resultCount,
page,
contentType,
}: {
query: string;
resultCount: number;
page: number;
contentType?: string;
}) {
useEffect(() => {
const keyword = query.trim();
if (!keyword || page > 1) return;
void tracker.keywordSearch({
keyword,
search_result_count: Math.max(0, resultCount),
search_content_type: contentType || "",
search_trace_id: "",
search_id: "",
});
}, [query, resultCount, page, contentType]);
return null;
}
+2
View File
@@ -37,6 +37,8 @@ export type ShortDramaItem =
episodes: string;
badge?: BadgeTone;
cover: string;
/** 发布时间(接口原值,视频卡展示日期用) */
date?: string;
/** 覆盖默认详情/播放链接(如历史继续看到某集) */
href?: string;
}
+76 -114
View File
@@ -2,79 +2,75 @@ import Link from "next/link";
import { CardTags } from "@/components/common/card-tags";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
MEDIA_CARD_COVER,
MEDIA_CARD_DESC,
MEDIA_CARD_DESC_SM,
MEDIA_CARD_EPISODES,
MEDIA_CARD_EPISODES_H5,
MEDIA_CARD_FOOTER_H5,
MEDIA_CARD_INFO_H5,
MEDIA_CARD_META,
MEDIA_CARD_ROOT,
MEDIA_CARD_TITLE_H5,
MEDIA_CARD_TITLE_PC,
} from "@/components/common/media-card-hover";
import { CoverRankBadge, RankNumber } from "@/components/common/rank-number";
import { Badge } from "@/components/home/section-heading";
import { EyeIcon, HL_COVER, HL_COVER_IMG, HL_TITLE_LINK, HlBadge } from "@/components/common/media-meta";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { dramaHref, type ShortDramaItem } from "./data";
export function ShortDramaCard({ item, rank }: { item: ShortDramaItem; rank?: number }) {
if (item.kind === "ad") {
return (
<a
href={item.href || "#download"}
target={item.href ? "_blank" : undefined}
rel={item.href ? "sponsored nofollow noopener noreferrer" : undefined}
className={`block w-[266px] ${MEDIA_CARD_ROOT}`}
>
<div className="relative h-[355px] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
<div className="absolute inset-0 bg-black/40" />
<img src="/logo.svg" alt="海角社区" className="absolute top-[21px] left-[21px] h-[19px] w-[84px]" />
</div>
<div className="flex min-h-[96px] flex-col justify-center gap-1 px-2.5 py-3">
<h3 className={MEDIA_CARD_TITLE_PC}>{item.title}</h3>
<p className={`${MEDIA_CARD_DESC} text-white/60`}>{item.subtitle}</p>
</div>
</a>
);
}
/** 排行序号:No.01(琥珀色,放在标题前) */
function RankLabel({ rank, className = "" }: { rank?: number; className?: string }) {
if (rank === undefined) return null;
return (
<span className={`mr-1.5 font-bold text-[#FFB020] tabular-nums ${className}`}>No.{String(rank).padStart(2, "0")}</span>
);
}
const detailHref = item.href || dramaHref(item.id, item.slug);
/** 短剧卡元信息:集数 + 热度(文字行,不叠在封面上) */
function DramaMeta({ item, className }: { item: Extract<ShortDramaItem, { kind: "drama" }>; className: string }) {
return (
<p className={`flex min-w-0 items-center gap-2 text-[#8E8C99] ${className}`}>
{item.episodes ? <span className="shrink-0 text-[#D9C59A]">共{item.episodes}</span> : null}
{item.episodes && item.heat ? <span aria-hidden="true" className="h-2.5 w-px bg-white/15" /> : null}
{item.heat ? (
<span className="inline-flex min-w-0 items-center gap-1 truncate">
<EyeIcon className="size-3.5 shrink-0" />
{item.heat}
</span>
) : null}
</p>
);
}
function AdDramaCard({ item, compact }: { item: ShortDramaItem; compact?: boolean }) {
return (
<a
href={item.href || (compact ? "#h5-download" : "#download")}
target={item.href ? "_blank" : undefined}
rel={item.href ? "sponsored nofollow noopener noreferrer" : undefined}
className={`group flex flex-col ${compact ? "gap-1.5" : "w-[266px] gap-2.5"}`}
>
<span className={`${HL_COVER} aspect-[2/3]`}>
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt={item.title} className={HL_COVER_IMG} />
<span className="absolute top-0 left-0 rounded-br-md bg-white/85 px-1.5 py-0.5 text-[10px] leading-[14px] text-black">
推广
</span>
</span>
<span className={`truncate text-white ${compact ? "text-sm leading-5" : "media-card-title-md"}`}>{item.title}</span>
</a>
);
}
export function ShortDramaCard({ item, rank }: { item: ShortDramaItem; rank?: number }) {
if (item.kind === "ad") return <AdDramaCard item={item} />;
const href = item.href || dramaHref(item.id, item.slug);
return (
<article data-component="short-drama-card" className={`w-[266px] ${MEDIA_CARD_ROOT}`}>
<Link href={detailHref} className="block">
<div className="relative h-[355px] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
{item.badge ? (
<div className="absolute top-2 right-2">
<Badge tone={item.badge} />
</div>
) : null}
{item.episodes ? (
<span
className={`absolute right-2 bottom-2 rounded-md bg-black/60 px-2.5 py-1.5 font-medium text-white ${MEDIA_CARD_EPISODES}`}
>
{item.episodes}
</span>
) : null}
</div>
<div className="flex min-w-0 flex-col gap-1 px-2.5 pt-3">
<h3 className={`flex min-h-7 items-center gap-1 ${MEDIA_CARD_TITLE_PC}`}>
<RankNumber rank={rank} />
<span className="min-w-0 truncate">{item.title}</span>
</h3>
<p className={`${MEDIA_CARD_DESC} text-white/60`}>{item.subtitle}</p>
</div>
<article data-component="hl-drama-card" className="group flex w-[266px] flex-col gap-2">
<Link href={href} tabIndex={-1} aria-hidden="true" className={`${HL_COVER} aspect-[2/3]`}>
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt={item.title} className={HL_COVER_IMG} />
<HlBadge tone={item.badge} />
</Link>
<div className={`flex min-w-0 items-center justify-between gap-2 px-2.5 pt-1 pb-3 ${MEDIA_CARD_META}`}>
<CardTags tags={item.tags} kind="playlet" className="min-w-0 text-white/60" />
<span className="shrink-0 text-[#F92882]">{item.heat}</span>
</div>
<h3 className="media-card-title-md truncate font-medium text-[#F4F3F7]">
<RankLabel rank={rank} />
<Link href={href} title={item.title} className={HL_TITLE_LINK}>
{item.title}
</Link>
</h3>
<DramaMeta item={item} className="media-card-desc" />
{item.subtitle ? (
<p className="media-card-desc line-clamp-2 min-h-[2.4em] text-[#7D7A88]">{item.subtitle}</p>
) : null}
<CardTags tags={item.tags} kind="playlet" className="media-card-meta min-w-0 text-[#B49A6A]" />
</article>
);
}
@@ -82,63 +78,29 @@ export function ShortDramaCard({ item, rank }: { item: ShortDramaItem; rank?: nu
export function H5ShortDramaCard({
item,
rank,
showEpisodes = true,
}: {
item: ShortDramaItem;
rank?: number;
/** @deprecated 新卡片不在封面显示集数,保留参数兼容调用方 */
showEpisodes?: boolean;
}) {
if (item.kind === "ad") {
return (
<a
href={item.href || "#h5-download"}
target={item.href ? "_blank" : undefined}
rel={item.href ? "sponsored nofollow noopener noreferrer" : undefined}
className={`block ${MEDIA_CARD_ROOT}`}
>
<div className="relative aspect-[3/4] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
<div className="absolute inset-0 bg-black/40" />
<img src="/logo.svg" alt="海角社区" className="absolute top-[17px] left-[13px] h-4 w-[71px]" />
</div>
<div className={`${MEDIA_CARD_INFO_H5} pb-1.5`}>
<h3 className={MEDIA_CARD_TITLE_H5}>{item.title}</h3>
<p className={`${MEDIA_CARD_DESC_SM} text-[#8C859C]`}>{item.subtitle}</p>
</div>
</a>
);
}
const detailHref = item.href || dramaHref(item.id, item.slug);
if (item.kind === "ad") return <AdDramaCard item={item} compact />;
const href = item.href || dramaHref(item.id, item.slug);
return (
<article data-component="h5-short-drama-card" className={MEDIA_CARD_ROOT}>
<Link href={detailHref} className="block">
<div className="relative aspect-[3/4] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
{item.badge ? (
<div className="absolute top-2 right-2">
<Badge tone={item.badge} className="h-6" />
</div>
) : null}
<CoverRankBadge rank={rank} />
{showEpisodes ? (
<span
className={`absolute right-2 bottom-2 rounded-lg bg-[#08060D]/88 px-1.5 py-0.5 text-white ${MEDIA_CARD_EPISODES_H5}`}
>
{item.episodes}
</span>
) : null}
</div>
<div className={MEDIA_CARD_INFO_H5}>
<h3 className={MEDIA_CARD_TITLE_H5}>{item.title}</h3>
<p className={`${MEDIA_CARD_DESC_SM} text-[#8C859C]`}>{item.subtitle}</p>
</div>
<article data-component="hl-drama-card-h5" className="group flex min-w-0 flex-col gap-1">
<Link href={href} tabIndex={-1} aria-hidden="true" className={`${HL_COVER} aspect-[2/3]`}>
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt={item.title} className={HL_COVER_IMG} />
<HlBadge tone={item.badge} small />
</Link>
<div className={MEDIA_CARD_FOOTER_H5}>
<CardTags tags={item.tags} kind="playlet" className="min-w-0 text-[#A89EB5]" />
<span className="shrink-0 text-[#F12B9E]">{item.heat}</span>
</div>
<h3 className="mt-0.5 truncate text-sm leading-5 font-medium text-[#F4F3F7]">
<RankLabel rank={rank} className="text-xs" />
<Link href={href} title={item.title} className={HL_TITLE_LINK}>
{item.title}
</Link>
</h3>
<DramaMeta item={item} className="text-[11px] leading-4" />
{item.subtitle ? <p className="line-clamp-2 text-[11px] leading-4 text-[#7D7A88]">{item.subtitle}</p> : null}
</article>
);
}
+88
View File
@@ -0,0 +1,88 @@
import Link from "next/link";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { HL_COVER, HL_COVER_IMG, HL_TITLE_LINK } from "@/components/common/media-meta";
import { resolveTopicWorkCount, topicCoverSrc } from "@/components/home/home-module-style";
import { dramaHref } from "@/components/short-drama/data";
import { videoHref } from "@/components/video/video-card";
import type { HomeTopicData, PlayletData, VideoInfo } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { topicDetailHref } from "./href";
type TopicWork = { key: string; title: string; href: string };
/** 专题里的前几部作品:给卡片补充可抓取的站内链接 */
function topicWorks(topic: HomeTopicData, dataType: 1 | 2, limit: number): TopicWork[] {
const works: TopicWork[] = [];
for (const raw of (topic.list ?? []) as Array<PlayletData | VideoInfo | { playlet?: PlayletData; video?: VideoInfo }>) {
if (works.length >= limit) break;
if (!raw || typeof raw !== "object") continue;
const wrapped = raw as { playlet?: PlayletData; video?: VideoInfo };
const isVideo = Boolean(wrapped.video) || (!wrapped.playlet && dataType === 2);
const item = (wrapped.playlet ?? wrapped.video ?? raw) as PlayletData | VideoInfo;
const title = (item.title || "").trim();
if (!item.id || !title) continue;
works.push({
key: `${isVideo ? "v" : "p"}-${item.id}`,
title,
href: isVideo ? videoHref(String(item.id), item.slug) : dramaHref(String(item.id), item.slug),
});
}
return works;
}
/**
* 专题列表卡:上图下文。封面不叠文字;
* 下方专题名 + 收录数量 + 前几部作品链接(区别于其它导航站的全图叠字 / 扇形叠封面)。
*/
export function TopicListCard({
topic,
dataType,
compact,
}: {
topic: HomeTopicData;
dataType: 1 | 2;
compact?: boolean;
}) {
const href = topicDetailHref(topic, dataType);
const name = topic.name?.trim() || "未命名专题";
const count = resolveTopicWorkCount(topic, dataType);
const works = topicWorks(topic, dataType, compact ? 2 : 3);
const unit = dataType === 2 ? "个视频" : "部短剧";
return (
<article data-component="hl-topic-card" className="group flex min-w-0 flex-col gap-2 lg:gap-3">
<Link href={href} tabIndex={-1} aria-hidden="true" className={`${HL_COVER} ${compact ? "aspect-[2/1]" : "aspect-[21/10]"}`}>
<DecryptedImage src={topicCoverSrc(topic)} fallback={IMG_PLACEHOLDER} alt={name} className={HL_COVER_IMG} />
</Link>
<div className="flex min-w-0 items-baseline justify-between gap-2">
<h3 className={`min-w-0 truncate font-medium text-[#F4F3F7] ${compact ? "text-sm leading-5" : "media-card-title-md"}`}>
<Link href={href} title={name} className={HL_TITLE_LINK}>
{name}
</Link>
</h3>
{count ? (
<span className={`shrink-0 text-[#FFB020] ${compact ? "text-[11px] leading-4" : "media-card-desc"}`}>
收录{count}
{unit}
</span>
) : null}
</div>
{works.length ? (
<ul className={`flex min-w-0 flex-col border-l border-[#FFB020]/40 pl-2.5 ${compact ? "gap-0.5" : "gap-1"}`}>
{works.map((work) => (
<li key={work.key} className="min-w-0">
<Link
href={work.href}
className={`block truncate text-[#8E8C99] hover:text-[#FFB020] ${compact ? "text-[11px] leading-4" : "media-card-desc"}`}
>
{work.title}
</Link>
</li>
))}
</ul>
) : null}
</article>
);
}
+3 -33
View File
@@ -1,18 +1,13 @@
import Link from "next/link";
import { AdBannersSlot, AdIconsSlot, FriendLinksSlot, H5FriendLinksSlot } from "@/components/common/ads/slots";
import { ChannelHeader, H5ChannelHeader } from "@/components/common/channel-header";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { FilterChipLink, FilterOptionLink } from "@/components/common/filter-link";
import { MEDIA_CARD_TITLE_PC } from "@/components/common/media-card-hover";
import { Pagination } from "@/components/common/pagination";
import { buildPageHrefMap } from "@/components/common/pagination-map";
import { FloatingActions } from "@/components/home/floating-actions";
import { topicCoverSrc } from "@/components/home/home-module-style";
import { DownloadBanner } from "@/components/home/page-sections";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { HomeTopicData, PlayletData, VideoInfo } from "@/lib/api/types";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import {
isTopicVideo,
@@ -20,9 +15,9 @@ import {
unwrapTopicListItem,
type TopicPageData,
} from "./data";
import { topicDetailHref } from "./href";
import { topicListHref, topicSortOptions } from "./route";
import { TopicPlayletCard, TopicVideoCard } from "./topic-cards";
import { TopicListCard } from "./topic-list-card";
const layouts = {
playlet: {
@@ -54,34 +49,9 @@ function MoreTopicGrid({
return (
<section className="flex flex-col gap-3 lg:gap-5">
<h2 className={compact ? "text-base font-medium text-white" : "text-xl font-medium text-white"}>更多专题</h2>
<div className={compact ? "grid grid-cols-2 gap-[7px]" : gridClassName}>
<div className={compact ? "grid grid-cols-2 gap-x-2.5 gap-y-4" : gridClassName}>
{topics.map((topic) => (
<Link
key={topic.id}
href={topicDetailHref(topic, dataType)}
className={`relative overflow-hidden rounded-lg bg-[#110F1F] ${
compact ? (dataType === 2 ? "aspect-[7/4]" : "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_38.85%_58.45%_at_66.18%_60.1%,rgba(255,255,255,0)_0%,rgba(0,0,0,0.55)_100%)]" />
<div className={compact ? "absolute bottom-3 left-3 right-3" : "absolute bottom-6 left-8 right-8"}>
<h3
className={
compact
? "truncate text-base font-medium text-[#F2F2FA]"
: `text-[#F2F2FA] ${MEDIA_CARD_TITLE_PC}`
}
>
{topic.name}
</h3>
</div>
</Link>
<TopicListCard key={topic.id} topic={topic} dataType={dataType} compact={compact} />
))}
</div>
</section>
+1
View File
@@ -34,6 +34,7 @@ function mapHotVideoItem(item: VideoData): ShortDramaItem {
heat: formatHeat(item.watchNum),
episodes: "",
cover: (item.coverImg || "").trim(),
date: item.addedTime || undefined,
};
}
+58 -181
View File
@@ -1,19 +1,14 @@
"use client";
import Link from "next/link";
import { useState, type ReactNode } from "react";
import type { ReactNode } from "react";
import { BackButton } from "@/components/common/back-button";
import { AdBanners, AdIcons } from "@/components/common/ads";
import { H5CommentsTab, useComments, type CommentTarget } from "@/components/common/comments";
import { BackButton } from "@/components/common/back-button";
import { HlAction, InfoList, SectionTitle, TagLinks, VideoRecommend, type InfoRow, type TagLinkItem } from "@/components/media-page/parts";
import { XgPlayer } from "@/components/player/xg-player";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { tagPageHref } from "@/components/tag/href";
import { H5VideoCard } from "@/components/video/video-card";
import { formatCount, formatWatchNum, type VideoDetail } from "./data";
type Tab = "intro" | "comments";
import { formatCount, type VideoDetail } from "./data";
type StatsProps = {
liked: boolean;
@@ -27,35 +22,16 @@ type StatsProps = {
onShare: () => void;
};
function H5VideoPlayer({
/** H5 视频页:播放器吸顶 → 正文(h1 / 资料 / 标签 / 介绍 / 操作)→ 相关视频推荐(替代原评论标签页) */
export function H5VideoPlay({
detail,
src,
related = [],
infoRows,
tags,
onPlaySuccess,
}: {
detail: VideoDetail;
src: string;
onPlaySuccess?: () => void;
}) {
// 上下文由 VideoPlayPage 统一设置;此处仅绑定播放器行为
return (
<div className="sticky top-0 z-20 aspect-video w-full overflow-hidden bg-black">
<XgPlayer
src={src}
poster={detail.coverImg || ""}
onPlaySuccess={onPlaySuccess}
analyticsKind="video"
clickToPlay
className="absolute inset-0"
options={{ ignores: ["cssfullscreen"], fullscreen: { rotateFullscreen: true } }}
/>
<BackButton fallback="/video/hot" className="absolute top-3 left-4 z-10" />
</div>
);
}
function IntroTab({
detail,
related,
adGrid,
bannerAds,
liked,
likeNum,
collected,
@@ -63,163 +39,64 @@ function IntroTab({
onLike,
onCollect,
onShare,
adGrid,
bannerAds,
}: StatsProps & {
detail: VideoDetail;
related: ShortDramaItem[];
adGrid?: ReactNode;
bannerAds?: ReactNode;
}) {
const tags = (detail.tags ?? []).filter((tag) =>
Boolean(tag.name?.trim() || tag.slug?.trim() || tag.id),
);
const likeCount = likeNum ?? detail.likeNum;
const collectCount = countCollect ?? detail.countCollect;
return (
<div className="flex flex-col gap-[18px] bg-[#010406] px-4 py-3">
<h1 className="line-clamp-2 text-sm leading-5 font-medium text-white/80">{detail.title}</h1>
{detail.desc?.trim() ? (
<p className="text-xs leading-[18px] text-white/80">{detail.desc}</p>
) : null}
{tags.length ? (
<div className="flex flex-wrap items-center gap-1.5">
{tags.map((tag) => {
const name = (tag.tagName || tag.name || "").trim() || tag.slug?.trim() || "";
if (!name) return null;
const canLink = Boolean(tag.slug?.trim() || tag.id != null);
return canLink ? (
<Link
key={tag.slug || tag.id || name}
href={tagPageHref({ slug: tag.slug, id: tag.id }, "video")}
className="rounded-[3px] bg-white/10 px-1.5 py-0.5 text-xs leading-[18px] text-white/90 hover:text-[#F12B9E]"
>
#{name}
</Link>
) : (
<span
key={name}
className="rounded-[3px] bg-white/10 px-1.5 py-0.5 text-xs leading-[18px] text-white/90"
>
#{name}
</span>
);
})}
</div>
) : null}
<div className="flex items-center justify-between text-xs leading-[18px]">
<span className="text-[#BDBDBD]">{formatWatchNum(detail.watchNum)}</span>
<div className="flex items-center gap-3 text-[#BDBDBD]">
<button type="button" aria-pressed={liked} onClick={onLike} className="cursor-pointer flex items-center gap-0.5">
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
<path
d="M8 14c-.2 0-.4-.07-.56-.2C5.3 12 2 9.7 2 6.6 2 4.6 3.4 3 5.2 3c1.1 0 2.1.6 2.8 1.5C8.7 3.6 9.7 3 10.8 3 12.6 3 14 4.6 14 6.6c0 3.1-3.3 5.4-5.44 7.2-.16.13-.36.2-.56.2Z"
fill={liked ? "#F52C56" : "none"}
stroke="#F52C56"
strokeWidth="1.4"
/>
</svg>
{formatCount(likeCount)}
</button>
<button
type="button"
aria-pressed={collected}
onClick={onCollect}
className="cursor-pointer flex items-center gap-0.5"
>
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
<path
d="m8 2 1.8 3.7 4 .6-2.9 2.8.7 4L8 11.2l-3.6 1.9.7-4-2.9-2.8 4-.6L8 2Z"
fill={collected ? "#FFC53D" : "none"}
stroke="#FFC53D"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
{formatCount(collectCount)}
</button>
<button type="button" onClick={onShare} className="cursor-pointer flex items-center gap-0.5">
<img src="/images/share_h5.svg" alt="" className="size-4" />
分享
</button>
</div>
</div>
{/* icon 广告 → 热门推荐 → banner 广告 */}
{adGrid ?? <AdIcons variant="h5" />}
{related.length ? (
<section className="flex flex-col items-center gap-3">
<h2 className="w-full text-lg leading-6 font-semibold text-white">热门推荐</h2>
<div className="grid w-full grid-cols-2 gap-x-[7px] gap-y-3">
{related.map((item) => (
<H5VideoCard key={item.id} item={item} showEpisodes={false} />
))}
</div>
</section>
) : null}
{bannerAds ?? <AdBanners variant="h5" />}
</div>
);
}
export function H5VideoPlay({
detail,
src,
related = [],
commentTarget,
onPlaySuccess,
adGrid,
bannerAds,
...stats
}: StatsProps & {
detail: VideoDetail;
src: string;
related?: ShortDramaItem[];
commentTarget: CommentTarget | null;
infoRows: InfoRow[];
tags: TagLinkItem[];
onPlaySuccess?: () => void;
adGrid?: ReactNode;
bannerAds?: ReactNode;
}) {
const [tab, setTab] = useState<Tab>("intro");
const comments = useComments({ target: commentTarget, initialSort: "最新" });
const tabs: { key: Tab; label: string }[] = [
{ key: "intro", label: "简介" },
{ key: "comments", label: `评论(${comments.total})` },
];
const title = detail.title || "视频";
const desc = detail.desc?.trim() || "";
return (
<div data-component="video-h5-play" className="flex flex-col bg-[#010406] lg:hidden">
<H5VideoPlayer detail={detail} src={src} onPlaySuccess={onPlaySuccess} />
<div role="tablist" className="flex gap-3 px-4 pt-[18px]">
{tabs.map((item) => (
<button
key={item.key}
type="button"
role="tab"
aria-selected={tab === item.key}
onClick={() => setTab(item.key)}
className={`cursor-pointer text-base leading-[22px] ${tab === item.key ? "font-medium text-white" : "text-[#8D8E8F]"}`}
>
{item.label}
</button>
))}
</div>
{tab === "intro" ? (
<IntroTab detail={detail} related={related} adGrid={adGrid} bannerAds={bannerAds} {...stats} />
) : (
<H5CommentsTab
state={comments}
footerExtra={
bannerAds ? <div className="px-4 py-3">{bannerAds}</div> : <AdBanners variant="h5" className="px-4 py-3" />
}
<div data-component="hl-video-play-h5" className="flex flex-col bg-[#0C0B10] lg:hidden">
<div className="sticky top-0 z-20 aspect-video w-full overflow-hidden bg-black">
<XgPlayer
src={src}
poster={detail.coverImg || ""}
onPlaySuccess={onPlaySuccess}
clickToPlay
className="absolute inset-0"
options={{ ignores: ["cssfullscreen"], fullscreen: { rotateFullscreen: true } }}
/>
)}
<BackButton fallback="/video/hot" className="absolute top-3 left-4 z-10" />
</div>
<div className="flex flex-col gap-6 px-4 pt-4 pb-10">
<article className="flex flex-col gap-3">
<p className="text-[11px] leading-4 tracking-[0.15em] text-[#FFB020]">视频库</p>
<h1 className="text-lg leading-7 font-bold text-white">{title}</h1>
<InfoList rows={infoRows} compact />
<TagLinks tags={tags} compact />
<div className="flex items-center gap-2">
<HlAction kind="like" label={formatCount(likeNum ?? detail.likeNum)} active={liked} onClick={onLike} compact />
<HlAction
kind="collect"
label={formatCount(countCollect ?? detail.countCollect)}
active={collected}
onClick={onCollect}
compact
/>
<HlAction kind="share" label="分享" onClick={onShare} compact />
</div>
{desc ? (
<section className="flex flex-col gap-2 pt-1">
<SectionTitle compact>视频介绍</SectionTitle>
<p className="text-[13px] leading-[22px] whitespace-pre-line text-[#A9A7B3]">{desc}</p>
</section>
) : null}
</article>
{adGrid ?? <AdIcons variant="h5" />}
<VideoRecommend items={related} compact />
{bannerAds ?? <AdBanners variant="h5" />}
</div>
</div>
);
}
+84 -98
View File
@@ -1,28 +1,32 @@
"use client";
import { toast } from "@heroui/react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { useVideoContext } from "@/components/analytics/use-playback-analytics";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { AdBanners, AdIcons } from "@/components/common/ads";
import { CommentPanel, videoCommentTarget } from "@/components/common/comments";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { ActionPill } from "@/components/drama-play/drama-play-page";
import { formatDay, formatDuration } from "@/components/common/media-meta";
import { PcPlayer } from "@/components/drama-play/pc-player";
import { FloatingActions } from "@/components/home/floating-actions";
import {
HlAction,
InfoList,
SectionTitle,
TagLinks,
VideoRecommend,
type InfoRow,
type TagLinkItem,
} from "@/components/media-page/parts";
import { PageBreadcrumb } from "@/components/seo/page-breadcrumb";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { tagPageHref } from "@/components/tag/href";
import { favoriteVideo, getVideoInfo, likeVideo } from "@/lib/api/video-actions";
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import type { VideoDetail } from "./data";
import { formatCount, formatWatchNum, type VideoDetail } from "./data";
import { H5VideoPlay } from "./h5-video-play";
import { VideoRelatedAside } from "./related-aside";
import { useReportVideoHistory } from "./use-report-video-history";
async function ensureVideoAuthToken() {
let token = getAuthToken();
@@ -51,7 +55,6 @@ export function VideoPlayPage({
detail,
src,
related = [],
relatedAside,
promo,
adIcons,
adGrid,
@@ -60,7 +63,6 @@ export function VideoPlayPage({
detail: VideoDetail;
src: string;
related?: ShortDramaItem[];
relatedAside?: ReactNode;
promo?: ReactNode;
adIcons?: ReactNode;
adGrid?: ReactNode;
@@ -72,24 +74,6 @@ export function VideoPlayPage({
const [collected, setCollected] = useState(Boolean(detail.isFavorite));
const [countCollect, setCountCollect] = useState(Number(detail.countCollect) || 0);
const [collecting, setCollecting] = useState(false);
const reportWatchHistory = useReportVideoHistory(Number(detail.id));
const tagName = (detail.tags?.[0]?.tagName || detail.tags?.[0]?.name || "").trim();
const videoTrackContext = useMemo(
() => ({
media_id: String(detail.id || ""),
video_id: String(detail.id || ""),
video_title: detail.title || "未命名视频",
video_type_id: "0",
video_type_name: "未分类",
video_content_type: "video",
recommend_trace_id: "",
video_tag_key: tagName ? "tag" : "default",
video_tag_name: tagName || "默认标签",
video_duration: Math.max(0, Math.round(Number(detail.video?.videoTime) || 0)),
}),
[detail.id, detail.title, detail.video?.videoTime, tagName],
);
useVideoContext(videoTrackContext);
const likeFavoriteKey = `${detail.id}:${detail.isLike}:${detail.likeNum}:${detail.isFavorite}:${detail.countCollect}`;
const [likeFavoriteSync, setLikeFavoriteSync] = useState(likeFavoriteKey);
@@ -206,88 +190,92 @@ export function VideoPlayPage({
}
};
return (
<main data-component="video-play-page" className="relative flex-1 bg-[#0B0709]">
<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.coverImg}
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>
const title = detail.title || "视频";
const desc = detail.desc?.trim() || "";
const day = formatDay(detail.addedTime);
const duration = formatDuration(detail.video?.videoTime);
const tags: TagLinkItem[] = (detail.tags ?? [])
.map((tag): TagLinkItem | null => {
const name = (tag.tagName || tag.name || "").trim();
if (!name) return null;
const canLink = Boolean(tag.slug?.trim() || tag.id != null);
return {
key: tag.slug || String(tag.id ?? name),
name,
href: canLink ? tagPageHref({ slug: tag.slug, id: tag.id }, "video") : undefined,
};
})
.filter((tag): tag is TagLinkItem => Boolean(tag));
const infoRows: InfoRow[] = [
{ label: "发布", value: day ? <time dateTime={day}>{day}</time> : "" },
{ label: "时长", value: duration },
{ label: "播放", value: formatWatchNum(detail.watchNum) },
{ label: "收藏", value: formatCount(countCollect) },
];
const crumbs = [
{ name: "首页", href: "/" },
{ name: "视频库", href: "/video/hot" },
{ name: title },
];
return (
<main data-component="hl-video-play" className="relative flex-1 bg-[#0C0B10]">
<SeoBreadcrumb items={crumbs} />
<div
data-pc-scale
className="relative mx-auto hidden w-[1700px] flex-col gap-6 pt-6 pb-8 [zoom:var(--page-scale,1)] lg:flex"
className="relative mx-auto hidden w-[1700px] flex-col gap-8 pt-6 pb-14 [zoom:var(--page-scale,1)] lg:flex"
>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "视频详情" },
]}
/>
<PageBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: detail.title || "视频详情" },
]}
<PageBreadcrumb items={crumbs} />
<PcPlayer
src={src}
poster={detail.coverImg || ""}
title={title}
locked={false}
dramaTitle={title}
layout="wide"
/>
<div className="flex flex-col gap-4">
<PcPlayer
src={src}
poster={detail.coverImg || ""}
title={detail.title || ""}
locked={false}
dramaTitle={detail.title || ""}
layout="wide"
onPlaySuccess={reportWatchHistory}
analyticsKind="video"
/>
{adIcons ?? <AdIcons variant="pc" labels={false} />}
<section className="flex h-[140px] items-center justify-between gap-10 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="truncate text-[28px] leading-[34px] font-semibold text-white">{detail.title}</p>
<p className="line-clamp-2 text-xs leading-[18px] text-[#9991AB]">{detail.desc}</p>
<article className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-10 gap-y-5 border-t-2 border-[#FFB020] pt-8">
<div className="flex min-w-0 flex-col gap-4">
<p className="text-sm leading-5 tracking-[0.2em] text-[#FFB020]">视频库</p>
<h1 className="text-4xl leading-[48px] font-bold text-white">{title}</h1>
<div className="max-w-[900px]">
<InfoList rows={infoRows} />
</div>
<div className="flex shrink-0 gap-3">
<ActionPill
icon="/images/icon_like.svg"
label={liked ? "已点赞" : "点赞"}
active={liked}
onClick={liking ? undefined : 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 variant="play" target={videoCommentTarget(detail.id)} />
{relatedAside ?? <VideoRelatedAside items={related} />}
<TagLinks tags={tags} />
</div>
<div className="flex shrink-0 gap-3 pt-7">
<HlAction kind="like" label={`点赞 ${formatCount(likeNum)}`} active={liked} onClick={liking ? undefined : toggleLike} />
<HlAction
kind="collect"
label={collected ? "已收藏" : "收藏"}
active={collected}
onClick={collecting ? undefined : toggleCollect}
/>
<HlAction kind="share" label="分享" onClick={share} />
</div>
{desc ? (
<section className="col-span-2 flex flex-col gap-3">
<SectionTitle>视频介绍</SectionTitle>
<p className="max-w-[1100px] text-[15px] leading-7 whitespace-pre-line text-[#B5B3BE]">{desc}</p>
</section>
) : null}
</article>
{promo ?? <AdBanners variant="pc" />}
</div>
{adIcons ?? <AdIcons variant="pc" labels={false} />}
<VideoRecommend items={related} />
{promo ?? <AdBanners variant="pc" />}
</div>
<H5VideoPlay
detail={detail}
src={src}
related={related}
infoRows={infoRows.slice(0, 3)}
tags={tags}
liked={liked}
likeNum={likeNum}
collected={collected}
@@ -295,8 +283,6 @@ export function VideoPlayPage({
onLike={liking ? () => undefined : toggleLike}
onCollect={collecting ? () => undefined : toggleCollect}
onShare={share}
commentTarget={videoCommentTarget(detail.id)}
onPlaySuccess={reportWatchHistory}
adGrid={adGrid}
bannerAds={bannerAds}
/>
+1
View File
@@ -36,6 +36,7 @@ export function mapVideoListItem(item: VideoListItem): ShortDramaItem | null {
heat: formatHeat(item.watchNum),
episodes: "",
cover: (item.coverImg || "").trim(),
date: item.addedTime || undefined,
};
}
+79 -114
View File
@@ -3,20 +3,13 @@ import Link from "next/link";
import { CardTags } from "@/components/common/card-tags";
import { DecryptedImage } from "@/components/common/decrypted-image";
import {
MEDIA_CARD_COVER,
MEDIA_CARD_DESC,
MEDIA_CARD_DESC_SM,
MEDIA_CARD_EPISODES,
MEDIA_CARD_EPISODES_H5,
MEDIA_CARD_FOOTER_H5,
MEDIA_CARD_INFO_H5,
MEDIA_CARD_META,
MEDIA_CARD_ROOT,
MEDIA_CARD_TITLE_H5,
MEDIA_CARD_TITLE_PC,
} from "@/components/common/media-card-hover";
import { RankNumber } from "@/components/common/rank-number";
import { Badge } from "@/components/home/section-heading";
EyeIcon,
formatDay,
HL_COVER,
HL_COVER_IMG,
HL_TITLE_LINK,
HlBadge,
} from "@/components/common/media-meta";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
@@ -24,59 +17,68 @@ export function videoHref(id: string, slug?: string | null) {
return `/video/${(slug?.trim() || id).trim()}`;
}
export function VideoCard({ item, rank }: { item: ShortDramaItem; rank?: number }) {
if (item.kind === "ad") {
return (
<a
href={item.href || "#download"}
target={item.href ? "_blank" : undefined}
rel={item.href ? "sponsored nofollow noopener noreferrer" : undefined}
className={`block w-[324px] ${MEDIA_CARD_ROOT}`}
>
<div className="relative h-[192px] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
<img src="/logo.svg" alt="海角社区" className="absolute top-[25px] left-[15px] h-[27px] w-[120px]" />
</div>
<div className="flex min-h-24 flex-col justify-center gap-1.5 px-2.5 py-3">
<h3 className={MEDIA_CARD_TITLE_PC}>{item.title}</h3>
<p className={`${MEDIA_CARD_DESC} text-white/60`}>{item.subtitle}</p>
</div>
</a>
);
}
/** 排行序号:No.01(琥珀色,放在标题前) */
function RankLabel({ rank, className = "" }: { rank?: number; className?: string }) {
if (rank === undefined) return null;
return (
<span className={`mr-1.5 font-bold text-[#FFB020] tabular-nums ${className}`}>No.{String(rank).padStart(2, "0")}</span>
);
}
const detailHref = item.href || videoHref(item.id, item.slug);
/** 视频卡元信息:观看数 + 发布日期 */
function VideoMeta({ item, className }: { item: Extract<ShortDramaItem, { kind: "drama" }>; className: string }) {
const day = formatDay(item.date);
return (
<p className={`flex min-w-0 items-center gap-2 text-[#8E8C99] ${className}`}>
{item.heat ? (
<span className="inline-flex shrink-0 items-center gap-1">
<EyeIcon />
{item.heat}
</span>
) : null}
{item.heat && day ? <span aria-hidden="true" className="h-2.5 w-px bg-white/15" /> : null}
{day ? <time dateTime={day}>{day}</time> : null}
</p>
);
}
function AdVideoCard({ item, compact }: { item: ShortDramaItem; compact?: boolean }) {
return (
<a
href={item.href || (compact ? "#h5-download" : "#download")}
target={item.href ? "_blank" : undefined}
rel={item.href ? "sponsored nofollow noopener noreferrer" : undefined}
className={`group flex flex-col ${compact ? "gap-1.5" : "w-[324px] gap-2.5"}`}
>
<span className={`${HL_COVER} aspect-[16/10]`}>
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt={item.title} className={HL_COVER_IMG} />
<span className="absolute top-0 left-0 rounded-br-md bg-white/85 px-1.5 py-0.5 text-[10px] leading-[14px] text-black">
推广
</span>
</span>
<span className={`line-clamp-2 text-white ${compact ? "text-sm leading-5" : "media-card-title-md"}`}>{item.title}</span>
</a>
);
}
export function VideoCard({ item, rank }: { item: ShortDramaItem; rank?: number }) {
if (item.kind === "ad") return <AdVideoCard item={item} />;
const href = item.href || videoHref(item.id, item.slug);
return (
<article data-component="video-card" className={`w-[324px] ${MEDIA_CARD_ROOT}`}>
<Link href={detailHref} className="block">
<div className="relative h-[192px] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
{item.badge ? (
<div className="absolute top-[15.5px] right-4">
<Badge tone={item.badge} />
</div>
) : null}
{item.episodes ? (
<span
className={`absolute right-2 bottom-2 rounded-md bg-black/60 px-3 py-2 text-white ${MEDIA_CARD_EPISODES}`}
>
{item.episodes}
</span>
) : null}
</div>
<div className="flex min-w-0 flex-col gap-1.5 px-2.5 pt-3">
<h3 className={`flex min-h-7 items-center gap-1.5 ${MEDIA_CARD_TITLE_PC}`}>
<RankNumber rank={rank} className="media-card-title" />
<span className="min-w-0 truncate">{item.title}</span>
</h3>
<p className={`${MEDIA_CARD_DESC} text-white/60`}>{item.subtitle}</p>
</div>
<article data-component="hl-video-card" className="group flex w-[324px] flex-col gap-2.5">
<Link href={href} tabIndex={-1} aria-hidden="true" className={`${HL_COVER} aspect-[16/10]`}>
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt={item.title} className={HL_COVER_IMG} />
<HlBadge tone={item.badge} />
</Link>
<div className={`flex min-w-0 items-center justify-between gap-2 px-2.5 pt-1.5 pb-3 ${MEDIA_CARD_META}`}>
<CardTags tags={item.tags} kind="video" className="min-w-0 text-white/60" />
<span className="shrink-0 text-[#F92882]">{item.heat}</span>
</div>
<h3 className="media-card-title-md line-clamp-2 min-h-[2.5em] font-medium text-[#F4F3F7]">
<RankLabel rank={rank} />
<Link href={href} title={item.title} className={HL_TITLE_LINK}>
{item.title}
</Link>
</h3>
<VideoMeta item={item} className="media-card-desc" />
<CardTags tags={item.tags} kind="video" className="media-card-meta min-w-0 text-[#B49A6A]" />
</article>
);
}
@@ -84,65 +86,28 @@ export function VideoCard({ item, rank }: { item: ShortDramaItem; rank?: number
export function H5VideoCard({
item,
rank,
showEpisodes = true,
}: {
item: ShortDramaItem;
rank?: number;
/** @deprecated 新卡片不在封面显示集数,保留参数兼容调用方 */
showEpisodes?: boolean;
}) {
if (item.kind === "ad") {
return (
<a
href={item.href || "#h5-download"}
target={item.href ? "_blank" : undefined}
rel={item.href ? "sponsored nofollow noopener noreferrer" : undefined}
className={`block ${MEDIA_CARD_ROOT}`}
>
<div className="relative aspect-[7/4] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
<div className="absolute inset-0 bg-black/40" />
<img src="/logo.svg" alt="海角社区" className="absolute top-[17px] left-[13px] h-4 w-[71px]" />
</div>
<div className={`${MEDIA_CARD_INFO_H5} pb-1.5`}>
<h3 className={MEDIA_CARD_TITLE_H5}>{item.title}</h3>
<p className={`${MEDIA_CARD_DESC_SM} text-[#8C859C]`}>{item.subtitle}</p>
</div>
</a>
);
}
const detailHref = item.href || videoHref(item.id, item.slug);
if (item.kind === "ad") return <AdVideoCard item={item} compact />;
const href = item.href || videoHref(item.id, item.slug);
return (
<article data-component="h5-video-card" className={MEDIA_CARD_ROOT}>
<Link href={detailHref} className="block">
<div className="relative aspect-[7/4] overflow-hidden rounded-t-lg">
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt="" className={MEDIA_CARD_COVER} />
{item.badge ? (
<div className="absolute top-2 right-2">
<Badge tone={item.badge} className="h-6" />
</div>
) : null}
{showEpisodes && item.episodes ? (
<span
className={`absolute right-2 bottom-[3px] flex h-6 items-center rounded-lg bg-[#08060D]/88 px-1.5 text-white ${MEDIA_CARD_EPISODES_H5}`}
>
{item.episodes}
</span>
) : null}
</div>
<div className={MEDIA_CARD_INFO_H5}>
<h3 className={`flex items-center gap-1 ${MEDIA_CARD_TITLE_H5}`}>
<RankNumber rank={rank} className="text-sm leading-5" />
<span className="truncate">{item.title}</span>
</h3>
<p className={`${MEDIA_CARD_DESC_SM} text-[#8C859C]`}>{item.subtitle}</p>
</div>
<article data-component="hl-video-card-h5" className="group flex min-w-0 flex-col gap-1.5">
<Link href={href} tabIndex={-1} aria-hidden="true" className={`${HL_COVER} aspect-[16/10]`}>
<DecryptedImage src={item.cover} fallback={IMG_PLACEHOLDER} alt={item.title} className={HL_COVER_IMG} />
<HlBadge tone={item.badge} small />
</Link>
<div className={MEDIA_CARD_FOOTER_H5}>
<CardTags tags={item.tags} kind="video" className="min-w-0 text-[#A89EB5]" />
<span className="shrink-0 text-[#F12B9E]">{item.heat}</span>
</div>
<h3 className="line-clamp-2 min-h-10 text-sm leading-5 font-medium text-[#F4F3F7]">
<RankLabel rank={rank} className="text-xs" />
<Link href={href} title={item.title} className={HL_TITLE_LINK}>
{item.title}
</Link>
</h3>
<VideoMeta item={item} className="text-[11px] leading-4" />
</article>
);
}
+2 -47
View File
@@ -1,52 +1,7 @@
export type BackupLine = { name: string; domain: string; speed: string };
export type PermanentEntry = { label: string; value: string };
export type ContactChannel = { title: string; description: string; action: string; href: string };
export type Step = { title: string; description: string };
export type DeviceTip = { icon: string; title: string; recommend: string; warning: string };
export type Faq = { question: string; answer: string };
export const replyEmail = "haidao@gmail.com";
export const backupLines: BackupLine[] = [
{ name: "备用地址 01", domain: "jsczyr.naquostmx.cc", speed: "极速" },
{ name: "备用地址 02", domain: "jsczyr.naquostmx.cc", speed: "极速" },
{ name: "备用地址 03", domain: "jsczyr.naquostmx.cc", speed: "极速" },
];
export const permanentEntries: PermanentEntry[] = [
{ label: "永久地址", value: "jsczyr.naquostmx.cc" },
{ label: "永久中转页", value: "haidao.ai" },
{ label: "最新中转页(免翻墙)", value: "haidao.com" },
{ label: "永久发布页", value: "https://haidao.pages.dev" },
];
export const contactChannels: ContactChannel[] = [
{
title: "✉ 邮箱自动回复",
description: "发送任意邮件到下方地址,30秒内自动回复当前所有可用入口的最新汇总。",
action: replyEmail,
href: `mailto:${replyEmail}`,
},
{
title: "➤ TG 官方交流群",
description: "获取域名变更通知的首选渠道,实时播报并加入用户互助社区。",
action: "立即订阅最新动态",
href: "https://t.me/haijiao",
},
{
title: "◆ 官方商务合作",
description: "联系官方商务合作,洽谈商务事宜,精诚合作,财富共赢。",
action: "立即联系",
href: "https://t.me/haijiao",
},
{
title: "𝕏 推特社区",
description: "关注官方推特社区,第一时间获取地址变更与官方公告。",
action: "快速访问",
href: "https://x.com/haijiao",
},
];
export const troubleshootSteps: Step[] = [
{ title: "打不开页面", description: "先切换网络,再尝试永久入口或备用入口。" },
{ title: "地址跳转异常", description: "确认地址拼写完整,不要从陌生弹窗进入。" },
@@ -77,7 +32,7 @@ export const deviceTips: DeviceTip[] = [
export const faqs: Faq[] = [
{
question: "回家的路页面有什么作用?",
answer: "集中展示最新地址、永久入口、备用入口和保存方式,方便访问异常时快速返回海角社区。",
answer: "集中展示最新地址、永久入口、备用入口和保存方式,方便访问异常时快速返回黄果黑料。",
},
{ question: "建议保存哪个入口?", answer: "优先收藏当前推荐入口,并额外保存永久入口或备用入口。" },
{
@@ -86,6 +41,6 @@ export const faqs: Faq[] = [
},
{
question: "如何确认入口是否安全?",
answer: "只从本页、站内导航或海角社区提示进入,不要从陌生弹窗、未知短链跳转。",
answer: "只从本页、站内导航或黄果黑料提示进入,不要从陌生弹窗、未知短链跳转。",
},
];
+32 -73
View File
@@ -3,15 +3,13 @@ import type { ReactNode } from "react";
import { H5PageHeader } from "@/components/common/h5-page-header";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { AnnouncementInfo, ConfigInfo } from "@/lib/api/types";
import type { AnnouncementInfo } from "@/lib/api/types";
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL, SITE_DOMAIN } from "@/lib/seo/metadata";
import { CopyButton } from "./copy-button";
import {
backupLines as mockBackupLines,
contactChannels as mockContactChannels,
deviceTips,
faqs,
permanentEntries as mockPermanentEntries,
troubleshootSteps,
} from "./data";
@@ -76,7 +74,7 @@ function Hero() {
<img src="/logo2.png" alt="" className="size-10 rounded-[7px] bg-white lg:size-[62px] lg:rounded-[10.9px]" />
<div className="flex flex-col">
<p className="text-base leading-[22px] font-semibold text-white lg:text-[24.8px] lg:leading-[34.1px]">
海角社区<span className="ml-0.5 text-[#F12B9E] lg:ml-[3px]">APP</span>
黄果黑料<span className="ml-0.5 text-[#F12B9E] lg:ml-[3px]">APP</span>
</p>
<p className="text-xs leading-[18px] text-[#DCDCDC] lg:text-[18.6px] lg:leading-[27.9px] lg:text-white">
全网最全成人视频平台
@@ -84,7 +82,7 @@ function Hero() {
</div>
</div>
<h1 className="relative text-base leading-[22px] font-medium text-white lg:text-2xl lg:leading-8">
海角社区 · 官方最新地址发布页
黄果黑料 · 官方最新地址发布页
</h1>
<p className="relative text-xs leading-[18px] text-[#DCDCDC] lg:text-xl lg:leading-7 lg:font-medium">
实时同步最新可用线路与备用入口,帮助你在不同网络环境下快速返回。
@@ -105,16 +103,9 @@ function Hero() {
);
}
function BackupLines({ announcement }: { announcement?: AnnouncementInfo | null }) {
const apiLines = [
{ name: "新国内地址", value: announcement?.newUrlAddr },
{ name: "备用地址 01", value: announcement?.newUrlAddr1 },
{ name: "备用地址 02", value: announcement?.newUrlAddr2 },
].filter((line) => line.value?.trim());
const lines = apiLines.length
? apiLines.map((line) => ({ name: line.name, value: line.value!.trim(), speed: "极速" as const }))
: mockBackupLines.map((line) => ({ name: line.name, value: line.domain, speed: line.speed }));
/** 地址固定本站域名,不取接口 newUrlAddr* */
function BackupLines() {
const lines = [{ name: "最新地址", value: SITE_DOMAIN, speed: "极速" }];
return (
<div className="grid gap-3 lg:grid-cols-3 lg:gap-[18px]">
@@ -150,16 +141,16 @@ function BackupLines({ announcement }: { announcement?: AnnouncementInfo | null
}
function PermanentEntries({ announcement }: { announcement?: AnnouncementInfo | null }) {
const apiEntries = [
{ label: "永久海外地址", value: announcement?.foreverUrlAddr },
{ label: "应用导航", value: announcement?.translationAddr },
{ label: "回家的路 PDF", value: announcement?.pdfAddr },
{ label: "吃瓜 APP", value: announcement?.appAddr },
].filter((entry) => entry.value?.trim());
const entries = apiEntries.length
? apiEntries.map((entry) => ({ label: entry.label, value: entry.value!.trim() }))
: mockPermanentEntries;
// 永久地址固定本站域名;PDF / APP 下载仍取接口
const entries = [
{ label: "永久地址", value: SITE_DOMAIN },
...[
{ label: "回家的路 PDF", value: announcement?.pdfAddr },
{ label: "吃瓜 APP", value: announcement?.appAddr },
]
.filter((entry) => entry.value?.trim())
.map((entry) => ({ label: entry.label, value: entry.value!.trim() })),
];
return (
<div className="flex flex-col gap-3 lg:gap-2.5">
@@ -185,46 +176,20 @@ function PermanentEntries({ announcement }: { announcement?: AnnouncementInfo |
);
}
function ContactChannels({ config }: { config?: ConfigInfo | null }) {
const apiChannels = [
config?.gmailAddr
? {
title: "✉ 邮箱自动回复",
description: "发送任意邮件到下方地址,30秒内自动回复当前所有可用入口的最新汇总。",
action: config.gmailAddr,
href: `mailto:${config.gmailAddr}`,
}
: null,
config?.tgGroup || config?.telegramAdd
? {
title: "➤ TG 官方交流群",
description: "获取域名变更通知的首选渠道,实时播报并加入用户互助社区。",
action: "立即订阅最新动态",
href: (config.tgGroup || config.telegramAdd)!,
}
: null,
config?.telegram
? {
title: "◆ 官方商务合作",
description: "联系官方商务合作,洽谈商务事宜,精诚合作,财富共赢。",
action: "立即联系",
href: config.telegram,
}
: null,
config?.twitterAddr
? {
title: "𝕏 推特社区",
description: "关注官方推特社区,第一时间获取地址变更与官方公告。",
action: "快速访问",
href: config.twitterAddr,
}
: null,
].filter(Boolean) as { title: string; description: string; action: string; href: string }[];
/** 联系方式只保留官方 TG,不取接口 gmailAddr / telegram / twitterAddr */
const channels = [
{
title: "➤ TG 官方联系",
description: "获取域名变更通知的首选渠道,地址更新、商务合作都可直接联系。",
action: OFFICIAL_TG_HANDLE,
href: OFFICIAL_TG_URL,
},
];
const channels = apiChannels.length ? apiChannels : mockContactChannels;
function ContactChannels() {
return (
<div className="grid gap-3 lg:grid-cols-2 lg:gap-4 xl:grid-cols-4">
<div className="grid gap-3 lg:gap-4">
{channels.map((channel) => (
<div
key={channel.title}
@@ -234,7 +199,7 @@ function ContactChannels({ config }: { config?: ConfigInfo | null }) {
<p className="self-stretch text-sm leading-5 text-[#DCDCDC] lg:flex-1">{channel.description}</p>
<a
href={channel.href}
target={channel.href.startsWith("mailto:") ? undefined : "_blank"}
target="_blank"
rel="noopener noreferrer"
className="flex self-stretch rounded-full bg-[#3D3D3D] px-4 py-3 text-sm leading-5 text-white outline outline-1 -outline-offset-1 outline-black/5 transition-colors hover:bg-[#4A4A4A]"
>
@@ -293,13 +258,7 @@ function FaqList() {
);
}
export function WayHomePage({
announcement,
config,
}: {
announcement?: AnnouncementInfo | null;
config?: ConfigInfo | null;
}) {
export function WayHomePage({ announcement }: { announcement?: AnnouncementInfo | null }) {
return (
<main data-component="way-home-page" className="relative isolate flex-1 overflow-x-clip bg-black/70">
<div className="pointer-events-none absolute inset-0 -z-10 hidden overflow-hidden lg:block" aria-hidden="true">
@@ -321,7 +280,7 @@ export function WayHomePage({
tagClassName="text-base leading-[22px] text-[#F12B9E] lg:text-sm lg:leading-5"
description="以下线路均经过可用性检查,优先选择高速线路。"
>
<BackupLines announcement={announcement} />
<BackupLines />
</Section>
<Section
@@ -341,7 +300,7 @@ export function WayHomePage({
tagClassName="text-[#FF5412] lg:text-[#F12B9E]"
description="通过以下方式获取最新地址或接收线路变更提醒。"
>
<ContactChannels config={config} />
<ContactChannels />
</Section>
<Section