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();