@@ -0,0 +1,319 @@
|
||||
import type { Advertisement } from '../api/types';
|
||||
import { recordList, safeUrl } from '../api/protocol';
|
||||
|
||||
const AGE_KEY = 'hg_age_ok';
|
||||
const DISMISSED_KEY = 'dialogAdDismissed';
|
||||
const PROGRESS_KEY = 'dialogAdProgress';
|
||||
const GRID_DISMISSED_KEY = 'gridAdDismissed';
|
||||
|
||||
function advertisements(list: unknown): Advertisement[] {
|
||||
return recordList<Advertisement>(list).map(item => ({...item,
|
||||
id: String(item.advertiseCode || '').trim() || String(item.id || '').trim(),
|
||||
images: safeUrl(item.images), link: safeUrl(item.link), title: String(item.title || '').trim(),
|
||||
})).filter(item => item.images);
|
||||
}
|
||||
|
||||
type SessionStore = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
||||
export interface StartupView {
|
||||
showAge(): void;
|
||||
closeAge(): void;
|
||||
prepareAdvertisement(): void;
|
||||
loadAdvertisement(item: Advertisement, signal: AbortSignal): Promise<void>;
|
||||
showAdvertisement(item: Advertisement): void;
|
||||
hideAdvertisement(): void;
|
||||
loadGrid(items: Advertisement[], signal: AbortSignal): Promise<Advertisement[]>;
|
||||
showGrid(items: Advertisement[]): void;
|
||||
hideGrid(): void;
|
||||
}
|
||||
|
||||
/** Session keys, signature version and resume behavior for JHA-1047. */
|
||||
export class StartupDialogs {
|
||||
private accepted = false;
|
||||
private items: Advertisement[] = [];
|
||||
private signature = '';
|
||||
private index = 0;
|
||||
private readyIndex = -1;
|
||||
private visibleIndex = -1;
|
||||
private displayed = false;
|
||||
private generation = 0;
|
||||
private loading?: AbortController;
|
||||
private advertisementPending = false;
|
||||
private gridSignature = '';
|
||||
private gridItems: Advertisement[] = [];
|
||||
private gridLoading?: AbortController;
|
||||
private gridVisible = false;
|
||||
private gridDismissed = false;
|
||||
|
||||
constructor(private storage: SessionStore, private view: StartupView, confirmed = false) {
|
||||
this.accepted = confirmed || this.read(AGE_KEY) === '1';
|
||||
if (this.accepted) view.closeAge(); else view.showAge();
|
||||
}
|
||||
|
||||
private read(key: string) {try {return this.storage.getItem(key) || '';} catch {return '';}}
|
||||
private write(key: string, value: string) {try {this.storage.setItem(key, value);} catch {}}
|
||||
private remove(key: string) {try {this.storage.removeItem(key);} catch {}}
|
||||
|
||||
confirmAge() {
|
||||
this.write(AGE_KEY, '1');
|
||||
this.accepted = true;
|
||||
this.view.closeAge();
|
||||
this.showReady();
|
||||
this.showGridReady();
|
||||
}
|
||||
|
||||
setAdvertisements(list: Advertisement[], gridList: Advertisement[] = []) {
|
||||
const items = advertisements(list);
|
||||
const signature = items.length ? '2|' + items.map((item, index) => item.id || item.images || String(index)).join('|') : '';
|
||||
const changed = signature !== this.signature;
|
||||
if (changed) {
|
||||
this.generation++;
|
||||
this.loading?.abort();
|
||||
this.view.hideAdvertisement();
|
||||
this.view.hideGrid();
|
||||
this.gridVisible = false;
|
||||
this.items = items;
|
||||
this.signature = signature;
|
||||
this.readyIndex = this.visibleIndex = -1;
|
||||
this.displayed = false;
|
||||
this.advertisementPending = !!items.length && this.read(DISMISSED_KEY) !== signature;
|
||||
}
|
||||
this.setGridAdvertisements(gridList);
|
||||
if (changed && this.advertisementPending) void this.showItem(this.readProgress());
|
||||
this.showGridReady();
|
||||
}
|
||||
|
||||
private setGridAdvertisements(list: Advertisement[]) {
|
||||
const items = advertisements(list);
|
||||
const signature = items.length ? JSON.stringify(items.map(item => [item.id, item.images, item.link, item.title])) : '';
|
||||
if (signature === this.gridSignature) return;
|
||||
this.gridLoading?.abort();
|
||||
this.view.hideGrid();
|
||||
this.gridVisible = false;
|
||||
this.gridItems = [];
|
||||
this.gridSignature = signature;
|
||||
this.gridDismissed = this.read(GRID_DISMISSED_KEY) === signature;
|
||||
if (!items.length || this.gridDismissed) return;
|
||||
const controller = this.gridLoading = new AbortController();
|
||||
void this.view.loadGrid(items, controller.signal).then(loaded => {
|
||||
if (controller.signal.aborted) return;
|
||||
this.gridItems = loaded;
|
||||
this.showGridReady();
|
||||
}).catch(() => { /* A failed grid must never block the page or the other dialogs. */ });
|
||||
}
|
||||
|
||||
private showGridReady() {
|
||||
if (!this.accepted || this.advertisementPending || this.gridVisible || this.gridDismissed || !this.gridItems.length) return;
|
||||
this.gridVisible = true;
|
||||
this.view.showGrid(this.gridItems);
|
||||
}
|
||||
|
||||
dismissGrid() {
|
||||
if (!this.gridVisible) return;
|
||||
this.gridDismissed = true;
|
||||
this.gridVisible = false;
|
||||
this.write(GRID_DISMISSED_KEY, this.gridSignature);
|
||||
this.view.hideGrid();
|
||||
}
|
||||
|
||||
private readProgress() {
|
||||
try {
|
||||
const progress = JSON.parse(this.read(PROGRESS_KEY));
|
||||
const value = Number(progress?.index);
|
||||
return progress?.signature === this.signature && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
|
||||
} catch {return 0;}
|
||||
}
|
||||
|
||||
private async showItem(index: number) {
|
||||
const generation = ++this.generation;
|
||||
this.loading?.abort();
|
||||
this.readyIndex = this.visibleIndex = -1;
|
||||
this.index = index;
|
||||
if (index >= this.items.length) {this.finish(); return;}
|
||||
this.write(PROGRESS_KEY, JSON.stringify({signature: this.signature, index}));
|
||||
this.view.prepareAdvertisement();
|
||||
const controller = this.loading = new AbortController();
|
||||
try {
|
||||
await this.view.loadAdvertisement(this.items[index], controller.signal);
|
||||
if (controller.signal.aborted || generation !== this.generation) return;
|
||||
this.readyIndex = index;
|
||||
this.showReady();
|
||||
} catch {
|
||||
if (!controller.signal.aborted && generation === this.generation) void this.showItem(index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private showReady() {
|
||||
if (!this.accepted || this.readyIndex < 0 || this.visibleIndex === this.readyIndex) return;
|
||||
this.visibleIndex = this.readyIndex;
|
||||
this.displayed = true;
|
||||
this.view.showAdvertisement(this.items[this.readyIndex]);
|
||||
}
|
||||
|
||||
nextAdvertisement() {
|
||||
if (!this.accepted || this.visibleIndex < 0) return;
|
||||
void this.showItem(this.visibleIndex + 1);
|
||||
}
|
||||
|
||||
private finish() {
|
||||
if (this.displayed) this.write(DISMISSED_KEY, this.signature); else this.remove(DISMISSED_KEY);
|
||||
this.remove(PROGRESS_KEY);
|
||||
this.view.hideAdvertisement();
|
||||
this.readyIndex = this.visibleIndex = -1;
|
||||
this.advertisementPending = false;
|
||||
this.showGridReady();
|
||||
}
|
||||
}
|
||||
|
||||
export function loadStartupImage(image: HTMLImageElement, source: string, signal: AbortSignal, timeoutMs = 12000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => finish(new Error('广告图片加载超时')), timeoutMs);
|
||||
function finish(error?: Error) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
image.removeEventListener('load', loaded);
|
||||
image.removeEventListener('error', failed);
|
||||
signal.removeEventListener('abort', aborted);
|
||||
if (error) reject(error); else resolve();
|
||||
}
|
||||
function loaded() {finish(image.naturalWidth > 0 ? undefined : new Error('广告图片无法解码'));}
|
||||
function failed() {finish(new Error('广告图片加载失败'));}
|
||||
function aborted() {finish(new DOMException('Aborted', 'AbortError'));}
|
||||
function check() {if (image.complete) loaded();}
|
||||
image.addEventListener('load', loaded);
|
||||
image.addEventListener('error', failed);
|
||||
signal.addEventListener('abort', aborted, {once: true});
|
||||
if (signal.aborted) {aborted(); return;}
|
||||
try {
|
||||
if (window.processImage) {
|
||||
Promise.resolve(window.processImage(source, image)).then(result => {
|
||||
if (!result) finish(new Error('广告图片解码失败')); else check();
|
||||
}).catch(error => finish(error instanceof Error ? error : new Error('广告图片解码失败')));
|
||||
} else {image.src = source; check();}
|
||||
} catch (error) {finish(error instanceof Error ? error : new Error('广告图片加载失败'));}
|
||||
});
|
||||
}
|
||||
|
||||
export function startStartupDialogs(): StartupDialogs {
|
||||
const age = document.getElementById('startup-age') as HTMLDialogElement;
|
||||
const ad = document.getElementById('startup-ad') as HTMLDialogElement;
|
||||
const grid = document.getElementById('startup-grid') as HTMLDialogElement;
|
||||
const promotions = grid.querySelector<HTMLElement>('.startup-promotions')!;
|
||||
let gridLinks = new Map<Advertisement, HTMLAnchorElement>();
|
||||
let image = ad.querySelector<HTMLImageElement>('.startup-ad-image')!;
|
||||
const media = ad.querySelector<HTMLAnchorElement>('.startup-ad-media')!;
|
||||
const loading = ad.querySelector<HTMLElement>('.startup-ad-loading')!;
|
||||
const close = ad.querySelector<HTMLButtonElement>('.startup-close')!;
|
||||
function track(eventType: string, element: HTMLElement = media) {
|
||||
try {
|
||||
if (eventType === 'close') {
|
||||
if (window.tracker?.advertising) window.tracker.advertising(element, eventType);
|
||||
else document.dispatchEvent(new CustomEvent('dialog-ad:track', {detail: {element, eventType}}));
|
||||
} else if (window.tracker?.externalAd) window.tracker.externalAd(element, eventType);
|
||||
else document.dispatchEvent(new CustomEvent('external-ad:track', {detail: {element, eventType}}));
|
||||
} catch { /* Tracking does not interrupt display or dismissal. */ }
|
||||
}
|
||||
const view: StartupView = {
|
||||
showAge() {if (!age.open) age.showModal();},
|
||||
closeAge() {
|
||||
const wasOpen = age.open;
|
||||
if (wasOpen) age.close();
|
||||
document.documentElement.classList.add('eighteen-ok');
|
||||
if (wasOpen) document.dispatchEvent(new CustomEvent('eighteen:closed'));
|
||||
},
|
||||
prepareAdvertisement() {media.hidden = true; loading.hidden = false; close.disabled = true; ad.setAttribute('aria-busy', 'true');},
|
||||
async loadAdvertisement(item, signal) {
|
||||
const pending = image.cloneNode(false) as HTMLImageElement;
|
||||
pending.removeAttribute('src');
|
||||
pending.setAttribute('fetchpriority', 'high');
|
||||
await loadStartupImage(pending, item.images, signal);
|
||||
if (signal.aborted) return;
|
||||
image.replaceWith(pending);
|
||||
image = pending;
|
||||
},
|
||||
showAdvertisement(item) {
|
||||
image.alt = image.title = item.title?.trim() || '推荐广告';
|
||||
media.setAttribute('aria-label', item.title || '查看广告');
|
||||
if (item.link) media.href = item.link; else media.removeAttribute('href');
|
||||
media.dataset.adId = item.advertiseCode || item.id || item.images;
|
||||
media.dataset.adClickable = item.link ? '1' : '0';
|
||||
media.dataset.adLocation = String(item.location || '');
|
||||
media.dataset.adSlotKey = item.advertiseLocationCode || String(item.location || '');
|
||||
media.dataset.adSlotName = item.advertiseLocationName || '弹窗广告位';
|
||||
media.dataset.adPlacement = 'startup-ad';
|
||||
media.hidden = false; loading.hidden = true; close.disabled = false;
|
||||
ad.removeAttribute('aria-busy');
|
||||
ad.setAttribute('aria-label', item.title || '广告推荐');
|
||||
if (!ad.open) ad.showModal();
|
||||
track('show');
|
||||
},
|
||||
hideAdvertisement() {if (ad.open) {if (!media.hidden) track('close'); ad.close();} media.hidden = true; loading.hidden = true; ad.removeAttribute('aria-busy');},
|
||||
async loadGrid(items, signal) {
|
||||
const links = new Map<Advertisement, HTMLAnchorElement>();
|
||||
const loaded = await Promise.all(items.map(async item => {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'startup-promotion';
|
||||
link.target = '_blank';
|
||||
link.rel = 'sponsored nofollow noopener noreferrer';
|
||||
link.setAttribute('aria-label', item.title || '查看推荐应用');
|
||||
if (item.link) link.href = item.link;
|
||||
link.dataset.trackAd = 'external';
|
||||
link.dataset.adPlacement = 'startup-grid';
|
||||
link.dataset.adType = 'icon';
|
||||
link.dataset.adId = item.advertiseCode || item.id || item.images;
|
||||
link.dataset.adClickable = item.link ? '1' : '0';
|
||||
link.dataset.adLocation = String(item.location || '');
|
||||
link.dataset.adSlotKey = item.advertiseLocationCode || String(item.location || 'nineGridList');
|
||||
link.dataset.adSlotName = item.advertiseLocationName || '宫格弹窗广告位';
|
||||
const icon = document.createElement('img');
|
||||
icon.width = icon.height = 80;
|
||||
icon.alt = item.title || '推荐应用';
|
||||
icon.decoding = 'async';
|
||||
const name = document.createElement('span');
|
||||
name.textContent = item.title || '推荐应用';
|
||||
link.append(icon, name);
|
||||
try {
|
||||
await loadStartupImage(icon, item.images, signal);
|
||||
links.set(item, link);
|
||||
return item;
|
||||
} catch {return null;}
|
||||
}));
|
||||
if (signal.aborted) return [];
|
||||
gridLinks = links;
|
||||
return loaded.filter((item): item is Advertisement => item !== null);
|
||||
},
|
||||
showGrid(items) {
|
||||
promotions.replaceChildren(...items.map(item => gridLinks.get(item)!));
|
||||
promotions.scrollTop = 0;
|
||||
if (!grid.open) grid.showModal();
|
||||
document.dispatchEvent(new Event('tracker:refresh'));
|
||||
},
|
||||
hideGrid() {if (grid.open) {promotions.querySelectorAll<HTMLElement>('.startup-promotion').forEach(link => track('close', link)); grid.close();}},
|
||||
};
|
||||
const storage: SessionStore = {
|
||||
getItem: key => window.sessionStorage.getItem(key),
|
||||
setItem: (key, value) => window.sessionStorage.setItem(key, value),
|
||||
removeItem: key => window.sessionStorage.removeItem(key),
|
||||
};
|
||||
const startup = new StartupDialogs(storage, view, document.documentElement.classList.contains('eighteen-ok'));
|
||||
age.addEventListener('cancel', event => event.preventDefault());
|
||||
age.querySelector('.startup-age-confirm')!.addEventListener('click', () => startup.confirmAge());
|
||||
age.querySelectorAll('.startup-age-leave,.startup-close').forEach(button => button.addEventListener('click', () => location.assign('https://www.google.com')));
|
||||
function nextAdvertisement() {if (ad.open && !media.hidden) track('close'); startup.nextAdvertisement();}
|
||||
close.addEventListener('click', nextAdvertisement);
|
||||
ad.addEventListener('cancel', event => {event.preventDefault(); nextAdvertisement();});
|
||||
grid.querySelector('.startup-close')!.addEventListener('click', () => startup.dismissGrid());
|
||||
grid.addEventListener('cancel', event => {event.preventDefault(); startup.dismissGrid();});
|
||||
promotions.addEventListener('click', event => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (target?.closest('.startup-promotion[href]')) startup.dismissGrid();
|
||||
});
|
||||
[age, ad, grid].forEach(dialog => {
|
||||
dialog.addEventListener('keydown', event => {if (event.key === 'Tab') dialog.classList.add('startup-keyboard-focus');});
|
||||
dialog.addEventListener('pointerdown', () => dialog.classList.remove('startup-keyboard-focus'));
|
||||
dialog.addEventListener('close', () => dialog.classList.remove('startup-keyboard-focus'));
|
||||
});
|
||||
media.addEventListener('click', () => {if (ad.open && !media.hidden && media.hasAttribute('href')) track('click');});
|
||||
return startup;
|
||||
}
|
||||
Reference in New Issue
Block a user