diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c038553..38be3e1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import Script from "next/script"; import { Suspense } from "react"; +import { AdClickReporter } from "@/components/ad/ad-click-reporter"; import { AdDialogSlot } from "@/components/ad/ad-dialog-slot"; import { AddressDialogSlot } from "@/components/address/address-dialog-slot"; import { AgeGateDialogHost } from "@/components/age-gate/age-gate-dialog"; @@ -99,6 +100,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) { {children} + diff --git a/src/components/ad/ad-click-reporter.tsx b/src/components/ad/ad-click-reporter.tsx new file mode 100644 index 0000000..da83471 --- /dev/null +++ b/src/components/ad/ad-click-reporter.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect } from "react"; + +import { adClickPayload, reportAdClick } from "@/lib/api/ads-actions"; +import { getAuthToken } from "@/lib/auth/session"; + +/** + * 全站广告点击上报:document 捕获阶段监听 click / auxclick(中键), + * 命中带 data-ads-id 的元素即异步上报;失败静默,不阻塞也不拦截广告跳转。 + */ +export function AdClickReporter() { + useEffect(() => { + const onClick = (event: MouseEvent) => { + if (event.type === "auxclick" && event.button !== 1) return; + const payload = adClickPayload(event.target); + if (!payload) return; + void reportAdClick(payload, getAuthToken()).catch(() => {}); + }; + document.addEventListener("click", onClick, true); + document.addEventListener("auxclick", onClick, true); + return () => { + document.removeEventListener("click", onClick, true); + document.removeEventListener("auxclick", onClick, true); + }; + }, []); + + return null; +} diff --git a/src/components/ad/ad-dialog.tsx b/src/components/ad/ad-dialog.tsx index 9d6f5fb..d6c3f2e 100644 --- a/src/components/ad/ad-dialog.tsx +++ b/src/components/ad/ad-dialog.tsx @@ -6,6 +6,7 @@ import { getAgeGateOpen, subscribeAgeGate } from "@/components/age-gate/age-gate import { DecryptedImage } from "@/components/common/decrypted-image"; import { useChromeless } from "@/components/layout/route-visibility"; import { lockBodyScroll } from "@/lib/browser/scroll-lock"; +import { adClickData } from "@/lib/api/ads-actions"; import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types"; /** 对齐 jha-1043 dialogAd.js:签名队列 + session 进度 / 关闭态 */ @@ -284,6 +285,7 @@ export function AdDialogHost({ href={ad.link} target="_blank" rel="noopener noreferrer sponsored" + {...adClickData(ad)} className="block" > {picture} @@ -330,6 +332,7 @@ function NineGridDialog({ ads, onClose }: { ads: Array diff --git a/src/components/common/ads/ad-banners.tsx b/src/components/common/ads/ad-banners.tsx index c1dbeed..7de18d7 100644 --- a/src/components/common/ads/ad-banners.tsx +++ b/src/components/common/ads/ad-banners.tsx @@ -1,4 +1,5 @@ import { DecryptedImage } from "@/components/common/decrypted-image"; +import { adClickData } from "@/lib/api/ads-actions"; import type { AdvertiseInfo, AdvitiseData } from "@/lib/api/types"; import { IMG_PLACEHOLDER } from "@/lib/image/decrypt"; @@ -38,6 +39,7 @@ export function AdBanners({ href={href} target={external ? "_blank" : undefined} rel={external ? "sponsored nofollow noopener noreferrer" : undefined} + {...adClickData(ad)} className="block aspect-[720/150] overflow-hidden rounded-xl" > 0 ? number : 0; +} + +/** + * 广告链接上的上报数据(展开到 上)。 + * id / location 任一无效时返回空对象,点击不上报。 + */ +export function adClickData(ad: Pick) { + const adsId = positiveId(ad.id); + const location = positiveId(ad.location); + if (!adsId || !location) return {}; + return { "data-ads-id": String(adsId), "data-ads-location": String(location) }; +} + +/** 从点击目标向上找最近的广告元素,组装上报参数 */ +export function adClickPayload(target: EventTarget | null): AdClickReq | null { + if (!(target instanceof Element)) return null; + const el = target.closest("[data-ads-id]"); + if (!el) return null; + const adsId = positiveId(el.dataset.adsId); + const location = positiveId(el.dataset.adsLocation); + return adsId && location ? { appId: AD_APP_ID, adsId, location } : null; +} + +/** 客户端:广告点击上报 POST /api/app/ads/click(keepalive,页面跳走也能发出) */ +export function reportAdClick(payload: AdClickReq, token?: string | null) { + return apiRequest("/api/app/ads/click", { + method: "POST", + body: payload, + token, + keepalive: true, + }); +} diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 1b1d264..3370afa 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -30,6 +30,8 @@ type RequestOptions = { token?: string | null; headers?: Record; signal?: AbortSignal; + /** 页面卸载后仍发出(上报类接口) */ + keepalive?: boolean; }; function withClientHeaders(extra?: Record) { @@ -41,7 +43,7 @@ function withClientHeaders(extra?: Record) { } export async function apiRequest(path: string, options: RequestOptions = {}): Promise { - const { method = "GET", body, token, headers = {}, signal } = options; + const { method = "GET", body, token, headers = {}, signal, keepalive } = options; const requestHeaders: Record = withClientHeaders(headers); if (body !== undefined) { @@ -56,6 +58,7 @@ export async function apiRequest(path: string, options: RequestOptions = {}): headers: requestHeaders, body: body === undefined ? undefined : JSON.stringify(body), signal, + keepalive, }); const text = await response.text();