Files
web_hgdjc_h5/src/lib/client/startup.ts
T
谢宇宁andClaude Opus 5.5 f65d10e998 Initial commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:07:59 +07:00

320 lines
14 KiB
TypeScript

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-1050. */
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;
}