@@ -0,0 +1,303 @@
|
||||
import { navigationIsActive } from '../site-navigation';
|
||||
import { ApiClient } from "./api";
|
||||
import type { Config, Drama, FooterContact, FooterInfoResponse, HomeData, PageData } from "../api/types";
|
||||
import type { SiteContext } from "./context";
|
||||
import { recordList, safeUrl } from "../api/protocol";
|
||||
import { activeBanners, card, empty, escape, hero } from "./render";
|
||||
import { configureSite, copyRoadAddress, downloadUrl } from "./config";
|
||||
import { accountMenuMarkup, bindAccountUI, openAuth, renderMember, renderWallet } from "./account";
|
||||
import { renderPlayer } from "./player";
|
||||
import { contentPath, navigationSection, paginationPath, parseSitePath, searchPath, samePageAddress, topNavigationSection } from "../routing";
|
||||
import { bindPaginationJumps, navigateTo, ROUTE_EVENT } from "./navigation";
|
||||
import { startStartupDialogs, type StartupDialogs } from "./startup";
|
||||
import { createDownloadQr, shouldUseDownloadDialog } from "./download";
|
||||
import { processImage, startImageLoading } from "./images";
|
||||
import { bindHeroSwipe } from "./hero-swipe";
|
||||
import { bindPromotionMarquee } from './promo-marquee';
|
||||
import { scrollToAppCategory } from "./apps";
|
||||
import { renderFeedbackPage } from "./feedback";
|
||||
import { positionSearchDiscovery, searchPicksMarkup, searchSuggestionsMarkup } from "./search";
|
||||
import { infoContactSection } from "../info-pages";
|
||||
import { buildPageHead, clientSeoEnvironment, resolveSeoEnvironment, type SeoBootstrap, type SeoContent, type SeoEnvironment } from '../seo';
|
||||
import { applyPageHead } from './seo';
|
||||
import type { PageBootstrap, SiteSharedData } from '../page-data';
|
||||
|
||||
declare global { interface Window { __dramaApiApp?: Application } }
|
||||
const $=<T extends Element=HTMLElement>(selector:string)=>document.querySelector<T>(selector)!;
|
||||
|
||||
export class Application implements SiteContext {
|
||||
api=new ApiClient();
|
||||
config:Config={};
|
||||
home:HomeData;
|
||||
signal:AbortSignal;
|
||||
private controller=new AbortController();
|
||||
private disposePlayer?:()=>void;
|
||||
private toastTimer?:ReturnType<typeof setTimeout>;
|
||||
private routeKey='';
|
||||
private feature=0;
|
||||
private renderedFeature=0;
|
||||
private heroSequence=0;
|
||||
private disposeHeroFade?:()=>void;
|
||||
private heroTimer?:ReturnType<typeof setInterval>;
|
||||
private disposePromotions?:()=>void;
|
||||
private hot: {hotSearch?:{keyword?:string;name?:string}[];guessLike?:Drama[]}={};
|
||||
private searchTimer?:ReturnType<typeof setTimeout>;
|
||||
private searchSeq=0;
|
||||
private initialized=false;
|
||||
private startupDialogs?:StartupDialogs;
|
||||
private footerContacts:FooterContact[]=[];
|
||||
private seoEnvironment:SeoEnvironment;
|
||||
private seoContent:SeoContent={};
|
||||
private initialSeo?:SeoBootstrap;
|
||||
private initialPage?:PageBootstrap;
|
||||
|
||||
constructor(home:HomeData,private homeLoaded=true,seo?:SeoBootstrap,page?:PageBootstrap,private initialSession?:PageBootstrap['session'],private initialShared?:SiteSharedData){this.initialPage=page;this.home=home;this.signal=this.controller.signal;this.initialSeo=seo;this.seoEnvironment=seo?.environment||resolveSeoEnvironment({},[]);}
|
||||
updateSeo(content:SeoContent){this.seoContent={...this.seoContent,...content};this.syncHead();}
|
||||
private syncHead(){applyPageHead(buildPageHead(location.pathname,location.search,this.seoEnvironment,this.seoContent));}
|
||||
current(){return !this.signal.aborted;}
|
||||
notify(message:string){const el=$('#toast');el.textContent=message;el.classList.remove('hidden');clearTimeout(this.toastTimer);this.toastTimer=setTimeout(()=>el.classList.add('hidden'),2600);}
|
||||
openAuth(mode='login'){openAuth(mode);}
|
||||
navigate(path:string){navigateTo(path);}
|
||||
|
||||
async init(){
|
||||
if(this.initialized)return;this.initialized=true;
|
||||
const session=this.initialSession||this.initialPage?.session;if(session)this.api.seedSession(session.user);
|
||||
startImageLoading();
|
||||
this.bindEvents();this.startupDialogs=startStartupDialogs();this.createAccountMenu();bindAccountUI(this);
|
||||
window.addEventListener('pagehide',event=>this.hidePage(event.persisted));
|
||||
window.addEventListener('pageshow',event=>{if(event.persisted)this.restorePage();});
|
||||
this.api.addEventListener('userchange',()=>this.updateAccount());
|
||||
this.api.addEventListener('expired',()=>{this.updateAccount();this.openAuth();});
|
||||
await this.initializeShared();
|
||||
void this.api.initializeSession().then(()=>this.updateAccount()).catch(error=>this.notify(error.message));
|
||||
this.startFloatingTools();
|
||||
await this.route();
|
||||
}
|
||||
|
||||
private async initializeShared(){
|
||||
const shared=this.initialShared;
|
||||
if(shared){
|
||||
this.footerContacts=recordList<FooterContact>(shared.footer.footerInfo?.contactList);
|
||||
await this.applyConfig(shared.config);
|
||||
// SDK configuration is fetched separately and is never serialized into page HTML.
|
||||
if(shared.trackerConfigured)void this.api.get<Config>('config/info').then(config=>window.tracker?.configure(config)).catch(()=>{});
|
||||
}else{
|
||||
this.api.get<Config>('config/info').then(config=>this.applyConfig(config)).catch(error=>this.notify(error.message));
|
||||
void this.loadFooter();
|
||||
}
|
||||
}
|
||||
|
||||
private async applyConfig(config:Config){
|
||||
this.config=config;this.startupDialogs?.setAdvertisements(config.advitise?.dialogList||[],config.advitise?.nineGridList||[]);
|
||||
this.seoEnvironment=clientSeoEnvironment(this.seoEnvironment,config);
|
||||
if(this.routeKey)this.syncHead();
|
||||
window.tracker?.configure(config);
|
||||
this.refreshInfoContacts();await configureSite(config);
|
||||
}
|
||||
private refreshInfoContacts(){
|
||||
const target=document.querySelector<HTMLElement>('#info-content [data-site-contacts]');
|
||||
if(target)target.outerHTML=infoContactSection(this.config,this.footerContacts);
|
||||
}
|
||||
|
||||
private disposePage(){
|
||||
this.stopHeroAutoplay();this.heroSequence++;this.feature=this.renderedFeature;this.controller.abort();
|
||||
this.disposeHeroFade?.();this.disposePromotions?.();this.disposePlayer?.();
|
||||
}
|
||||
private hidePage(persisted:boolean){
|
||||
const route=parseSitePath(location.pathname,location.search);
|
||||
const interactive=route?.group==='detail'||route?.group==='play'||route?.group==='member'||route?.group==='info'&&['deposit','settings','user-center','feedback'].includes(route.key);
|
||||
if(!persisted||interactive||document.querySelector('dialog[open]')){this.disposePage();return;}
|
||||
this.stopHeroAutoplay();this.heroSequence++;this.feature=this.renderedFeature;this.disposeHeroFade?.();
|
||||
}
|
||||
private restorePage(){
|
||||
if(this.signal.aborted){window.location.reload();return;}
|
||||
this.restartHeroAutoplay();
|
||||
window.tracker?.resumePage();
|
||||
// Restore the document immediately, then refresh the header identity using current cookies.
|
||||
void this.api.revalidateSession().catch(()=>{});
|
||||
}
|
||||
private async selectHero(index:number){
|
||||
const banners=activeBanners(this.home),target=$('#hero');
|
||||
if(!banners.length||!target)return;
|
||||
const selected=(index%banners.length+banners.length)%banners.length;
|
||||
if(selected===this.feature)return;
|
||||
this.feature=selected;
|
||||
const sequence=++this.heroSequence,signal=this.signal;
|
||||
const incoming=document.createElement('div');incoming.innerHTML=hero(banners,selected);
|
||||
const artwork=incoming.querySelector<HTMLImageElement>('.hero-art');
|
||||
if(artwork){
|
||||
const source=artwork.getAttribute('data-src');
|
||||
if(source)await processImage(source,artwork);
|
||||
if(artwork.decode)await artwork.decode().catch(()=>{});
|
||||
}
|
||||
if(sequence!==this.heroSequence||signal.aborted||!target.isConnected)return;
|
||||
this.disposeHeroFade?.();
|
||||
const previous=target.cloneNode(true) as HTMLElement;
|
||||
previous.removeAttribute('id');previous.classList.add('hero-outgoing');
|
||||
previous.setAttribute('aria-hidden','true');previous.inert=true;
|
||||
previous.querySelectorAll('[data-src]').forEach(image=>image.removeAttribute('data-src'));
|
||||
target.replaceChildren(...incoming.childNodes);this.renderedFeature=selected;
|
||||
if(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches||typeof previous.animate!=='function')return;
|
||||
target.append(previous);
|
||||
const animation=previous.animate([{opacity:1},{opacity:0}],{duration:500,easing:'ease-in-out',fill:'forwards'});
|
||||
const dispose=()=>{animation.cancel();previous.remove();if(this.disposeHeroFade===dispose)this.disposeHeroFade=undefined;};
|
||||
this.disposeHeroFade=dispose;
|
||||
void animation.finished.then(dispose,dispose);
|
||||
}
|
||||
private stopHeroAutoplay(){clearInterval(this.heroTimer);this.heroTimer=undefined;}
|
||||
private bindPromotions(){
|
||||
this.disposePromotions?.();
|
||||
const dispose=Array.from(document.querySelectorAll<HTMLElement>('[data-promotion-row]'),root=>bindPromotionMarquee(root,this.signal));
|
||||
this.disposePromotions=()=>dispose.forEach(close=>close());
|
||||
}
|
||||
private restartHeroAutoplay(){
|
||||
this.stopHeroAutoplay();
|
||||
if(this.signal.aborted||location.pathname!=='/'||document.hidden||activeBanners(this.home).length<2)return;
|
||||
this.heroTimer=setInterval(()=>this.selectHero(this.feature+1),3000);
|
||||
}
|
||||
|
||||
async route(){
|
||||
// Every page navigation goes through the server. Re-entry after a mutation reloads fresh HTML.
|
||||
if(this.routeKey){window.location.reload();return;}
|
||||
this.routeKey=location.pathname+location.search;
|
||||
const page=this.initialPage;this.initialPage=undefined;
|
||||
const seo=this.initialSeo;this.initialSeo=undefined;
|
||||
if(seo&&samePageAddress(seo.path,this.routeKey))this.seoContent=seo.content;
|
||||
const route=parseSitePath(location.pathname,location.search);
|
||||
const section=route?navigationSection(route):'',topSection=topNavigationSection(route);
|
||||
document.querySelectorAll<HTMLAnchorElement>('a[data-page],a[data-hg-page],a[data-mobile-route],a[data-info]').forEach(link=>{
|
||||
const selected=link.dataset.siteNav?navigationIsActive(link.getAttribute('href')||'',this.routeKey,link.dataset.siteNav==='top'?'top':'sidebar'):link.dataset.hgPage?link.dataset.hgPage===topSection:link.dataset.info?route?.group==='info'&&link.dataset.info===route.key:(link.dataset.page||link.dataset.mobileRoute)===section;
|
||||
link.classList.toggle('active',selected);
|
||||
if(selected)link.setAttribute('aria-current','page');else link.removeAttribute('aria-current');
|
||||
});
|
||||
window.dispatchEvent(new Event(ROUTE_EVENT));
|
||||
if(!route)return;
|
||||
this.bindPromotions();
|
||||
if(!route.group){
|
||||
bindHeroSwipe($('.home-content'),{enabled:()=>activeBanners(this.home).length>1,onStart:()=>this.stopHeroAutoplay(),onStep:step=>this.selectHero(this.feature+step),onEnd:()=>this.restartHeroAutoplay()},this.signal);
|
||||
this.restartHeroAutoplay();return;
|
||||
}
|
||||
// Error HTML stays visible too; missing initial data must never trigger a client page fetch.
|
||||
if(!page||!samePageAddress(page.path,this.routeKey))return;
|
||||
const pageHref=(value:number)=>paginationPath(this.routeKey,value);
|
||||
if(page.panel==='player'&&page.player){
|
||||
this.disposePlayer=await renderPlayer(this,page.player.detail.id,route.group==='play'?Number(route.value):undefined,false,{initial:page.player,preserveMarkup:true,page:route.page||1,commentSort:new URLSearchParams(location.search).get('commentSort')==='1'?1:2,pageHref:(value,sort)=>paginationPath(contentPath(route.group==='play'?'play':'detail',page.player!.detail,route.group==='play'?Number(route.value):undefined)+location.search,value,{commentSort:sort===2?null:sort})});
|
||||
}else if(page.panel==='search'&&page.search){
|
||||
$('#query').setAttribute('value',page.search.keyword);
|
||||
window.tracker?.search(page.search.keyword,page.search.total,page.search.page);
|
||||
}else if(page.panel==='info'){
|
||||
if(page.info?.member)await renderMember(this,page.info.key,route.page||1,pageHref,page.info.member,true);
|
||||
else if(page.info?.wallet)await renderWallet(this,route.value==='vip',route.page||1,pageHref,page.info.wallet,true);
|
||||
else if(page.info?.feedback)renderFeedbackPage(this,true);
|
||||
}
|
||||
}
|
||||
|
||||
private bindEvents(){
|
||||
bindPaginationJumps();
|
||||
document.addEventListener('visibilitychange',()=>this.restartHeroAutoplay());
|
||||
document.addEventListener('click',event=>{void (async()=>{
|
||||
const el=(event.target as Element).closest<HTMLElement>('button,a');if(!el)return;
|
||||
if(el.dataset.close){(document.getElementById(el.dataset.close) as HTMLDialogElement)?.close();return;}
|
||||
if(el.dataset.authMode){this.openAuth(el.dataset.authMode);return;}
|
||||
if(el.dataset.memberRoute){this.navigate('/member/'+el.dataset.memberRoute+'/');return;}
|
||||
if(el.hasAttribute('data-logout')){await this.api.post('auth/logout',{});this.navigate('/');this.notify('已退出登录');return;}
|
||||
if(el.dataset.sort){const sort=Number(el.dataset.sort);this.navigate(paginationPath(location.pathname+location.search,1,{sort:sort===1?null:sort}));return;}
|
||||
if(el.dataset.appCategory){scrollToAppCategory($('#channel-content'),el.dataset.appCategory);return;}
|
||||
if(el.dataset.feature!==undefined||el.dataset.heroStep){this.selectHero(el.dataset.feature!==undefined?Number(el.dataset.feature):this.feature+Number(el.dataset.heroStep));this.restartHeroAutoplay();return;}
|
||||
if(el.dataset.apiAction==='retry'){window.location.reload();return;}
|
||||
if(el.dataset.accountAction==='coins'){this.navigate('/info/deposit/');return;}
|
||||
if(el.dataset.roadCopy){await copyRoadAddress(el);return;}
|
||||
if(el.hasAttribute('data-app-download')){
|
||||
if(!downloadUrl(this.config)){event.preventDefault();return;}
|
||||
if(shouldUseDownloadDialog(event)){event.preventDefault();await this.download();}
|
||||
return;
|
||||
}
|
||||
if(el.id==='app-copy-link'){const url=downloadUrl(this.config);if(!url)throw new Error('暂未配置下载地址');await navigator.clipboard.writeText(url);$('#app-copy-status').textContent='链接已复制';return;}
|
||||
if(el.id==='app-download-close'){($<HTMLDialogElement>('#app-download-dialog')).close();return;}
|
||||
if(el.id==='floating-history'){this.navigate('/member/history/');return;}
|
||||
if(el.id==='back-to-top'){window.scrollTo({top:0,behavior:'smooth'});return;}
|
||||
if(el.id==='floating-app-close'){$('#page-tool-download').style.display='none';return;}
|
||||
if(el.id==='search-panel-close'){this.closeSearchDiscovery();return;}
|
||||
if(el.id==='search-clear'){localStorage.removeItem('91-search-history');await this.searchDiscovery();return;}
|
||||
if(el.id==='search-picks-refresh'){const picks=await this.api.get<PageData<Drama>>('playlet/all/list',{pageNum:1,pageSize:3,sortType:4});this.hot.guessLike=recordList<Drama>(picks.list).slice(0,3);$('#search-picks-grid').innerHTML=searchPicksMarkup(this.hot.guessLike);return;}
|
||||
|
||||
})().catch(error=>this.notify(error instanceof Error?error.message:'操作失败'));});
|
||||
const form=$<HTMLFormElement>('#search'),query=$<HTMLInputElement>('#query');
|
||||
const panel=$('#search-discovery'),position=()=>positionSearchDiscovery(form,panel);
|
||||
window.addEventListener('resize',position);
|
||||
if(typeof ResizeObserver!=='undefined'){const observer=new ResizeObserver(position);observer.observe(panel);this.signal.addEventListener('abort',()=>observer.disconnect(),{once:true});}
|
||||
form.addEventListener('submit',event=>{event.preventDefault();const text=query.value.trim();if(!text)return;try{const history:string[]=JSON.parse(localStorage.getItem('91-search-history')||'[]');localStorage.setItem('91-search-history',JSON.stringify([text,...history.filter(v=>v!==text)].slice(0,10)));}catch{}this.closeSearchDiscovery();this.navigate(searchPath(text));});
|
||||
query.addEventListener('focus',()=>{void this.searchDiscovery();});
|
||||
query.addEventListener('click',()=>{if(panel.hidden)void this.searchDiscovery();});
|
||||
query.addEventListener('input',()=>{clearTimeout(this.searchTimer);this.searchTimer=setTimeout(()=>{void this.searchDiscovery();},250);});
|
||||
query.addEventListener('keydown',event=>{if(event.key==='Escape')this.closeSearchDiscovery();});
|
||||
document.addEventListener('click',event=>{if(!form.contains(event.target as Node))this.closeSearchDiscovery();});
|
||||
}
|
||||
|
||||
private async download(){
|
||||
const url=downloadUrl(this.config);if(!url)return;
|
||||
const dialog=$<HTMLDialogElement>('#app-download-dialog');if(!dialog.open)dialog.showModal();
|
||||
const qr=await createDownloadQr(url);
|
||||
if(dialog.open&&downloadUrl(this.config)===url){const image=$<HTMLImageElement>('#app-platform-qr');image.src=qr;image.alt='91短剧 App 下载二维码';}
|
||||
}
|
||||
private createAccountMenu(){
|
||||
const panel=document.createElement('div');panel.id='profile-popover';panel.hidden=true;panel.setAttribute('aria-label','账户菜单');document.body.append(panel);
|
||||
const entry=$('#account-entry');entry.addEventListener('click',()=>{this.updateAccount();const rect=entry.getBoundingClientRect();panel.style.right=Math.max(12,innerWidth-rect.right)+'px';panel.style.top=rect.bottom+12+'px';panel.hidden=!panel.hidden;entry.setAttribute('aria-expanded',String(!panel.hidden));});
|
||||
document.addEventListener('click',event=>{if(!entry.contains(event.target as Node)&&!panel.contains(event.target as Node)){panel.hidden=true;entry.setAttribute('aria-expanded','false');}});
|
||||
window.addEventListener('scroll',()=>{panel.hidden=true;},{passive:true});
|
||||
this.updateAccount();
|
||||
}
|
||||
private updateAccount(){
|
||||
const user=this.api.user;const name=user?.nickName||user?.accountNo||'游客';
|
||||
$('#account-entry strong').textContent=name;$('#account-status').textContent=user?(user.isRegister||user.type===1?'已登录':'游客账号'):'未登录';
|
||||
const avatar=$<HTMLImageElement>('#account-entry img');void processImage(safeUrl(user?.avatar)||'/icon.svg',avatar,'img','/icon.svg');
|
||||
$('.coin-value').textContent=user?String(user.recharge||0):'—';$('.coin-entry').setAttribute('aria-label',user?`金币余额 ${user.recharge||0}`:'金币余额,登录后查看');
|
||||
const panel=document.getElementById('profile-popover');if(!panel)return;
|
||||
panel.innerHTML=accountMenuMarkup(user);
|
||||
}
|
||||
|
||||
private async loadDiscovery(){
|
||||
try{
|
||||
this.hot=await this.api.get('playlet/hot-like');
|
||||
$('#popular-search-tags').innerHTML=(this.hot.hotSearch||[]).map((item,i)=>{const text=item.keyword||item.name||'';return `<a class="popular-search-tag popular-search-tag--${i%5}" href="${escape(searchPath(text))}">${escape(text)}</a>`;}).join('');
|
||||
}catch{}
|
||||
}
|
||||
private closeSearchDiscovery(){this.searchSeq++;$('#search-discovery').hidden=true;$('#query').setAttribute('aria-expanded','false');}
|
||||
private async searchDiscovery(){
|
||||
const seq=++this.searchSeq;const query=$<HTMLInputElement>('#query').value.trim();const panel=$('#search-discovery');panel.hidden=false;
|
||||
$('#query').setAttribute('aria-expanded','true');positionSearchDiscovery($('#search'),panel);
|
||||
if(!Object.keys(this.hot).length)await this.loadDiscovery();
|
||||
if(seq!==this.searchSeq||query!==$<HTMLInputElement>('#query').value.trim())return;
|
||||
let history:string[]=[];try{history=JSON.parse(localStorage.getItem('91-search-history')||'[]');}catch{}
|
||||
$('#search-history-tags').innerHTML=(Array.isArray(history)?history.filter(text=>typeof text==='string').slice(0,10):[]).map(text=>`<a href="${escape(searchPath(text))}">${escape(text)}</a>`).join('')||'<span class="search-discovery-status">暂无搜索记录</span>';
|
||||
$('#search-picks-grid').innerHTML=searchPicksMarkup(this.hot.guessLike);
|
||||
positionSearchDiscovery($('#search'),panel);
|
||||
try{
|
||||
const suggestions=query?await this.api.get<{list?:unknown[];suggestions?:unknown[]}>('playlet/search/suggest',{keyword:query}):null;
|
||||
if(seq!==this.searchSeq||query!==$<HTMLInputElement>('#query').value.trim())return;
|
||||
const list=suggestions?.list||suggestions?.suggestions||this.hot.hotSearch||[];
|
||||
$('#search-suggestion-title').textContent=query?'搜索联想':'热搜榜';
|
||||
$('#search-suggestion-list').innerHTML=searchSuggestionsMarkup(list,query);
|
||||
}catch(error){if(seq===this.searchSeq)$('#search-suggestion-list').innerHTML=`<p class="search-discovery-status">${escape(error instanceof Error?error.message:'搜索建议加载失败')}</p>`;}
|
||||
finally{if(seq===this.searchSeq)positionSearchDiscovery($('#search'),panel);}
|
||||
}
|
||||
|
||||
private startFloatingTools(){
|
||||
const tools=$('#page-tools');const visibility=()=>{const visible=window.scrollY>240;tools.classList.toggle('is-visible',visible);tools.inert=!visible;tools.setAttribute('aria-hidden',String(!visible));};
|
||||
window.addEventListener('scroll',visibility,{passive:true});visibility();
|
||||
}
|
||||
private async loadFooter(){
|
||||
try{
|
||||
const data=await this.api.get<FooterInfoResponse>('config/footer/info');
|
||||
const footer=data?.footerInfo;if(!footer)return;
|
||||
this.footerContacts=recordList<FooterContact>(footer.contactList);this.refreshInfoContacts();
|
||||
if(footer.brand?.content)$('.hg-footer-original-slogan').textContent=footer.brand.content;
|
||||
}catch{}
|
||||
}
|
||||
}
|
||||
|
||||
export function startApplication(home:HomeData,homeLoaded=true,seo?:SeoBootstrap,page?:PageBootstrap,session?:PageBootstrap['session'],shared?:SiteSharedData){
|
||||
if(window.__dramaApiApp)return;
|
||||
const app=new Application(home,homeLoaded,seo,page,session,shared);window.__dramaApiApp=app;
|
||||
void app.init().catch(error=>{app.notify(error instanceof Error?error.message:'页面初始化失败');});
|
||||
}
|
||||
Reference in New Issue
Block a user