PageSourceSearch

https://xoigac.top/js/spa-router.js?v=dwhRvRTKNW3gDRjkITc68k3snHSqhN2jwIL1Le2mwmw

js xoigac.top collected 2026-10-10 02:22:02 UTC 49,107 bytes, 1,252 lines download raw bytes

1/**
2 * SPA 路由器 - 实现无刷新页面切换
3 * 点击导航时只替换主内容区域,不整页刷新
4 */
5(function() {
6    'use strict';
7
8    const MAIN_CONTENT_ID = 'main-content';
9    const TRANSITION_DURATION = 300;
10    const SKELETON_DELAY_MS = 320;
11    const ARTICLE_LIST_PATHS = [
12        '/tin-tuc-bong-da',
13        '/news',
14        '/xinwen',
15        '/nhan-dinh-bong-da',
16        '/analysis',
17        '/fenxi',
18        '/articles',
19        '/article/news',
20        '/article/analysis'
21    ];
22    
23    // 需要 SPA 处理的链接选择器
24    const SPA_LINK_SELECTORS = [
25        '.navbar-nav .nav-link',
26        '.mobile-nav .nav-link', 
27        '.footer-links a',
28        '.news-card',
29        '.match-card a',
30        '.league-item',
31        'a[data-spa]'
32    ];
33
34    // 排除的链接(登录、注销、外部链接等)
35    const EXCLUDE_PATTERNS = [
36        '/Account/',
37        '/Admin/',
38        '/admin/',
39        'logout',
40        'login',
41        'register',
42        '#',
43        'javascript:',
44        'mailto:',
45        'tel:'
46    ];
47
48    let isNavigating = false;
49    let abortController = null;
50    let skeletonTimer = null;
51    let skeletonShown = false;
52    let detailNavLock = false;
53
54    /**
55     * 初始化 SPA 路由
56     */
57    function init() {
58        // 比赛详情在捕获阶段拦截,避免 SPA 先改 URL 却不换页面。
59        document.addEventListener('click', handleMatchDetailClick, true);
60        document.addEventListener('click', handleLinkClick);
61        
62        // 监听浏览器前进/后退
63        window.addEventListener('popstate', handlePopState);
64        resetMatchDetailScroll();
65        window.addEventListener('pageshow', resetMatchDetailScroll);
66        ensureMatchListSearch();
67        ensureFooterDirectoryLinks();
68        localizeDirectoryPagination();
69        alignListSearch();
70        document.addEventListener('keydown', onListSearchEnter, true);
71        document.addEventListener('keypress', onListSearchEnter, true);
72        document.addEventListener('focusin', holdSearchScroll);
73        document.addEventListener('focusout', releaseSearchScroll);
74        document.addEventListener('input', rememberListFilterScroll, true);
75        document.addEventListener('input', restoreListFilterScroll);
76        document.addEventListener('spa:loaded', ensureMatchListSearch);
77        document.addEventListener('spa:loaded', ensureFooterDirectoryLinks);
78        document.addEventListener('spa:loaded', localizeDirectoryPagination);
79        document.addEventListener('spa:loaded', alignListSearch);
80        window.addEventListener('load', alignListSearch);
81        
82        console.log('SPA Router initialized');
83    }
84
85    function footerDirectoryLabels() {
86        const lang = String(window.__currentLang || document.documentElement.lang || 'vi').toLowerCase();
87        if (lang.indexOf('zh') === 0) return { league: '联赛中心', team: '球队中心' };
88        if (lang.indexOf('en') === 0) return { league: 'League Center', team: 'Team Center' };
89        return { league: 'Trung tâm giải đấu', team: 'Trung tâm câu lạc bộ' };
90    }
91
92    function ensureFooterDirectoryLinks() {
93        const list = document.querySelector('.footer .footer-links, .footer-links');
94        if (!list) return;
95        const labels = footerDirectoryLabels();
96        const items = [
97            { href: window.__leagueRoutePrefix || '/giai-dau', text: labels.league },
98            { href: window.__teamRoutePrefix || '/doi-bong', text: labels.team }
99        ];
100        items.forEach(function (item) {
101            const href = item.href || '';
102            if (!href) return;
103            const exists = Array.prototype.some.call(list.querySelectorAll('a'), function (link) {
104                return link.getAttribute('href') === href;
105            });
106            if (exists) return;
107            const li = document.createElement('li');
108            const a = document.createElement('a');
109            a.href = href;
110            a.textContent = item.text;
111            li.appendChild(a);
112            list.appendChild(li);
113        });
114    }
115
116    function matchListCopy() {
117        const lang = String(window.__currentLang || document.documentElement.lang || 'vi').toLowerCase();
118        if (lang.indexOf('zh') === 0) {
119            return { search: '搜索', placeholder: '搜索球队或联赛...', empty: '未找到结果', clear: '清除' };
120        }
121        if (lang.indexOf('en') === 0) {
122            return { search: 'Search', placeholder: 'Search teams or leagues...', empty: 'No results found', clear: 'Clear' };
123        }
124        return { search: 'Tìm kiếm', placeholder: 'Tìm kiếm đội bóng hoặc giải đấu...', empty: 'Không tìm thấy kết quả', clear: 'Xóa' };
125    }
126
127    function ensureMatchListSearch() {
128        const page = document.querySelector('.match-list-page');
129        if (!page) return;
130        const row = page.querySelector('.filter-bar-row');
131        if (!row) return;
132
133        const copy = matchListCopy();
134        let tools = row.querySelector('.filter-bar-tools');
135        const dropdown = row.querySelector('.schedule-category-dropdown');
136        if (!tools) {
137            tools = document.createElement('div');
138            tools.className = 'filter-bar-tools';
139            if (dropdown && dropdown.parentNode) {
140                dropdown.parentNode.insertBefore(tools, dropdown);
141                tools.appendChild(dropdown);
142            } else {
143                row.appendChild(tools);
144            }
145        }
146
147        if (!document.getElementById('matchListSearch')) {
148            const wrap = document.createElement('div');
149            wrap.className = 'match-list-search';
150            wrap.innerHTML =
151                '<label class="visually-hidden" for="matchListSearch"></label>' +
152                '<svg class="match-list-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.5-3.5"></path></svg>' +
153                '<input id="matchListSearch" class="match-list-search-input" type="search" autocomplete="off" enterkeyhint="search">' +
154                '<button type="button" class="match-list-search-clear" id="matchListSearchClear" hidden>&times;</button>';
155            wrap.querySelector('label').textContent = copy.search;
156            wrap.querySelector('input').placeholder = copy.placeholder;
157            wrap.querySelector('button').setAttribute('aria-label', copy.clear);
158            const dropdownInTools = tools.querySelector('.schedule-category-dropdown');
159            if (dropdownInTools) tools.insertBefore(wrap, dropdownInTools);
160            else tools.appendChild(wrap);
161        }
162
163        if (!document.getElementById('matchListSearchEmpty')) {
164            const empty = document.createElement('div');
165            empty.id = 'matchListSearchEmpty';
166            empty.className = 'schedule-sport-empty';
167            empty.hidden = true;
168            empty.textContent = copy.empty;
169            const bar = page.querySelector('.filter-bar');
170            if (bar && bar.parentNode) bar.parentNode.insertBefore(empty, bar.nextSibling);
171        }
172
173        page.querySelectorAll('nav .pagination').forEach(function (list) {
174            const nav = list.closest('nav');
175            if (!nav) return;
176            nav.classList.add('match-list-paging');
177            const next = nav.nextElementSibling;
178            if (next && next.classList.contains('text-muted')) next.classList.add('match-list-paging');
179        });
180
181        bindMatchListSearch(page);
182    }
183
184    function matchRowHaystack(row) {
185        const named = Array.prototype.map.call(
186            row.querySelectorAll('.mx-team-name, .mx-streamer-name, .lg-name, .beauty-schedule-title, .beauty-schedule-streamer'),
187            function (el) { return el.textContent || ''; }
188        ).join(' ');
189        return ((row.getAttribute('data-search') || '') + ' ' + named).toLowerCase();
190    }
191
192    function bindMatchListSearch(page) {
193        const input = document.getElementById('matchListSearch');
194        const clearBtn = document.getElementById('matchListSearchClear');
195        const empty = document.getElementById('matchListSearchEmpty');
196        if (!page || !input || input.dataset.bound === '1') return;
197        input.dataset.bound = '1';
198
199        function applyFilter() {
200            const query = input.value.trim().toLowerCase();
201            if (clearBtn) clearBtn.hidden = query.length === 0;
202            let visible = 0;
203
204            page.querySelectorAll('.mx-row').forEach(function (row) {
205                const show = !query || matchRowHaystack(row).indexOf(query) !== -1;
206                row.classList.toggle('is-search-hidden', !show);
207                if (show) visible += 1;
208            });
209
210            page.querySelectorAll('.lg-section').forEach(function (section) {
211                const any = section.querySelector('.mx-row:not(.is-search-hidden)');
212                section.classList.toggle('is-search-hidden', !any);
213                section.classList.toggle('is-search-open', !!query && !!any);
214            });
215
216            page.querySelectorAll('.beauty-schedule-row').forEach(function (row) {
217                const show = !query || matchRowHaystack(row).indexOf(query) !== -1;
218                row.classList.toggle('is-search-hidden', !show);
219                if (show) visible += 1;
220            });
221
222            page.querySelectorAll('.schedule-sport-section').forEach(function (section) {
223                if (!section.querySelector('.beauty-schedule-row')) return;
224                const any = section.querySelector('.beauty-schedule-row:not(.is-search-hidden)');
225                section.classList.toggle('is-search-hidden', !any);
226            });
227
228            page.querySelectorAll('.schedule-sport-title-inline').forEach(function (title) {
229                const anySports = page.querySelector('.lg-section:not(.is-search-hidden)');
230                title.classList.toggle('is-search-hidden', !!query && !anySports);
231            });
232
233            page.querySelectorAll('.match-list-paging').forEach(function (el) {
234                el.classList.toggle('is-search-hidden', query.length > 0);
235            });
236
237            if (empty) empty.hidden = !query || visible > 0;
238        }
239
240        input.addEventListener('input', applyFilter);
241        input.addEventListener('keydown', function (e) {
242            if (e.key === 'Enter') e.preventDefault();
243            if (e.key === 'Escape') {
244                input.value = '';
245                applyFilter();
246            }
247        });
248        if (clearBtn) {
249            clearBtn.addEventListener('click', function () {
250                input.value = '';
251                applyFilter();
252                input.focus();
253            });
254        }
255    }
256
257    const LIST_SEARCH_SELECTOR = '#articleSearch, #leagueSearch, #teamSearch';
258    let preserveSearchAnchor = false;
259    let searchNavKeepsScroll = false;
260
261    function listSearchInput() {
262        return document.querySelector(LIST_SEARCH_SELECTOR);
263    }
264
265    function isListSearchInput(node) {
266        return !!(node && node.matches && node.matches(LIST_SEARCH_SELECTOR + ', #matchListSearch, .match-list-search-input, .match-search-box input'));
267    }
268
269    function setInstantScroll() {
270        const root = document.documentElement;
271        const previous = root.style.scrollBehavior;
272        root.style.scrollBehavior = 'auto';
273        return function restore() {
274            root.style.scrollBehavior = previous;
275        };
276    }
277
278    function listFilterMarker(input) {
279        const page = input.closest('.match-list-page') || document;
280        return page.querySelector('.seo-page-h1') || input.closest('.filter-bar') || input;
281    }
282
283    function rememberListFilterScroll(e) {
284        const input = e.target;
285        if (!input || !input.matches || !input.matches('#matchListSearch, .match-list-search-input')) return;
286        const marker = listFilterMarker(input);
287        input._searchScrollMarker = marker;
288        input._searchScrollBefore = marker.getBoundingClientRect().top;
289        input._searchScrollY = window.scrollY;
290        document.documentElement.style.scrollBehavior = 'auto';
291        document.documentElement.style.overflowAnchor = 'none';
292        const page = input.closest('.match-list-page');
293        if (page) page.style.overflowAnchor = 'none';
294    }
295
296    function restoreListFilterScroll(e) {
297        const input = e.target;
298        if (!input || input._searchScrollBefore == null) return;
299        const before = input._searchScrollBefore;
300        const savedY = input._searchScrollY || 0;
301        const marker = input._searchScrollMarker;
302        input._searchScrollMarker = null;
303        input._searchScrollBefore = null;
304        const page = input.closest('.match-list-page');
305        if (page) {
306            if ((input.value || '').trim()) {
307                const needed = savedY + window.innerHeight;
308                if (page.getBoundingClientRect().height < needed) page.style.minHeight = needed + 'px';
309            } else {
310                page.style.minHeight = '';
311            }
312        }
313        document.documentElement.style.overflowAnchor = '';
314        if (!marker || !marker.isConnected) return;
315        const delta = marker.getBoundingClientRect().top - before;
316        if (Math.abs(delta) > 1) window.scrollBy(0, delta);
317    }
318
319    function holdSearchScroll(e) {
320        if (!isListSearchInput(e.target)) return;
321        document.documentElement.style.scrollBehavior = 'auto';
322    }
323
324    function releaseSearchScroll(e) {
325        if (!isListSearchInput(e.target)) return;
326        document.documentElement.style.scrollBehavior = '';
327    }
328
329    function alignListSearch() {
330        const params = new URLSearchParams(window.location.search);
331        if (!params.get('search')) return;
332        const input = listSearchInput();
333        if (!input) return;
334        const box = input.closest('.article-latest-head, .section-header') || input;
335        const rect = box.getBoundingClientRect();
336        const offset = 16;
337        if (rect.top >= offset && rect.bottom <= window.innerHeight - 8) return;
338        const restoreBehavior = setInstantScroll();
339        window.scrollTo(0, Math.max(0, rect.top + window.scrollY - offset));
340        restoreBehavior();
341    }
342
343    function onListSearchEnter(e) {
344        if (e.key !== 'Enter' || e.repeat) return;
345        const input = e.target;
346        if (!input || !input.matches || !input.matches(LIST_SEARCH_SELECTOR)) return;
347        e.preventDefault();
348        e.stopPropagation();
349        if (e.type !== 'keydown') return;
350        const value = input.value.trim();
351        const url = new URL(window.location.href);
352        if (value) url.searchParams.set('search', value);
353        else url.searchParams.delete('search');
354        url.searchParams.delete('page');
355        const next = url.pathname + url.search;
356        if (next === window.location.pathname + window.location.search) {
357            alignListSearch();
358            return;
359        }
360        preserveSearchAnchor = true;
361        navigateTo(next);
362    }
363
364    function directoryPageCopy() {
365        const lang = String(window.__currentLang || document.documentElement.lang || 'vi').toLowerCase();
366        const zh = lang.indexOf('zh') === 0;
367        const en = lang.indexOf('en') === 0;
368        return {
369            pagination: zh ? '分页导航' : (en ? 'Pagination' : 'Điều hướng trang'),
370            first: zh ? '首页' : (en ? 'First page' : 'Trang đầu'),
371            prev: zh ? '上一页' : (en ? 'Previous Page' : 'Trang trước'),
372            next: zh ? '下一页' : (en ? 'Next Page' : 'Trang sau'),
373            last: zh ? '末页' : (en ? 'Last Page' : 'Trang cuối'),
374            status: function (current, total, count, isTeam) {
375                if (zh) return '第 ' + current + ' / ' + total + ' · 共 ' + count + (isTeam ? ' 个球队' : ' 个联赛');
376                if (en) return 'Page ' + current + ' / ' + total + ' · ' + count + (isTeam ? ' teams' : ' leagues');
377                return 'Trang ' + current + ' / ' + total + ' · Tổng ' + count + (isTeam ? ' đội bóng' : ' giải đấu');
378            }
379        };
380    }
381
382    function isTeamCenterPath(pathname) {
383        const path = (pathname || '').replace(/\/+$/, '') || '/';
384        const team = String(window.__teamRoutePrefix || '/doi-bong').replace(/\/+$/, '');
385        return path === team || path === '/doi-bong' || path === '/teams';
386    }
387
388    function localizeDirectoryPagination() {
389        if (!isDirectoryCenterPath(window.location.pathname)) return;
390        const isTeam = isTeamCenterPath(window.location.pathname);
391        const copy = directoryPageCopy();
392        const iconTitles = {
393            'fa-angle-double-left': copy.first,
394            'fa-angle-left': copy.prev,
395            'fa-angle-right': copy.next,
396            'fa-angle-double-right': copy.last
397        };
398        document.querySelectorAll('#main-content nav .pagination').forEach(function (list) {
399            const nav = list.closest('nav');
400            if (nav) nav.setAttribute('aria-label', copy.pagination);
401            list.querySelectorAll('a.page-link i').forEach(function (icon) {
402                Object.keys(iconTitles).forEach(function (cls) {
403                    if (icon.classList.contains(cls)) {
404                        const link = icon.closest('a');
405                        if (link) link.setAttribute('title', iconTitles[cls]);
406                    }
407                });
408            });
409            const summary = nav && nav.nextElementSibling ? nav.nextElementSibling.querySelector('small') : null;
410            if (!summary) return;
411            const nums = (summary.textContent || '').match(/\d[\d,]*/g);
412            if (!nums || nums.length < 3) return;
413            summary.textContent = copy.status(
414                nums[0].replace(/,/g, ''),
415                nums[1].replace(/,/g, ''),
416                nums[nums.length - 1].replace(/,/g, ''),
417                isTeam
418            );
419        });
420    }
421
422    function isDirectoryCenterPath(pathname) {
423        const path = (pathname || '').replace(/\/+$/, '') || '/';
424        const known = ['/giai-dau', '/doi-bong', '/leagues', '/teams', window.__leagueRoutePrefix, window.__teamRoutePrefix];
425        return known.some(function (item) {
426            const normalized = (item || '').replace(/\/+$/, '');
427            return normalized && path === normalized;
428        });
429    }
430
431    function isMatchDetailPath(pathname) {
432        return /-id\d+(?:\/|$)/i.test(pathname)
433            || pathname.indexOf('/live/beauty/') === 0
434            || pathname.indexOf('/live/football/') === 0
435            || pathname.indexOf('/Match/Details') === 0;
436    }
437
438    function isDirectoryCenterHref(href) {
439        if (!href || href.charAt(0) === '#' || href.indexOf('javascript:') === 0) return false;
440        try {
441            const url = new URL(href, window.location.href);
442            if (url.origin !== window.location.origin) return false;
443            return isDirectoryCenterPath(url.pathname);
444        } catch (e) {
445            return false;
446        }
447    }
448
449    function resetMatchDetailScroll() {
450        const path = window.location.pathname;
451        if (!isMatchDetailPath(path) && !isDirectoryCenterPath(path)) return;
452        window.scrollTo(0, 0);
453    }
454
455    function isMatchDetailHref(href) {
456        if (!href || href.charAt(0) === '#' || href.indexOf('javascript:') === 0) return false;
457        try {
458            const url = new URL(href, window.location.href);
459            if (url.origin !== window.location.origin) return false;
460            return isMatchDetailPath(url.pathname);
461        } catch (e) {
462            return false;
463        }
464    }
465
466    function matchUrlFromCard(card) {
467        if (!card) return '';
468        const bodyLink = card.querySelector('.match-body-link, a.focus-match-card-link');
469        if (bodyLink && bodyLink.href) return bodyLink.href;
470        if (card.tagName === 'A' && card.href) return card.href;
471        return card.getAttribute('data-match-url') || '';
472    }
473
474    function shouldInterceptPrimaryClick(e) {
475        return e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey;
476    }
477
478    function handleMatchDetailClick(e) {
479        if (!shouldInterceptPrimaryClick(e)) return;
480
481        const watchBtn = e.target.closest('.watch-btn');
482        if (watchBtn) {
483            const href = matchUrlFromCard(watchBtn.closest('.match-card, .focus-match-card, .focus-card'));
484            if (!isMatchDetailHref(href)) return;
485            e.preventDefault();
486            e.stopPropagation();
487            showMatchDetailSkeletonAndGo(href);
488            return;
489        }
490
491        const link = e.target.closest('a');
492        if (!link || link.target === '_blank') return;
493        const href = link.getAttribute('href') || link.href;
494        if (isDirectoryCenterHref(href)) {
495            e.preventDefault();
496            e.stopPropagation();
497            showSkeletonAndGo(link.href, directorySkeletonMarkup());
498            return;
499        }
500        if (!isMatchDetailHref(href)) return;
501        e.preventDefault();
502        e.stopPropagation();
503        showMatchDetailSkeletonAndGo(link.href);
504    }
505
506    function matchDetailSkeletonMarkup() {
507        const label = (document.body && document.body.getAttribute('data-loading-text')) || 'Loading';
508        return `
509            <div class="container-fluid match-detail-skeleton" aria-busy="true" aria-live="polite">
510                <span class="visually-hidden">${label}</span>
511                <div class="mds-crumb mds-shimmer"></div>
512                <div class="row">
513                    <div class="col-lg-8">
514                        <div class="mds-card mds-player">
515                            <div class="mds-player-head">
516                                <div class="mds-shimmer mds-pill"></div>
517                                <div class="mds-shimmer mds-pill mds-pill-short"></div>
518                            </div>
519                            <div class="mds-shimmer mds-screen"></div>
520                        </div>
521                        <div class="mds-card mds-hero">
522                            <div class="mds-shimmer mds-league"></div>
523                            <div class="mds-hero-row">
524                                <div class="mds-team"><div class="mds-shimmer mds-logo"></div><div class="mds-shimmer mds-name"></div></div>
525                                <div class="mds-shimmer mds-score"></div>
526                                <div class="mds-team"><div class="mds-shimmer mds-logo"></div><div class="mds-shimmer mds-name"></div></div>
527                            </div>
528                        </div>
529                        <div class="mds-card mds-tabs">
530                            <div class="mds-tabbar">
531                                <div class="mds-shimmer"></div>
532                                <div class="mds-shimmer"></div>
533                                <div class="mds-shimmer"></div>
534                                <div class="mds-shimmer"></div>
535                            </div>
536                            <div class="mds-shimmer mds-line"></div>
537                            <div class="mds-shimmer mds-line mds-line-lg"></div>
538                            <div class="mds-shimmer mds-line"></div>
539                            <div class="mds-shimmer mds-line mds-line-md"></div>
540                        </div>
541                    </div>
542                    <div class="col-lg-4">
543                        <div class="mds-card mds-side">
544                            <div class="mds-shimmer mds-side-head"></div>
545                            <div class="mds-side-lines">
546                                <div class="mds-shimmer mds-line"></div>
547                                <div class="mds-shimmer mds-line mds-line-md"></div>
548                                <div class="mds-shimmer mds-line"></div>
549                                <div class="mds-shimmer mds-line mds-line-lg"></div>
550                                <div class="mds-shimmer mds-line"></div>
551                            </div>
552                            <div class="mds-shimmer mds-side-input"></div>
553                        </div>
554                    </div>
555                </div>
556            </div>`;
557    }
558
559    function directoryCardMarkup() {
560        return `
561            <div class="col-md-6 col-lg-4">
562                <div class="dir-skel-card">
563                    <div class="mds-shimmer dir-skel-logo"></div>
564                    <div class="dir-skel-text">
565                        <div class="mds-shimmer dir-skel-line"></div>
566                        <div class="mds-shimmer dir-skel-line dir-skel-line-short"></div>
567                    </div>
568                    <div class="mds-shimmer dir-skel-badge"></div>
569                </div>
570            </div>`;
571    }
572
573    function directorySkeletonMarkup() {
574        const label = (document.body && document.body.getAttribute('data-loading-text')) || 'Loading';
575        const card = directoryCardMarkup();
576        return `
577            <div class="container-fluid directory-skeleton pt-3 pb-4" aria-busy="true" aria-live="polite">
578                <span class="visually-hidden">${label}</span>
579                <div class="dir-skel-header">
580                    <div class="mds-shimmer dir-skel-title"></div>
581                    <div class="mds-shimmer dir-skel-search"></div>
582                </div>
583                <div class="mds-shimmer dir-skel-section"></div>
584                <div class="row g-3 mb-4">${card}${card}${card}${card}${card}${card}</div>
585                <div class="mds-shimmer dir-skel-section"></div>
586                <div class="row g-3">${card}${card}${card}${card}${card}${card}${card}${card}${card}</div>
587            </div>`;
588    }
589
590    function showSkeletonAndGo(url, markup) {
591        if (detailNavLock) return;
592        detailNavLock = true;
593        window.scrollTo(0, 0);
594        const mainContent = document.getElementById(MAIN_CONTENT_ID);
595        if (mainContent) {
596            mainContent.innerHTML = markup;
597        }
598        requestAnimationFrame(function () {
599            requestAnimationFrame(function () {
600                window.location.assign(url);
601            });
602        });
603    }
604
605    function showMatchDetailSkeletonAndGo(url) {
606        showSkeletonAndGo(url, matchDetailSkeletonMarkup());
607    }
608
609    /**
610     * 检查链接是否需要 SPA 处理
611     */
612    function shouldHandleLink(link) {
613        const href = link.getAttribute('href');
614        
615        if (!href) return false;
616        
617        // 比赛相关页面需要完整刷新(包含内联脚本需要重新初始化)
618        // - 比赛详情页: 聊天室 SignalR 初始化
619        // - 比赛列表页: 虚拟列表、无限滚动等JS初始化
620        if (href.startsWith('/Match/Details') || 
621            href.match(/^\/match\/\d+/) ||
622            href.match(/^\/football\/\d+/) ||
623            href.startsWith('/football/') ||
624            href.startsWith('/matches/') ||
625            href.startsWith('/Match') ||
626            href.startsWith('/streamer/')) {
627            return false;
628        }
629        
630        // 检查排除模式
631        for (const pattern of EXCLUDE_PATTERNS) {
632            if (href.includes(pattern)) return false;
633        }
634        
635        // 外部链接
636        if (href.startsWith('http') && !href.includes(window.location.host)) {
637            return false;
638        }
639        
640        // 新窗口打开
641        if (link.target === '_blank') return false;
642        
643        // 检查是否匹配 SPA 选择器
644        for (const selector of SPA_LINK_SELECTORS) {
645            if (link.matches(selector) || link.closest(selector)) {
646                return true;
647            }
648        }
649        
650        // 分页等同一路径的查询链接
651        if (href.startsWith('?')) {
652            return true;
653        }
654
655        // 其他站内链接也走 SPA
656        if (href.startsWith('/') && !href.startsWith('//')) {
657            return true;
658        }
659        
660        return false;
661    }
662
663    /**
664     * 处理链接点击
665     */
666    function handleLinkClick(e) {
667        const link = e.target.closest('a');
668        if (!link) return;
669
670        const linkHref = link.getAttribute('href') || link.href;
671        if (isDirectoryCenterHref(linkHref)) {
672            if (!shouldInterceptPrimaryClick(e) || link.target === '_blank') return;
673            e.preventDefault();
674            showSkeletonAndGo(link.href, directorySkeletonMarkup());
675            return;
676        }
677        if (isMatchDetailHref(linkHref)) {
678            if (!shouldInterceptPrimaryClick(e) || link.target === '_blank') return;
679            e.preventDefault();
680            showMatchDetailSkeletonAndGo(link.href);
681            return;
682        }
683        
684        if (!shouldHandleLink(link)) return;
685        
686        e.preventDefault();
687        
688        const url = link.getAttribute('href');
689        navigateTo(url);
690    }
691
692    /**
693     * 处理浏览器前进/后退
694     */
695    function handlePopState(e) {
696        if (e.state && e.state.spa) {
697            loadPage(window.location.pathname + window.location.search, false);
698        }
699    }
700
701    /**
702     * 导航到新页面
703     */
704    function navigateTo(url) {
705        if (isNavigating) return;
706        if (url === window.location.pathname + window.location.search) return;
707        
708        // 更新 URL
709        history.pushState({ spa: true }, '', url);
710        
711        // 加载页面
712        loadPage(url, true);
713    }
714
715    /**
716     * 加载页面内容
717     */
718    async function loadPage(url, showTransition = true) {
719        const mainContent = document.getElementById(MAIN_CONTENT_ID);
720        if (!mainContent) {
721            // 没有主内容区域,回退到传统刷新
722            window.location.href = url;
723            return;
724        }
725
726        isNavigating = true;
727        searchNavKeepsScroll = preserveSearchAnchor;
728        preserveSearchAnchor = false;
729        const articleListNav = isArticleListUrl(url);
730
731        // 取消之前的请求
732        if (abortController) {
733            abortController.abort();
734        }
735        abortController = new AbortController();
736
737        try {
738            // 显示加载状态。文章列表先保留当前页面,避免快请求时骨架闪一下。
739            if (showTransition || articleListNav) {
740                showLoadingState(mainContent, url);
741            }
742
743            // 请求新页面
744            const response = await fetch(url, {
745                signal: abortController.signal,
746                headers: {
747                    'X-Requested-With': 'SPA'
748                }
749            });
750
751            if (!response.ok) {
752                throw new Error(`HTTP ${response.status}`);
753            }
754
755            const html = await response.text();
756            clearSkeletonTimer();
757            
758            // 解析新页面
759            const parser = new DOMParser();
760            const doc = parser.parseFromString(html, 'text/html');
761            
762            // 获取新内容
763            const newContent = doc.getElementById(MAIN_CONTENT_ID);
764            const newTitle = doc.querySelector('title');
765            
766            if (newContent) {
767                clearSkeletonTimer();
768                const swapInPlace = articleListNav && !skeletonShown;
769                const assetsReady = adoptDocumentAssets(doc);
770
771                if (!swapInPlace) {
772                    await fadeOut(mainContent, articleListNav);
773                }
774                await assetsReady;
775                
776                // 替换内容。先卸掉挂在 body 上的弹窗,避免旧遮罩盖住新页面。
777                dismissPopupAd();
778                mainContent.innerHTML = newContent.innerHTML;
779                runEmbeddedScripts(newContent);
780                runNewPageScripts(doc);
781                if (window.CustomSelect && typeof window.CustomSelect.init === 'function') {
782                    window.CustomSelect.init(mainContent);
783                }
784                if (mainContent.querySelector('.profile-page') && typeof window.initProfilePage === 'function') {
785                    window.initProfilePage();
786                }
787                
788                // 更新标题
789                if (newTitle) {
790                    document.title = newTitle.textContent;
791                }
792                
793                // 更新导航状态
794                updateActiveNav(url);
795                
796                if (searchNavKeepsScroll) {
797                    alignListSearch();
798                } else {
799                    window.scrollTo({ top: 0, behavior: 'instant' });
800                }
801
802                if (swapInPlace) {
803                    mainContent.classList.remove('spa-loading');
804                    mainContent.style.minHeight = '';
805                    mainContent.style.opacity = '1';
806                    mainContent.style.transform = 'none';
807                    hideLoadingState();
808                } else {
809                    await fadeIn(mainContent, articleListNav);
810                }
811                
812                // 重新初始化页面脚本
813                reinitializeScripts();
814                
815                // 触发自定义事件
816                document.dispatchEvent(new CustomEvent('spa:loaded', { detail: { url } }));
817            } else {
818                // 没有找到主内容,回退
819                window.location.href = url;
820            }
821
822        } catch (error) {
823            if (error.name === 'AbortError') {
824                // 请求被取消,忽略
825                return;
826            }
827            console.error('SPA navigation failed:', error);
828            // 回退到传统刷新
829            window.location.href = url;
830        } finally {
831            clearSkeletonTimer();
832            searchNavKeepsScroll = false;
833            isNavigating = false;
834            abortController = null;
835        }
836    }
837
838    /**
839     * 显示加载状态(骨架屏 + 进度条)
840     */
841    function isArticleListUrl(url) {
842        const path = new URL(url, window.location.href).pathname.replace(/\/+$/, '').toLowerCase() || '/';
843        return ARTICLE_LIST_PATHS.indexOf(path) !== -1;
844    }
845
846    function articleGridSkeletonHtml() {
847        const template = document.getElementById('articleGridSkeletonTemplate');
848        if (!template) return '';
849        const clone = template.cloneNode(true);
850        clone.querySelectorAll('style').forEach(function(style) { style.remove(); });
851        return clone.innerHTML.trim();
852    }
853
854    function clearSkeletonTimer() {
855        if (skeletonTimer) {
856            clearTimeout(skeletonTimer);
857            skeletonTimer = null;
858        }
859    }
860
861    function showLoadingState(container, url) {
862        skeletonShown = false;
863        // 显示顶部进度条
864        const loadingBar = document.getElementById('spaLoadingBar');
865        if (loadingBar) {
866            loadingBar.classList.remove('complete');
867            loadingBar.classList.add('loading');
868        }
869        
870        // 保留容器高度,避免跳动
871        container.style.minHeight = container.offsetHeight + 'px';
872        
873        // 添加加载类
874        container.classList.add('spa-loading');
875
876        if (url && isArticleListUrl(url)) {
877            clearSkeletonTimer();
878            skeletonShown = false;
879            skeletonTimer = setTimeout(function() {
880                skeletonTimer = null;
881                if (!isNavigating) return;
882                const markup = articleGridSkeletonHtml();
883                if (!markup) return;
884                skeletonShown = true;
885                container.innerHTML = markup;
886                if (!searchNavKeepsScroll) window.scrollTo({ top: 0, behavior: 'instant' });
887            }, SKELETON_DELAY_MS);
888            return;
889        }
890        
891        // 创建骨架屏覆盖层
892        const skeleton = document.createElement('div');
893        skeleton.className = 'spa-skeleton-overlay';
894        skeleton.innerHTML = `
895            <div class="skeleton-content">
896                <div class="skeleton-header"></div>
897                <div class="skeleton-cards">
898                    <div class="skeleton-card"></div>
899                    <div class="skeleton-card"></div>
900                    <div class="skeleton-card"></div>
901                    <div class="skeleton-card"></div>
902                    <div class="skeleton-card"></div>
903                    <div class="skeleton-card"></div>
904                </div>
905            </div>
906        `;
907        container.appendChild(skeleton);
908        
909        // 触发重绘后显示
910        requestAnimationFrame(() => {
911            skeleton.classList.add('show');
912        });
913    }
914
915    function runEmbeddedScripts(sourceRoot) {
916        if (!sourceRoot) return;
917        sourceRoot.querySelectorAll('script').forEach(function(oldScript) {
918            if (oldScript.src) return;
919            const type = (oldScript.getAttribute('type') || '').toLowerCase();
920            if (type && type !== 'text/javascript' && type !== 'application/javascript' && type !== 'module') return;
921            const script = document.createElement('script');
922            if (type === 'module') script.type = 'module';
923            script.textContent = oldScript.textContent;
924            document.body.appendChild(script);
925            script.remove();
926        });
927    }
928    
929    /**
930     * 隐藏加载状态
931     *
932     * 进度条到 100% 后只做一次淡出,不再回到 0。
933     * 下次导航开始时由 showLoadingState 重置状态。
934     */
935    function hideLoadingState() {
936        const loadingBar = document.getElementById('spaLoadingBar');
937        if (loadingBar) {
938            // 从 loading 切换到 complete,让 CSS 负责宽度和透明度动画
939            loadingBar.classList.remove('loading');
940            loadingBar.classList.add('complete');
941            // 不再移除 complete,避免视觉上从 100% 跳回 0%
942        }
943    }
944
945    /**
946     * 淡出动画
947     */
948    function fadeOut(element, lockMotion) {
949        return new Promise(resolve => {
950            element.style.opacity = '0';
951            element.style.transform = lockMotion ? 'none' : 'translateY(10px)';
952            setTimeout(resolve, TRANSITION_DURATION / 2);
953        });
954    }
955
956    /**
957     * 淡入动画
958     */
959    function fadeIn(element, lockMotion) {
960        return new Promise(resolve => {
961            // 隐藏加载进度条
962            hideLoadingState();
963            
964            // 移除骨架屏
965            const skeleton = element.querySelector('.spa-skeleton-overlay');
966            if (skeleton) {
967                skeleton.remove();
968            }
969            
970            element.classList.remove('spa-loading');
971            element.style.minHeight = '';
972            element.style.opacity = '0';
973            element.style.transform = lockMotion ? 'none' : 'translateY(10px)';
974            
975            // 触发重绘
976            element.offsetHeight;
977            
978            element.style.transition = lockMotion
979                ? `opacity ${TRANSITION_DURATION}ms ease`
980                : `opacity ${TRANSITION_DURATION}ms ease, transform ${TRANSITION_DURATION}ms ease`;
981            element.style.opacity = '1';
982            if (!lockMotion) {
983                element.style.transform = 'translateY(0)';
984            }
985            
986            setTimeout(() => {
987                element.style.transition = '';
988                element.style.transform = '';
989                resolve();
990            }, TRANSITION_DURATION);
991        });
992    }
993
994    /**
995     * 更新导航激活状态
996     */
997    function updateActiveNav(url) {
998        const urlObj = new URL(url, window.location.origin);
999        const path = urlObj.pathname;
1000        const filter = urlObj.searchParams.get('filter') || '';
1001        const fullUrl = path + urlObj.search;
1002        
1003        // 移除所有 active
1004        document.querySelectorAll('.nav-link-icon.active').forEach(link => {
1005            link.classList.remove('active');
1006        });
1007        
1008        // 找到匹配的链接并激活
1009        document.querySelectorAll('.nav-link-icon').forEach(link => {
1010            const href = link.getAttribute('href');
1011            if (!href) return;
1012            
1013            let isActive = false;
1014            
1015            // 精确匹配(包括查询参数)
1016            if (href === fullUrl || href === path + urlObj.search) {
1017                isActive = true;
1018            }
1019            // 首页特殊处理
1020            else if (href === '/' && path === '/') {
1021                isActive = true;
1022            }
1023            // Match 页面带 filter 参数的处理
1024            else if (href.startsWith('/Match?filter=') && path.startsWith('/Match')) {
1025                const hrefFilter = new URL(href, window.location.origin).searchParams.get('filter');
1026                if (hrefFilter === filter) {
1027                    isActive = true;
1028                }
1029            }
1030            // 移除硬编码的足球路由检查,使用通用匹配
1031            // League 页面
1032            else if (href === '/League' && path.startsWith('/League')) {
1033                isActive = true;
1034            }
1035            // News 页面
1036            else if (href === '/News' && path.startsWith('/News')) {
1037                isActive = true;
1038            }
1039            
1040            if (isActive) {
1041                link.classList.add('active');
1042            }
1043        });
1044    }
1045
1046    /**
1047     * 重新初始化页面脚本
1048     */
1049    function dismissPopupAd() {
1050        document.querySelectorAll('#popupAdOverlay').forEach(function (el) {
1051            el.remove();
1052        });
1053        document.documentElement.classList.remove('popup-ad-open');
1054        document.body.classList.remove('popup-ad-open');
1055        document.body.style.top = '';
1056    }
1057
1058    function reinitializeScripts() {
1059        // 重新应用 i18n
1060        if (window.i18n && window.i18n.updatePageTranslations) {
1061            window.i18n.updatePageTranslations();
1062        }
1063        
1064        // 重新初始化图片懒加载
1065        initLazyImages();
1066        
1067        // 更新当前页面的导航状态
1068        updateActiveNav(window.location.pathname + window.location.search);
1069        
1070        // 重新初始化其他组件...
1071        if (typeof initMatchCards === 'function') {
1072            initMatchCards();
1073        }
1074        if (window.CustomSelect && typeof window.CustomSelect.init === 'function') {
1075            window.CustomSelect.init(document.getElementById(MAIN_CONTENT_ID) || document);
1076        }
1077        if (document.querySelector('.match-list-page') && typeof window.initMatchListPage === 'function') {
1078            window.initMatchListPage();
1079        }
1080        if (document.querySelector('.profile-page') && typeof window.initProfilePage === 'function') {
1081            window.initProfilePage();
1082        }
1083    }
1084
1085    function assetPath(url, base) {
1086        try {
1087            return new URL(url, base || window.location.href).pathname;
1088        } catch (e) {
1089            return '';
1090        }
1091    }
1092
1093    function stylesheetAlreadyLoaded(href) {
1094        const path = assetPath(href);
1095        if (!path) return true;
1096        return Array.prototype.some.call(document.querySelectorAll('link[rel="stylesheet"]'), function(el) {
1097            return assetPath(el.href) === path;
1098        });
1099    }
1100
1101    function waitForStylesheet(el) {
1102        return new Promise(function(resolve) {
1103            let settled = false;
1104            const done = function() {
1105                if (settled) return;
1106                settled = true;
1107                resolve();
1108            };
1109            el.addEventListener('load', done, { once: true });
1110            el.addEventListener('error', done, { once: true });
1111            setTimeout(done, 2000);
1112        });
1113    }
1114
1115    /**
1116     * 把新页面 head 里的样式表和 style 块挂到当前文档,
1117     * 这样 SPA 换入 HTML 时组件样式已经存在。
1118     */
1119    function adoptDocumentAssets(doc) {
1120        const pending = [];
1121        if (!stylesheetAlreadyLoaded('/css/match-list.css')) {
1122            const matchCss = document.createElement('link');
1123            matchCss.rel = 'stylesheet';
1124            matchCss.href = '/css/match-list.css';
1125            document.head.appendChild(matchCss);
1126            pending.push(waitForStylesheet(matchCss));
1127        }
1128        doc.querySelectorAll('link[rel="stylesheet"]').forEach(function(link) {
1129            const href = link.getAttribute('href');
1130            if (!href || stylesheetAlreadyLoaded(href)) return;
1131            const el = document.createElement('link');
1132            el.rel = 'stylesheet';
1133            el.href = new URL(href, window.location.href).href;
1134            if (link.media) el.media = link.media;
1135            document.head.appendChild(el);
1136            pending.push(waitForStylesheet(el));
1137        });
1138
1139        const styleTags = [];
1140        if (doc.head) {
1141            doc.head.querySelectorAll('style').forEach(function(style) { styleTags.push(style); });
1142        }
1143        if (doc.body) {
1144            doc.body.querySelectorAll('style').forEach(function(style) {
1145                if (!style.closest('#' + MAIN_CONTENT_ID)) styleTags.push(style);
1146            });
1147        }
1148        const existingStyles = Array.prototype.map.call(document.head.querySelectorAll('style'), function(style) {
1149            return (style.textContent || '').trim();
1150        });
1151        styleTags.forEach(function(style) {
1152            const text = (style.textContent || '').trim();
1153            if (!text || existingStyles.indexOf(text) !== -1) return;
1154            existingStyles.push(text);
1155            const el = document.createElement('style');
1156            el.setAttribute('data-spa-style', '1');
1157            el.textContent = style.textContent;
1158            document.head.appendChild(el);
1159        });
1160
1161        return Promise.all(pending);
1162    }
1163
1164    /**
1165     * 执行新页面里、主内容之外的脚本(布局 @section Scripts)。
1166     * 已经在当前文档中的布局脚本不重复执行。
1167     */
1168    function runNewPageScripts(doc) {
1169        if (!doc.body) return;
1170        const existingInline = new Set();
1171        const existingSrc = new Set();
1172        document.querySelectorAll('script').forEach(function(script) {
1173            if (script.src) {
1174                const path = assetPath(script.src);
1175                if (path) existingSrc.add(path);
1176            } else if (script.textContent) {
1177                existingInline.add(script.textContent.trim());
1178            }
1179        });
1180
1181        doc.body.querySelectorAll('script').forEach(function(oldScript) {
1182            if (oldScript.closest('#' + MAIN_CONTENT_ID)) return;
1183            const type = (oldScript.getAttribute('type') || '').toLowerCase();
1184            if (type && type !== 'text/javascript' && type !== 'application/javascript' && type !== 'module') return;
1185
1186            if (oldScript.src || oldScript.getAttribute('src')) {
1187                const src = oldScript.getAttribute('src');
1188                const path = assetPath(src);
1189                if (!src || !path || existingSrc.has(path)) return;
1190                existingSrc.add(path);
1191                const script = document.createElement('script');
1192                script.src = new URL(src, window.location.href).href;
1193                if (oldScript.async) script.async = true;
1194                document.body.appendChild(script);
1195                return;
1196            }
1197
1198            const text = (oldScript.textContent || '').trim();
1199            if (!text || existingInline.has(text)) return;
1200            existingInline.add(text);
1201            const script = document.createElement('script');
1202            script.textContent = oldScript.textContent;
1203            document.body.appendChild(script);
1204        });
1205    }
1206
1207    /**
1208     * 初始化图片懒加载
1209     */
1210    function initLazyImages() {
1211        const images = document.querySelectorAll('img[data-src]');
1212        
1213        if ('IntersectionObserver' in window) {
1214            const observer = new IntersectionObserver((entries) => {
1215                entries.forEach(entry => {
1216                    if (entry.isIntersecting) {
1217                        const img = entry.target;
1218                        img.src = img.dataset.src;
1219                        img.removeAttribute('data-src');
1220                        observer.unobserve(img);
1221                    }
1222                });
1223            }, {
1224                rootMargin: '100px'
1225            });
1226            
1227            images.forEach(img => observer.observe(img));
1228        } else {
1229            // 回退方案
1230            images.forEach(img => {
1231                img.src = img.dataset.src;
1232                img.removeAttribute('data-src');
1233            });
1234        }
1235    }
1236
1237    // 暴露给全局
1238    window.spaRouter = {
1239        init,
1240        navigateTo,
1241        openMatchDetail: showMatchDetailSkeletonAndGo,
1242        refresh: () => loadPage(window.location.pathname + window.location.search)
1243    };
1244
1245    // DOM 加载完成后初始化
1246    if (document.readyState === 'loading') {
1247        document.addEventListener('DOMContentLoaded', init);
1248    } else {
1249        init();
1250    }
1251
1252})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.