Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-26 17:51:31 +07:00
co-authored by Claude Opus 5.5
commit 61d0a11dfa
371 changed files with 35836 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
import { connection } from "next/server";
import { getAdvitiseCached } from "@/lib/api/home";
import { AdDialogHost } from "./ad-dialog";
/** 服务端插槽:从 /api/app/home/base 拉取 advitise.dialogList(Suspense + connection) */
export async function AdDialogSlot() {
await connection();
const advitise = await getAdvitiseCached();
return <AdDialogHost advitise={advitise} />;
}
+305
View File
@@ -0,0 +1,305 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { getAgeGateOpen, subscribeAgeGate } from "@/components/age-gate/age-gate-dialog";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { adTrackProps } from "@/components/common/ads/pick";
import { useChromeless } from "@/components/layout/route-visibility";
import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types";
/** 对齐 jha-1043 dialogAd.js:签名队列 + session 进度 / 关闭态 */
const QUEUE_VERSION = "2";
const DISMISSED_KEY = "dialogAdDismissed";
const PROGRESS_KEY = "dialogAdProgress";
let queueIdle = true;
const listeners = new Set<() => void>();
function emit() {
listeners.forEach((listener) => listener());
}
function setQueueIdle(idle: boolean) {
if (queueIdle === idle) return;
queueIdle = idle;
emit();
}
export function subscribeAd(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** 当前是否正在展示广告弹窗 */
export const getAdOpen = () => !queueIdle;
/**
* 广告队列是否已结束(已全部关闭 / 无广告 / 已 dismiss)
* 供最新地址弹窗排序:年龄确认 → 广告队列 → 地址弹窗
*/
export function isAdQueueDone(signature: string) {
if (!signature) return true;
try {
return window.sessionStorage.getItem(DISMISSED_KEY) === signature;
} catch {
return true;
}
}
/** @deprecated 兼容旧调用:等价于队列空闲 */
export const hasAdShown = () => queueIdle;
const getAgeGateServerSnapshot = () => true;
function readSession(key: string) {
try {
return window.sessionStorage.getItem(key) || "";
} catch {
return "";
}
}
function writeSession(key: string, value: string) {
try {
window.sessionStorage.setItem(key, value);
} catch {
// sessionStorage 不可用时仍允许本页依次展示
}
}
function removeSession(key: string) {
try {
window.sessionStorage.removeItem(key);
} catch {}
}
function buildSignature(ads: AdvertiseInfo[]) {
return (
QUEUE_VERSION +
"|" +
ads
.map((item, index) => item.id || item.images || String(index))
.join("|")
);
}
function readProgress(signature: string) {
try {
const raw = readSession(PROGRESS_KEY);
if (!raw) return 0;
const progress = JSON.parse(raw) as { signature?: string; index?: number };
if (!progress || progress.signature !== signature) return 0;
const value = Number(progress.index);
if (!Number.isFinite(value) || value < 0) return 0;
return Math.floor(value);
} catch {
return 0;
}
}
function rememberProgress(signature: string, index: number) {
writeSession(PROGRESS_KEY, JSON.stringify({ signature, index }));
}
function pickDialogAds(items?: AdvertiseInfo[] | null, advitise?: AdvitiseData | null) {
const source = items ?? advitise?.dialogList ?? [];
return source.filter((item): item is AdvertiseInfo & { images: string } => Boolean(item.images?.trim()));
}
/** 弹窗广告:使用 advitise.dialogList,全站队列展示(对齐 jha-1043) */
export function AdDialogHost({
items,
advitise,
}: {
items?: AdvertiseInfo[] | null;
advitise?: AdvitiseData | null;
}) {
const chromeless = useChromeless();
const ageGateOpen = useSyncExternalStore(subscribeAgeGate, getAgeGateOpen, getAgeGateServerSnapshot);
const ads = useMemo(() => pickDialogAds(items, advitise), [items, advitise]);
const signature = useMemo(() => buildSignature(ads), [ads]);
const [index, setIndex] = useState(-1);
const hasDisplayedRef = useRef(false);
const enabled = !chromeless && !ageGateOpen && ads.length > 0;
useEffect(() => {
let cancelled = false;
void (async () => {
await Promise.resolve();
if (cancelled) return;
if (!enabled) {
if (!ads.length) setQueueIdle(true);
return;
}
if (isAdQueueDone(signature)) {
setIndex(-1);
setQueueIdle(true);
return;
}
const start = readProgress(signature);
if (start >= ads.length) {
if (hasDisplayedRef.current) writeSession(DISMISSED_KEY, signature);
removeSession(PROGRESS_KEY);
setIndex(-1);
setQueueIdle(true);
return;
}
setIndex(start);
setQueueIdle(false);
})();
return () => {
cancelled = true;
};
}, [enabled, signature, ads.length]);
const finishQueue = useCallback(() => {
if (hasDisplayedRef.current) writeSession(DISMISSED_KEY, signature);
else removeSession(DISMISSED_KEY);
removeSession(PROGRESS_KEY);
setIndex(-1);
setQueueIdle(true);
}, [signature]);
const showNext = useCallback(() => {
setIndex((current) => {
const next = current + 1;
if (next >= ads.length) {
finishQueue();
return -1;
}
rememberProgress(signature, next);
return next;
});
}, [ads.length, finishQueue, signature]);
const skipBroken = useCallback(() => {
showNext();
}, [showNext]);
useEffect(() => {
if (index < 0 || index >= ads.length) return;
hasDisplayedRef.current = true;
rememberProgress(signature, index);
setQueueIdle(false);
}, [index, ads.length, signature]);
useEffect(() => {
if (index < 0) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") showNext();
};
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = overflow;
window.removeEventListener("keydown", onKeyDown);
};
}, [index, showNext]);
const ad = index >= 0 && index < ads.length ? ads[index] : null;
const adId = ad ? String(ad.advertiseCode || ad.id || "") : "";
const trackAttrs = ad
? adTrackProps(ad, {
slotKey: "dialog_ad",
slotName: "弹窗广告位",
adType: "interstitial",
})
: {};
useEffect(() => {
if (!ad?.images || !adId) return;
document.dispatchEvent(
new CustomEvent("dialog-ad:track", {
detail: {
eventType: "show",
element: Object.assign(document.createElement("div"), {
dataset: {
advertisingKey: "dialog_ad",
advertisingName: "弹窗广告",
advertisingId: adId,
dialogAdId: adId,
},
}),
},
}),
);
}, [ad, adId]);
if (!ad?.images) return null;
const picture = (
<DecryptedImage
src={ad.images}
alt={ad.title || "广告"}
priority
className="h-[468px] w-[300px] rounded-3xl object-cover lg:h-[602px] lg:w-[400px] lg:rounded-[40px]"
onError={skipBroken}
/>
);
return (
<div
data-component="ad-dialog"
className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto bg-black/70 px-4 py-10"
onClick={(event) => event.target === event.currentTarget && showNext()}
>
<div
role="dialog"
aria-modal="true"
aria-label={ad.title || "广告"}
className="my-auto flex flex-col items-center gap-9 lg:gap-6"
onClick={(event) => event.target === event.currentTarget && showNext()}
data-advertising-key="dialog_ad"
data-advertising-name="弹窗广告"
data-advertising-id={adId}
data-dialog-ad-id={adId}
>
{ad.link ? (
<a
href={ad.link}
target="_blank"
rel="noopener noreferrer sponsored"
className="block"
{...trackAttrs}
onClick={() => {
document.dispatchEvent(
new CustomEvent("dialog-ad:track", {
detail: {
eventType: "click",
element: Object.assign(document.createElement("div"), {
dataset: {
advertisingKey: "dialog_ad",
advertisingName: "弹窗广告",
advertisingId: adId,
dialogAdId: adId,
},
}),
},
}),
);
}}
>
{picture}
</a>
) : (
picture
)}
<button
type="button"
aria-label="关闭广告"
data-dialog-ad-close=""
onClick={showNext}
className="size-9 cursor-pointer"
>
<svg viewBox="0 0 36 36" className="size-full" aria-hidden="true">
<circle cx="18" cy="18" r="14" fill="none" stroke="white" strokeWidth="2" />
<path d="M13 13l10 10M23 13L13 23" stroke="white" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
</div>
);
}