广告点击添加

This commit is contained in:
谢宇宁
2026-10-29 16:37:54 +07:00
parent 654257a2d2
commit faf1b3d210
7 changed files with 97 additions and 1 deletions
+2
View File
@@ -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<"/">) {
<SiteShell>{children}</SiteShell>
<GuestAuthBootstrap />
<AuthUnauthorizedBridge />
<AdClickReporter />
<LoginDialogHost />
<Suspense fallback={null}>
<DownloadDialogSlot />
+29
View File
@@ -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;
}
+3
View File
@@ -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<AdvertiseInfo & { images:
target="_blank"
rel="sponsored nofollow noopener noreferrer"
title={item.title || undefined}
{...adClickData(item)}
onClick={onClose}
className="flex flex-col items-center gap-2"
>
+3
View File
@@ -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"
>
<DecryptedImage
@@ -68,6 +70,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"
>
<DecryptedImage
+2
View File
@@ -1,5 +1,6 @@
import { AdIconMarquee } from "@/components/common/ad-icon-marquee";
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";
@@ -45,6 +46,7 @@ export function AdSquares({ slot, variant = "pc", items, advitise, className = "
target={external ? "_blank" : undefined}
rel={external ? "sponsored nofollow noopener noreferrer" : undefined}
title={ad.title || undefined}
{...adClickData(ad)}
className={`group flex shrink-0 flex-col items-center gap-1.5 ${isH5 ? "w-[72px]" : "w-[120px]"}`}
>
<DecryptedImage
+54
View File
@@ -0,0 +1,54 @@
import { apiRequest } from "./client";
import type { AdvertiseInfo } from "./types";
/** 广告服务里本站的 appId */
export const AD_APP_ID = 12;
/** POST /api/app/ads/click 请求体(后端三个字段均必填,int64) */
export type AdClickReq = {
appId: number;
adsId: number;
location: number;
};
/** 后端 id 为数字字符串(如 "1138200002");非正整数视为无效 */
function positiveId(value: unknown) {
const number =
typeof value === "number"
? value
: typeof value === "string" && /^\d+$/.test(value.trim())
? Number(value.trim())
: NaN;
return Number.isSafeInteger(number) && number > 0 ? number : 0;
}
/**
* 广告链接上的上报数据(展开到 <a> 上)。
* id / location 任一无效时返回空对象,点击不上报。
*/
export function adClickData(ad: Pick<AdvertiseInfo, "id" | "location">) {
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<HTMLElement>("[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<null>("/api/app/ads/click", {
method: "POST",
body: payload,
token,
keepalive: true,
});
}
+4 -1
View File
@@ -30,6 +30,8 @@ type RequestOptions = {
token?: string | null;
headers?: Record<string, string>;
signal?: AbortSignal;
/** 页面卸载后仍发出(上报类接口) */
keepalive?: boolean;
};
function withClientHeaders(extra?: Record<string, string>) {
@@ -41,7 +43,7 @@ function withClientHeaders(extra?: Record<string, string>) {
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = "GET", body, token, headers = {}, signal } = options;
const { method = "GET", body, token, headers = {}, signal, keepalive } = options;
const requestHeaders: Record<string, string> = withClientHeaders(headers);
if (body !== undefined) {
@@ -56,6 +58,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
headers: requestHeaders,
body: body === undefined ? undefined : JSON.stringify(body),
signal,
keepalive,
});
const text = await response.text();