// ==UserScript== // @name 阅境 · NGA // @namespace nga-cards-local // @version 3.0.4 // @description 阅境(Readscape)的 NGA 阅读适配器:卡片列表、正文与回复阅读、字体配色设置、手机布局和原生跳转过渡。 // @homepageURL https://github.com/corvofeng/readscape // @supportURL https://github.com/corvofeng/readscape/issues // @match https://bbs.nga.cn/thread.php* // @match https://bbs.nga.cn/read.php* // @match https://bbs.nga.cn/forum.php* // @match https://bbs.nga.cn/index.php* // @match https://bbs.nga.cn/* // @match https://nga.178.com/thread.php* // @match https://nga.178.com/read.php* // @match https://nga.178.com/forum.php* // @match https://nga.178.com/index.php* // @match https://nga.178.com/* // @match https://ngabbs.com/thread.php* // @match https://ngabbs.com/read.php* // @match https://ngabbs.com/forum.php* // @match https://ngabbs.com/index.php* // @match https://ngabbs.com/* // @match https://bbs.ngacn.cc/thread.php* // @match https://bbs.ngacn.cc/read.php* // @match https://bbs.ngacn.cc/forum.php* // @match https://bbs.ngacn.cc/index.php* // @match https://bbs.ngacn.cc/* // @run-at document-start // @grant GM_registerMenuCommand // @grant unsafeWindow // @updateURL https://raw.githubusercontent.com/corvofeng/readscape/dist/readscape-nga.user.js // @downloadURL https://raw.githubusercontent.com/corvofeng/readscape/dist/readscape-nga.user.js // @license MIT // ==/UserScript== (() => { 'use strict'; if (window.top !== window.self) return; function createPostCache({ context = globalThis.window, key, maxPosts = 500, maxMetadataBytes = 10 * 1024 * 1024, ttl = 7 * 86400000 }) { const stores = ['posts', 'replyPages', 'relations', 'listPages']; const listeners = new Set(); let connection, queue = Promise.resolve(), closed = false, epoch = 0; let enabled = true, postLimit = maxPosts; const channel = typeof context.BroadcastChannel === 'function' ? new context.BroadcastChannel(`${key}-cache-events`) : null; const validTid = tid => /^\d+$/.test(String(tid)); const size = value => JSON.stringify(value).length * 2; const resourceId = source => { try { const u = new URL(source); if (!/^https?:$/.test(u.protocol)) return null; u.hash = ''; return u.href; } catch { return null; } }; const notify = event => { for (const listener of listeners) { try { listener(event); } catch {} } if (!event.remote) { try { channel?.postMessage(event); } catch {} } }; // 封面登记先同步写一条 pending 日志:页面在 IndexedDB 写入提交前被刷新/关闭时, // 下次加载据此回填,避免"关系没存上、刷新后封面消失"。 const pendingCoverKey = `${key}-cover-pending`; const readPendingCovers = () => { try { return JSON.parse(context.localStorage.getItem(pendingCoverKey) || '{}') || {}; } catch { return {}; } }; const writePendingCovers = map => { try { if (Object.keys(map).length) context.localStorage.setItem(pendingCoverKey, JSON.stringify(map)); else context.localStorage.removeItem(pendingCoverKey); } catch {} }; const dropPendingCover = tid => { const map = readPendingCovers(); if (!(tid in map)) return; delete map[tid]; writePendingCovers(map); }; // 只在本会话结束前把"已登记但还没提交"的封面同步落一条 pending 日志, // 避免与本次会话自己的 IndexedDB 写入竞争。 const uncommittedCovers = new Map(); const flushPendingCovers = () => { if (!uncommittedCovers.size) return; const map = readPendingCovers(); for (const [tid, source] of uncommittedCovers) map[tid] = { source, at: Date.now() }; writePendingCovers(map); }; if (channel) channel.onmessage = message => { const event=message.data;if(!event||!['clear','usage','favorites','evict','cover'].includes(event.type))return;if(event.type==='clear')epoch++;notify({...event,remote:true}); }; function database() { return connection ||= new Promise(resolve => { if (!context.indexedDB || closed) { resolve(null); return; } try { const request = context.indexedDB.open(`${key}-posts`, 3); request.onupgradeneeded = () => { const db = request.result, tx = request.transaction; for (const [name, keyPath] of [['posts','tid'],['replyPages',['tid','page']],['relations','id'],['listPages','id']]) { if (!db.objectStoreNames.contains(name)) db.createObjectStore(name, { keyPath }); } for (const [name, indexes] of [['posts',['lastAccess']],['replyPages',['tid']],['relations',['tid','kind']],['listPages',['streamKey']]]) { const store = tx.objectStore(name); for (const index of indexes) if (!store.indexNames.contains(index)) store.createIndex(index, index); } // Version 1 keyed each cover by thread and stored bytes; version 3 keeps // only the cover URL, so drop the blob stores and legacy image links. if (db.objectStoreNames.contains('images')) { const cursor = tx.objectStore('images').openCursor(); cursor.onsuccess = () => { const row = cursor.result; if (!row) { db.deleteObjectStore('images'); return; } const image = row.value, id = resourceId(image?.source); if (id && image.tid != null) { const post = tx.objectStore('posts').get(image.tid); post.onsuccess = () => { if (post.result && !post.result.coverId) tx.objectStore('posts').put({ ...post.result, coverId: id }); }; } row.continue(); }; } if (db.objectStoreNames.contains('resources')) db.deleteObjectStore('resources'); const relations = tx.objectStore('relations').openCursor(); relations.onsuccess = () => { const row = relations.result; if (!row) return; if (row.value?.kind === 'image') row.delete(); row.continue(); }; }; request.onsuccess = () => { const db = request.result; db.onversionchange = () => db.close(); resolve(db); }; request.onerror = request.onblocked = () => resolve(null); } catch { resolve(null); } }); } function enqueue(operation) { const result = queue.then(operation).catch(() => null); queue = result; return result; } function readModel(tx, done) { const model = {}, original = {}; let left = stores.length; const finish = (name, rows) => { const identity = name === 'posts' ? p => p.tid : name === 'replyPages' ? p => `${p.tid}:${p.page}` : p => p.id; original[name] = new Map(rows.map(p => [identity(p), p])); model[name] = new Map(rows.map(p => [identity(p), { ...p }])); if (!--left) done(model, original); }; for (const name of stores) { const request = tx.objectStore(name).getAll(); request.onsuccess = () => finish(name, request.result); } } function removePost(model, tid) { model.posts.delete(tid); for (const [id, row] of model.replyPages) if (row.tid === tid) model.replyPages.delete(id); for (const [id, row] of model.listPages) { row.tids = row.tids.filter(t => t !== tid); if (!row.tids.length) model.listPages.delete(id); } } function usage(model, sizes = new WeakMap()) { const bytes = row => { if (!sizes.has(row)) sizes.set(row,size(row)); return sizes.get(row); }; const metadataBytes = ['posts','replyPages'].reduce((sum, name) => sum + [...model[name].values()].reduce((n, row) => n + bytes(row), 0), 0) + [...model.listPages.values()].reduce((n,row)=>n+size(row),0) + [...model.relations.values()].filter(r => r.kind !== 'favorite').reduce((n, row) => n + bytes(row), 0); return { available: true, posts: model.posts.size, replyPages: model.replyPages.size, metadataBytes, totalBytes: metadataBytes, favorites: [...model.relations.values()].filter(r => r.kind === 'favorite').length, enabled, maxPosts: postLimit, maxMetadataBytes }; } function prune(model) { const now = Date.now(); for (const p of model.posts.values()) if (now - p.lastAccess > ttl) removePost(model, p.tid); const sizes = new WeakMap(); for (const p of [...model.posts.values()].sort((a,b) => a.lastAccess - b.lastAccess)) { const used = usage(model,sizes); if (used.posts <= postLimit && used.metadataBytes <= maxMetadataBytes) break; removePost(model, p.tid); } } async function mutate(change, generation = epoch) { const db = await database(); if (!db || closed || generation !== epoch) return null; return new Promise((resolve, reject) => { const tx = db.transaction(stores, 'readwrite'); let result, removed = [], changed = false; readModel(tx, (model, original) => { try { result = change(model); prune(model); removed = [...original.posts.keys()].filter(tid => !model.posts.has(tid)); for (const name of stores) { const store = tx.objectStore(name); for (const [id, row] of original[name]) if (!model[name].has(id)) { store.delete(name === 'replyPages' ? [row.tid,row.page] : id); changed = true; } for (const [id, row] of model[name]) if (JSON.stringify(row) !== JSON.stringify(original[name].get(id))) { store.put(row); changed = true; } } } catch (error) { tx.abort(); reject(error); } }); tx.oncomplete = () => { if (removed.length) notify({ type:'evict', tids:removed }); if (changed) notify({ type:'usage' }); resolve(result); }; tx.onerror = tx.onabort = () => reject(tx.error || new Error('缓存写入失败')); }); } function remember(model, item, at = Date.now()) { if (!item || !validTid(item.tid)) return; const tid = String(item.tid); let previous = model.posts.get(tid); if (previous && at - previous.lastAccess > ttl) { removePost(model, tid); previous = null; } model.posts.set(tid, { ...previous, ...item, tid, lastAccess: at, updatedAt: item.updatedAt ?? previous?.updatedAt ?? at }); } const ready = database().then(async db => { if (!db) return false; let pages = [], favorites = {}; try { const raw = context.localStorage.getItem(`${key}-list-cache`); if (raw && raw.length * 2 <= maxMetadataBytes) pages = JSON.parse(raw)?.pages || []; } catch {} try { favorites = JSON.parse(context.localStorage.getItem(`${key}-favorites`) || '{}'); } catch {} const pendingCovers = readPendingCovers(), appliedCovers = []; const migrated = await mutate(model => { for (const page of Array.isArray(pages) ? pages : []) { const items = (Array.isArray(page?.items) ? page.items : []).filter(item => validTid(item?.tid) && Date.now() - (item.lastAccess ?? page.at ?? 0) <= ttl); if (!items.length || !page.key || !Number.isInteger(page.number)) continue; for (const item of items) if (!model.posts.has(String(item.tid))) remember(model, item, item.lastAccess ?? page.at); const id = `${page.key}#${page.number}`; if (!model.listPages.has(id)) model.listPages.set(id, { id, streamKey:page.key, number:page.number, url:page.url, next:page.next, tids:items.map(i=>String(i.tid)), cachedAt:page.at }); } for (const item of Object.values(favorites || {})) if (validTid(item?.tid)) { const tid = String(item.tid), id = `favorite:${tid}`; if (!model.relations.has(id)) model.relations.set(id, { id, kind:'favorite', tid, savedAt:Date.now(), snapshot:item }); } for (const [tid, entry] of Object.entries(pendingCovers)) { const id = resourceId(entry?.source); if (!id || !validTid(tid)) continue; remember(model, { tid }); const post = model.posts.get(tid); if (post) { post.coverId = id; appliedCovers.push(tid); } } return true; }); if (migrated) for (const tid of appliedCovers) dropPendingCover(tid); if (migrated) { try { context.localStorage.removeItem(`${key}-list-cache`); context.localStorage.removeItem(`${key}-favorites`); } catch {} } return !!migrated; }).catch(() => false); function write(change) { const generation = epoch; return enqueue(async () => { if (!await ready || !enabled || generation !== epoch) return null; return mutate(change, generation); }); } async function read(select, includeDisabled = false) { if (!await ready) return null; await queue; const db = await database(); if (!db || closed || !enabled && !includeDisabled) return null; return new Promise(resolve => { const tx = db.transaction(stores, 'readonly'); let result; readModel(tx, model => { prune(model); result = select(model); }); tx.oncomplete = () => resolve(result); tx.onerror = tx.onabort = () => resolve(null); }); } function visit(input) { const items = Array.isArray(input) ? input : [input]; if (!items.length) return Promise.resolve(true); return write(model => { for (const item of items) remember(model, item); return true; }); } async function getRecord(name,id) { if(!await ready)return null; await queue; const db=await database(); if(!db||closed||!enabled)return null; return new Promise(resolve=>{const tx=db.transaction(name),request=tx.objectStore(name).get(id);tx.oncomplete=()=>resolve(request.result||null);tx.onerror=tx.onabort=()=>resolve(null);}); } const getPost = async tid => {const post=await getRecord('posts',String(tid));return post&&Date.now()-post.lastAccess<=ttl?post:null;}; const getReplyPage = async (tid,page) => await getPost(tid) ? getRecord('replyPages',[String(tid),page]) : null; function saveReplyPage(tid, page, replies, metadata = {}) { if (!validTid(tid) || !Number.isInteger(page) || page < 1) return Promise.resolve(false); tid = String(tid); return write(model => { const op = replies.find(p=>p.floor===0); remember(model, { tid, ...(metadata.title ? {title:metadata.title} : {}), ...(metadata.url ? {url:metadata.url} : {}), ...(op ? {author:op.author,contentHtml:op.html} : {}) }); model.replyPages.set(`${tid}:${page}`, {tid,page,replies,next:metadata.next,title:metadata.title,url:metadata.url,cachedAt:Date.now()}); return true; }); } function saveListPages(streamKey, pages) { return write(model => { for (const [number,page] of pages) { for (const item of page.items) remember(model,item,item.lastAccess ?? Date.now()); const id = `${streamKey}#${number}`; model.listPages.set(id,{id,streamKey,number,url:page.url,next:page.next,tids:page.items.map(i=>String(i.tid)),cachedAt:Date.now()}); } return true; }); } const getListPages = streamKey => read(model => [...model.listPages.values()].filter(p=>p.streamKey===streamKey).sort((a,b)=>a.number-b.number).map(p=>({...p,items:p.tids.map(tid=>model.posts.get(tid)).filter(Boolean)}))); const getFavorites = () => read(model => Object.fromEntries([...model.relations.values()].filter(r=>r.kind==='favorite').map(r=>[r.tid,r.snapshot])), true); function setFavorite(item, saved) { const tid = String(item.tid); if(!validTid(tid))return Promise.resolve(false); const snapshot={};for(const field of ['tid','title','author','uid','replies','time','url','latest','pinned'])if(item[field]!==undefined)snapshot[field]=item[field];snapshot.tid=tid; return enqueue(async()=> { if(!await ready)return null; const result = await mutate(model => { const id = `favorite:${tid}`; if (saved) model.relations.set(id,{id,kind:'favorite',tid,savedAt:Date.now(),snapshot}); else model.relations.delete(id); return true; }); if(result) notify({type:'favorites'}); return result; }); } // 封面只记录原图地址,图片本身交给浏览器 HTTP 缓存,不再写入 Blob。 function saveCover(tid, source, metadata = {}) { tid = String(tid); const id = resourceId(source); if (!id || !validTid(tid) || !enabled) return Promise.resolve(false); uncommittedCovers.set(tid, id); return write(model => { remember(model, { ...metadata, tid }); const post = model.posts.get(tid); if (!post) return false; post.coverId = id; return true; }) .then(result => { if (result) { uncommittedCovers.delete(tid); dropPendingCover(tid); notify({ type:'cover', tid, source:id }); } return !!result; }); } async function getCover(tid) { const post = await getPost(tid); return post?.coverId ? { source: post.coverId, width: post.coverW, height: post.coverH } : null; } const cleanup=()=>enqueue(async()=>{if(!await ready)return null;return mutate(()=>true);}); const remove=tids=>write(model=>{for(const tid of tids)removePost(model,String(tid));return true;}); async function stats(){const used=await read(usage,true);return used||{available:false,enabled,maxPosts:postLimit,maxMetadataBytes};} function configure(options={}) { const changed=enabled!==(options.enabled!==false);enabled=options.enabled!==false; postLimit=Math.max(1,Math.min(maxPosts,Math.trunc(Number(options.maxPosts)||maxPosts))); if(changed)epoch++;return cleanup(); } function clear() { epoch++;const generation=epoch; return enqueue(async()=>{if(!await ready)return null;const result=await mutate(model=>{for(const name of ['posts','replyPages','listPages'])model[name].clear();for(const [id,r]of model.relations)if(r.kind!=='favorite')model.relations.delete(id);return true;},generation);if(result){try{context.localStorage.removeItem(pendingCoverKey);}catch{}notify({type:'clear'});}return result;}); } function scheduleCleanup(){if(context.requestIdleCallback)context.requestIdleCallback(()=>cleanup(),{timeout:5000});else context.setTimeout(()=>cleanup(),1000);} scheduleCleanup();const timer=context.setInterval(scheduleCleanup,30*60000); const close=()=>{flushPendingCovers();closed=true;context.clearInterval(timer);connection?.then(db=>db?.close());listeners.clear();channel?.close();};context.addEventListener('pagehide',e=>{if(!e.persisted)close();}); return {ready,visit,getPost,getReplyPage,saveReplyPage,getListPages,saveListPages,getFavorites,setFavorite,getCover,saveCover,cleanup,remove,stats,configure,clear,get generation(){return epoch;},subscribe(listener){listeners.add(listener);return()=>listeners.delete(listener);},close}; } function createNavigation(config, context = globalThis.window) { const window = context; const { document, location, history, localStorage, sessionStorage } = window; const key = 'reader-toolkit-transition'; let veil, timeout, background, scrollRestoration; const framed = window.top !== window.self; function safeTid(raw) { try { return new URL(raw, location.href).searchParams.get('tid'); } catch { return null; } } function isDeletedDoc(doc) { if (!doc) return false; if (doc.querySelector?.('.postcontent, [id^="postcontent"], #postcontainer0')) return false; const title = (doc.title || '').trim(); const text = doc.body?.textContent || ''; return /ERROR:\s*62/i.test(text) || /ERROR:\s*62/i.test(title) || title === '帖子被删除' || title === '主题被删除' || /(?:ERROR:\s*62\s*\)?\s*>\s*)?帖子(?:不存在或)?(?:已[被经]|被)?删除/.test(text); } // 只允许本脚本创建的同源阅读容器运行适配器。 function disposeBackground() { if (!background) return; clearInterval(background.poll); clearTimeout(background.timeout); document.title = background.title; background.cleanup?.(); background.frame.remove(); background.notice.remove(); document.documentElement.removeAttribute('data-readscape-reader-open'); if (background.list) { background.list.style.visibility = background.listVisibility; background.list.inert = background.listInert; background.list.style.overflow = background.listOverflow; if (document.documentElement.hasAttribute('data-readscape-document-scroll')) { const left = background.documentScroll.left, top = background.documentMode ? background.documentScroll.top : background.listScroll; if (window.scrollX !== left || window.scrollY !== top) window.scrollTo({ left, top }); } else { background.list.scrollTop = background.documentMode ? background.documentScroll.top : background.listScroll; } } if (background.shown && background.focus?.isConnected) background.focus.focus({ preventScroll: true }); background = null; document.dispatchEvent(new document.defaultView.Event('readscape-list-resume')); } function openReader(u, restoring = false) { disposeBackground(); finish(); const frame = document.createElement('iframe'); frame.dataset.readscapeReader = 'true'; frame.dataset.readscapeListURL = (restoring && history.state?.readscapeListURL) || location.href; frame.title = '帖子与评论'; frame.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;height:100dvh;border:0;z-index:2147483002;visibility:hidden;pointer-events:none;background:transparent;overscroll-behavior:none'; const notice = document.createElement('div'); notice.setAttribute('role', 'status'); notice.style.cssText = 'position:fixed;bottom:20px;left:16px;right:16px;width:fit-content;max-width:calc(100% - 32px);z-index:2147483003;padding:12px 16px;border-radius:14px;background:#fff;color:#555;box-shadow:0 4px 24px #0002;font:14px system-ui;display:flex;gap:12px;align-items:center;flex-wrap:wrap'; const label = document.createElement('span'); label.textContent = '正在后台打开评论…'; const original = document.createElement('a'); original.href = u.href; original.textContent = '直接打开'; original.dataset.readscapeNative = 'true'; const cancel = document.createElement('button'); cancel.textContent = '取消'; cancel.onclick = () => { if (restoring) history.back(); else disposeBackground(); }; notice.append(label, original, cancel); const state = background = { frame, notice, url: u.href, title: document.title, focus: document.activeElement }; const fail = () => { if (background !== state) return; disposeBackground(); // 后台启动失败也必须完成点击意图,退回浏览器正常导航。 location.assign(u.href); }; frame.addEventListener('readscape-return', () => history.back()); frame.addEventListener('error', fail); frame.src = u.href; document.documentElement.append(frame, notice); state.poll = setInterval(() => { if (background !== state) return; try { const doc = frame.contentDocument; if (doc?.body && doc.readyState !== 'loading' && doc.URL !== 'about:blank') { const checkDeleted = config.isDeleted || isDeletedDoc; if (checkDeleted(doc, u)) { const tid = u.searchParams.get('tid') || safeTid(doc.URL); disposeBackground(); document.dispatchEvent(new (document.defaultView?.CustomEvent || CustomEvent)('readscape-post-deleted', { detail: { tid, url: u.href, reason: '帖子被删除' } })); return; } if (state.mountedDoc !== doc) { const childURL = new URL(doc.URL); if (childURL.origin !== location.origin || !config.accepts(childURL)) { fail(); return; } state.cleanup?.(); state.mountedDoc = doc; state.cleanup = config.mountReader?.(frame.contentWindow); } } const reader = doc?.getElementById('nga-cards-host')?.shadowRoot?.querySelector('.reader'); if (!reader || reader.hidden || !reader.querySelector('.comment')) return; // 就绪前保留列表、滚动位置和焦点;地址与历史只在成功后更新。 if (!restoring) history.pushState({ ...history.state, readscapeReader: u.href, readscapeListURL: frame.dataset.readscapeListURL }, '', u.href); clearInterval(state.poll); clearTimeout(state.timeout); frame.style.visibility = 'visible'; frame.style.pointerEvents = 'auto'; const list = document.getElementById('nga-cards-host')?.shadowRoot?.querySelector('.app:not(.reader)'); if (list) { Object.assign(state, { list, listVisibility: list.style.visibility, listInert: !!list.inert, listOverflow: list.style.overflow, listScroll: list.scrollTop, documentMode: document.documentElement.hasAttribute('data-readscape-document-scroll'), documentScroll: { left: window.scrollX, top: window.scrollY } }); list.style.visibility = 'hidden'; list.inert = true; if (!state.documentMode) list.style.overflow = 'hidden'; document.documentElement.setAttribute('data-readscape-reader-open', ''); // 原列表仍在文档中;由它保留位置,避免浏览器遍历历史时另行滚动。 if (scrollRestoration === undefined) scrollRestoration = history.scrollRestoration ?? 'auto'; history.scrollRestoration = 'manual'; } state.shown = true; document.title = doc.title; notice.remove(); frame.focus({ preventScroll: true }); } catch { fail(); } }, 100); state.timeout = setTimeout(fail, 15000); } function onPopState(event) { disposeBackground(); finish(); const url = event.state?.readscapeReader; if (typeof url !== 'string' || !enabled()) return; try { const u = new URL(url); if (u.origin === location.origin && config.accepts(u)) openReader(u, true); } catch {} } if (!framed) window.addEventListener('popstate', onPopState); function finish() { veil?.remove(); veil = null; clearTimeout(timeout); if (!enabled()) { disposeBackground(); if (scrollRestoration !== undefined) { history.scrollRestoration = scrollRestoration; scrollRestoration = undefined; } } try { sessionStorage.removeItem(key); } catch {} } function show() { if (veil || !document.documentElement) return; veil = document.createElement('div'); veil.style.cssText = 'position:fixed;inset:0;z-index:2147483646;background:#fff;display:grid;place-items:center;color:#888;font:14px system-ui'; const box = document.createElement('div'); box.style.textAlign = 'center'; const label = document.createElement('p'); label.textContent = '正在打开页面…'; const cancel = document.createElement('button'); cancel.textContent = '显示原页'; cancel.style.cssText = 'border:0;border-radius:22px;padding:10px 18px;background:#f3f3f5;color:#777;font:inherit'; cancel.onclick = finish; box.append(label, cancel); veil.append(box); document.documentElement.append(veil); timeout = setTimeout(finish, 12000); } function enabled() { try { const p = JSON.parse(localStorage.getItem(config.storageKey) || '{}'); return p.enabled !== false && p.smoothNavigation !== false; } catch { return true; } } try { const pending = JSON.parse(sessionStorage.getItem(key) || 'null'); if (enabled() && pending && Date.now() - pending.at < 20000 && pending.origin === location.origin && config.accepts(new URL(location.href))) show(); else sessionStorage.removeItem(key); } catch {} // 列表到评论使用完整同源页面后台加载;其他链接保留原生导航。 function capture(event) { if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || !enabled()) return; const a = event.composedPath().find(n => n?.tagName === 'A'); if (!a || a.hasAttribute('data-readscape-native') || a.hasAttribute('download') || (a.target && a.target !== '_self')) return; let u; try { u = new URL(a.href, location.href); } catch { return; } if (u.origin !== location.origin || !config.accepts(u) || (u.pathname === location.pathname && u.search === location.search)) return; if (framed && /\/thread\.php$/.test(u.pathname)) { const listURL = new URL(window.frameElement.dataset.readscapeListURL); if (['stid', 'fid'].every(key => u.searchParams.get(key) === listURL.searchParams.get(key))) { event.preventDefault(); window.frameElement.dispatchEvent(new Event('readscape-return')); return; } } const list = document.getElementById('nga-cards-host')?.shadowRoot?.querySelector('.app:not(.reader)'); if (!framed && list && !list.hidden && /\/read\.php$/.test(u.pathname)) { event.preventDefault(); openReader(u); return; } try { sessionStorage.setItem(key, JSON.stringify({ at: Date.now(), origin: u.origin })); } catch {} show(); } document.addEventListener('click', capture); window.addEventListener('pageshow', e => { if (e.persisted) finish(); }); return { finish, show, destroy() { disposeBackground(); finish(); if (scrollRestoration !== undefined) history.scrollRestoration = scrollRestoration; document.removeEventListener('click', capture); window.removeEventListener('popstate', onPopState); } }; } function mountSettings({ context = globalThis.window, shadow, app, prefs, save, change, original, login, cache, openBoards }) { const document = context.document; const style = document.createElement('style'); style.textContent = ` .rt-mask[hidden],.rt-fab[hidden]{display:none!important} .rt-fab,.rt-mask{--rt-surface:#fff8fa;--rt-text:#30272d;--rt-muted:#776b73;--rt-accent:#a43d60;--rt-container:#f5dce5;--rt-outline:#d8c4cc} [data-rt-theme=dark]{--rt-surface:#202329;--rt-text:#f3e4ed;--rt-muted:#cbb9c4;--rt-accent:#ff8ba0;--rt-container:#35252e;--rt-outline:#34373e;color-scheme:dark} [data-rt-theme=paper]{--rt-surface:#faf5eb;--rt-text:#3c332d;--rt-muted:#7b6f61;--rt-container:#eee2cf;--rt-outline:#d3c5b2} .rt-fab{position:fixed;right:16px;bottom:calc(24px + env(safe-area-inset-bottom));z-index:2147483010;width:56px;height:56px;display:grid;place-items:center;border:0;border-radius:18px;background:var(--rt-container);color:var(--rt-accent);box-shadow:0 3px 8px #38212e26,0 1px 3px #38212e1a;touch-action:manipulation;transition:box-shadow .18s,transform .18s} .rt-fab:active{transform:scale(.94);box-shadow:0 1px 3px #38212e33}.rt-fab svg{width:24px;height:24px;pointer-events:none} .rt-mask{position:fixed;inset:0;z-index:2147483015;width:100vw;height:100dvh;max-width:100vw;max-height:100dvh;margin:0;padding:0;border:0;background:rgba(0,0,0,.45);backdrop-filter:blur(4px);pointer-events:auto;display:flex;align-items:center;justify-content:center;color:var(--rt-text);overscroll-behavior:contain;touch-action:pan-y;box-sizing:border-box} .rt-mask:not([open]){display:none!important} .rt-mask::backdrop{background:transparent} .rt-sheet{background:var(--rt-surface);color:var(--rt-text);pointer-events:auto;width:min(500px,calc(100vw - 32px));max-height:86dvh;display:flex;flex-direction:column;overflow:hidden;overscroll-behavior:contain;border-radius:24px;border:1px solid var(--rt-outline);box-shadow:0 14px 50px rgba(0,0,0,.25);font:14px/1.5 system-ui;touch-action:pan-y;position:relative} .rt-handle{display:none;width:100%;height:20px;align-items:center;justify-content:center;cursor:grab;flex:none;touch-action:none} .rt-handle:before{content:'';width:40px;height:5px;border-radius:3px;background:var(--rt-muted);opacity:.45} .rt-header{display:flex;justify-content:space-between;align-items:center;padding:16px 20px 12px;border-bottom:1px solid color-mix(in srgb,var(--rt-outline) 35%,transparent);flex:none;gap:12px} .rt-header h2{font-size:17px;font-weight:700;margin:0;display:flex;align-items:center;gap:8px;white-space:nowrap} .rt-header .rt-account{font-size:12px;padding:6px 12px;border-radius:16px;background:var(--rt-container);color:var(--rt-accent);min-width:0;max-width:150px;text-align:left;border:0;cursor:pointer} .rt-close{width:34px;height:34px;border-radius:50%;display:grid;place-items:center;font-size:22px;line-height:1;color:var(--rt-muted);background:transparent;border:0;cursor:pointer;transition:background .15s,color .15s} .rt-close:hover{background:var(--rt-container);color:var(--rt-accent)} .rt-body{flex:1;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;padding:16px 20px calc(16px + env(safe-area-inset-bottom));display:flex;flex-direction:column;gap:12px;touch-action:pan-y} .rt-boards-btn{display:flex;align-items:center;justify-content:space-between;padding:11px 16px;border-radius:16px;background:var(--rt-container);color:var(--rt-accent);font-size:14px;font-weight:700;border:0;cursor:pointer;transition:filter .15s} .rt-boards-btn:active{filter:brightness(.92)} .rt-sheet label{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:46px;border-bottom:1px solid color-mix(in srgb,var(--rt-outline) 35%,transparent);cursor:pointer} .rt-sheet select{max-width:180px;min-height:36px;padding:6px 10px;border:1px solid var(--rt-outline);border-radius:12px;background:transparent;color:var(--rt-text);font:inherit} .rt-range{display:flex;align-items:center;gap:10px}.rt-scale{min-width:40px;text-align:right;font-size:13px;color:var(--rt-muted);font-variant-numeric:tabular-nums}.rt-sheet input[type=range]{width:110px;height:36px;accent-color:var(--rt-accent);cursor:pointer} .rt-sheet input[type=checkbox]{appearance:none;-webkit-appearance:none;flex:none;width:52px;height:32px;border:2px solid var(--rt-outline);border-radius:20px;background:var(--rt-outline);position:relative;cursor:pointer;transition:background .18s,border-color .18s}.rt-sheet input[type=checkbox]:before{content:'';position:absolute;left:4px;top:4px;width:20px;height:20px;border-radius:50%;background:var(--rt-muted);transition:transform .18s,background .18s}.rt-sheet input[type=checkbox]:checked{background:var(--rt-accent);border-color:var(--rt-accent)}.rt-sheet input[type=checkbox]:checked:before{transform:translateX(20px);background:var(--rt-surface)} .rt-sheet button{touch-action:manipulation} .rt-actions{display:flex;gap:10px;margin-top:14px;flex-wrap:wrap}.rt-actions button{flex:1;min-height:42px;padding:8px 16px;border-radius:20px;background:var(--rt-container);color:var(--rt-accent);font-weight:600;border:0;cursor:pointer;white-space:nowrap;transition:filter .15s}.rt-actions button:active{filter:brightness(.92)} .rt-more{background:color-mix(in srgb,var(--rt-surface) 60%,var(--rt-container) 40%);border:1px solid color-mix(in srgb,var(--rt-outline) 40%,transparent);border-radius:18px;padding:4px 16px 12px;margin-top:4px} .rt-more>summary{cursor:pointer;color:var(--rt-accent);font-weight:600;padding:10px 0} .account-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.account-uid{display:block;font-size:10px;font-weight:400;white-space:nowrap;opacity:.75}.rt-note{font-size:12px;color:var(--rt-muted);margin:10px 0 0} .rt-cache{background:color-mix(in srgb,var(--rt-surface) 60%,var(--rt-container) 40%);border:1px solid color-mix(in srgb,var(--rt-outline) 40%,transparent);border-radius:18px;padding:12px 16px;margin-top:4px} .rt-cache-usage{font-size:12px;line-height:1.6;color:var(--rt-muted);margin:4px 0 8px;overflow-wrap:anywhere}.rt-cache summary{padding:8px 0;cursor:pointer;color:var(--rt-accent);font-weight:600}.rt-cache input[type=number]{width:84px;min-height:36px;background:var(--rt-container);color:var(--rt-text);border:0;border-radius:8px;padding:6px} .rt-clear-cache{padding:7px 14px;border-radius:16px;background:var(--rt-container);color:var(--rt-accent);border:0;font-weight:600;cursor:pointer} .rt-reader-actions{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin:0 0 8px}.rt-reader-actions[hidden]{display:none}.rt-reader-actions{grid-template-columns:repeat(3,minmax(0,1fr))}.rt-reader-actions button,.rt-reader-actions a{display:flex;align-items:center;justify-content:center;min-height:44px;border-radius:24px;background:var(--rt-container);color:var(--rt-accent);font-weight:600;padding:8px 4px;font-size:13px}.rt-reader-actions .reply-entry{background:var(--rt-accent);color:var(--rt-surface)} @keyframes rt-sheet-enter{from{transform:translateY(48px);opacity:0}to{transform:translateY(0);opacity:1}} @media(prefers-reduced-motion:reduce){.rt-sheet{animation:none}.rt-fab,.rt-sheet button,.rt-sheet input[type=checkbox],.rt-sheet input[type=checkbox]:before{transition:none}} .app{font-family:var(--rt-font,system-ui);max-width:100vw;overflow-x:hidden;text-size-adjust:100%;-webkit-text-size-adjust:100%}.app main,.grid,.bar{min-width:0;width:100%}.grid>.card{min-width:0;max-width:100%} .app.rt-settings-open{padding-bottom:calc(30px + var(--rt-panel-height,260px) + env(safe-area-inset-bottom))}.app .comment-content{font-size:calc(15px * var(--rt-scale,1))}.app .comment.op .comment-content{font-size:calc(16px * var(--rt-scale,1))}.app .thread .comment-content{font-size:calc(14px * var(--rt-scale,1))}.app .mobile-caption,.app .image-title{font-size:calc(14px * var(--rt-scale,1))}.app .cover .title{font-size:calc(18px * var(--rt-scale,1))} .app.rt-paper{background:#f6f2e9}.app.rt-paper .top,.app.rt-paper .card,.app.rt-paper .comment.op{background:#faf7ef}.app.rt-dark{background:#17191d;color:#e0e0e5;color-scheme:dark}.app.rt-dark .top,.app.rt-dark .card,.app.rt-dark .comment{background:#202329;border-color:#34373e}.app.rt-dark .comment-content,.app.rt-dark .tabs button[aria-selected=true],.app.rt-dark .tabs button[aria-pressed=true]{color:#eee}.app.rt-dark .quoted,.app.rt-dark .search input,.app.rt-dark .load-next{background:#2c3038;color:#bbb}.app.rt-dark .cover:not(.has-image){background:#30343d}.app.rt-dark .cover .title,.app.rt-dark .eyebrow{color:#ddd}.app.rt-dark .tag{background:#444;color:#ddd} @media(max-width:600px){ .rt-mask{align-items:flex-end;padding:0} .rt-sheet{width:100vw;max-width:100vw;max-height:86dvh;border-radius:20px 20px 0 0;border:0;box-shadow:0 -8px 36px rgba(0,0,0,.25)} .rt-handle{display:flex} .rt-header{padding:6px 16px 8px} .rt-body{padding:12px 16px calc(14px + env(safe-area-inset-bottom))} } @media(min-width:601px){.rt-mask{align-items:center;justify-content:center;padding:20px}.rt-sheet{border-radius:24px}.rt-fab{bottom:24px}.app .cover .title{font-size:calc(20px * var(--rt-scale,1))}.app .image-title{font-size:calc(16px * var(--rt-scale,1))}} `; shadow.append(style); const fab = document.createElement('button'); fab.type = 'button'; fab.className = 'rt-fab'; fab.innerHTML = ''; fab.setAttribute('aria-haspopup', 'dialog'); fab.setAttribute('aria-controls', 'readscape-settings'); fab.setAttribute('aria-label', '阅读设置'); fab.setAttribute('aria-expanded', 'false'); const mask = document.createElement('dialog'); mask.id = 'readscape-settings'; mask.setAttribute('aria-label', '阅读设置'); mask.className = 'rt-mask'; mask.hidden = true; const sheet = document.createElement('section'); sheet.className = 'rt-sheet'; sheet.tabIndex = -1; const header = document.createElement('div'); header.className = 'rt-header'; const title = document.createElement('h2'); title.innerHTML = '⚙️ 阅读设置'; const close = document.createElement('button'); close.className = 'rt-close'; close.textContent = '×'; close.setAttribute('aria-label', '关闭设置'); header.append(title, close); const handle = document.createElement('div'); handle.className = 'rt-handle'; handle.setAttribute('aria-hidden', 'true'); const body = document.createElement('div'); body.className = 'rt-body'; sheet.append(handle, header, body); let dragStart; handle.addEventListener('pointerdown', event => { dragStart = event.clientY; handle.setPointerCapture?.(event.pointerId); }); handle.addEventListener('pointerup', event => { if (dragStart != null && event.clientY - dragStart > 60) hide(); dragStart = null; }); handle.addEventListener('pointercancel', () => { dragStart = null; }); handle.addEventListener('touchstart', e => { if (e.touches.length !== 1) return; dragStart = e.touches[0].clientY; }, { passive: true }); handle.addEventListener('touchmove', e => { if (dragStart == null) return; const deltaY = e.touches[0].clientY - dragStart; if (deltaY > 0) { if (e.cancelable) e.preventDefault(); sheet.style.transform = `translateY(${deltaY}px)`; } }, { passive: false }); handle.addEventListener('touchend', () => { if (dragStart == null) return; const currentY = Number(sheet.style.transform.match(/translateY\(([\d.]+)px\)/)?.[1] || 0); if (currentY > 80) { sheet.style.transform = 'translateY(100%)'; setTimeout(() => { hide(); sheet.style.transform = ''; }, 160); } else { sheet.style.transform = ''; } dragStart = null; }, { passive: true }); const controls = new Map(); const readerActions = document.createElement('div'); readerActions.className = 'rt-reader-actions'; readerActions.hidden = true; body.append(readerActions); if (openBoards) { const boardsBtn = document.createElement('button'); boardsBtn.type = 'button'; boardsBtn.className = 'rt-boards-btn'; boardsBtn.innerHTML = '🧭 切换论坛板块 ➔'; boardsBtn.onclick = () => { hide(true, true); openBoards(); }; body.append(boardsBtn); } const THEME_COLORS = { light: '#ffffff', paper: '#faf7ef', dark: '#17191d' }; const originalThemeColor = document.querySelector('meta[name="theme-color"]'); const originalThemeColorContent = originalThemeColor?.getAttribute('content'); const themeColorMeta = originalThemeColor || document.createElement('meta'); themeColorMeta.name = 'theme-color'; function syncThemeMetadata(active = !fab.hidden) { const theme = prefs.theme || 'light'; const color = THEME_COLORS[theme] || THEME_COLORS.light; const docs = [document]; try { if (context.parent && context.parent !== context && context.parent.document) { docs.push(context.parent.document); } } catch {} for (const doc of docs) { if (active) { doc.documentElement.dataset.rtTheme = theme; let meta = doc.querySelector('meta[name="theme-color"]'); if (!meta) { meta = doc.createElement('meta'); meta.name = 'theme-color'; doc.head?.append(meta); } meta.setAttribute('content', color); } else { doc.documentElement.removeAttribute('data-rt-theme'); const meta = doc.querySelector('meta[name="theme-color"]'); if (meta) { if (doc === document && originalThemeColor) { if (originalThemeColorContent == null) meta.removeAttribute('content'); else meta.setAttribute('content', originalThemeColorContent); } else if (doc === document && !originalThemeColor) { meta.remove(); } } } } } function apply() { const scale = Math.min(1.4, Math.max(.85, Number(prefs.fontScale) || 1)); app.style.setProperty('--rt-scale', scale); app.style.setProperty('--rt-font', prefs.font === 'serif' ? '"Songti SC","Noto Serif CJK SC",serif' : prefs.font === 'rounded' ? 'ui-rounded,"Arial Rounded MT Bold","PingFang SC",sans-serif' : '-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif'); const theme = prefs.theme || 'light'; mask.dataset.rtTheme = fab.dataset.rtTheme = theme; app.classList.toggle('rt-paper', theme === 'paper'); app.classList.toggle('rt-dark', theme === 'dark'); syncThemeMetadata(!fab.hidden); } function row(label, key, type, options, target = body) { const wrap = document.createElement('label'); const text = document.createElement('span'); text.textContent = label; const c = document.createElement(type === 'select' ? 'select' : 'input'); c.setAttribute('aria-label', label); c.dataset.pref = key; if (type === 'select') for (const [value, name] of options) { const opt = document.createElement('option'); opt.value = value; opt.textContent = name; c.append(opt); } else { c.type = type; if (type === 'checkbox') c.setAttribute('role', 'switch'); if (type === 'range') { c.min = '.85'; c.max = '1.4'; c.step = '.05'; } } const update = () => { prefs[key] = type === 'checkbox' ? c.checked : type === 'range' || type === 'number' ? Number(c.value) : c.value; if (key === 'cacheMaxPosts') prefs[key] = Math.max(1, Math.min(500, Math.trunc(prefs[key]) || 0)); save(); sync(); apply(); configureCache(); change(); }; c.addEventListener(type === 'range' ? 'input' : 'change', update); controls.set(key, c); wrap.append(text); if (type === 'range') { const group = document.createElement('span'); group.className = 'rt-range'; const value = document.createElement('output'); value.className = 'rt-scale'; value.setAttribute('aria-label', '当前字号'); group.append(c, value); wrap.append(group); } else wrap.append(c); target.append(wrap); } row('字号', 'fontScale', 'range'); row('字体', 'font', 'select', [['system','系统字体'],['serif','宋体 / 衬线'],['rounded','圆润字体']]); const more = document.createElement('details'); more.className = 'rt-more'; const moreTitle = document.createElement('summary'); moreTitle.textContent = '更多设置'; more.append(moreTitle); body.append(more); row('配色', 'theme', 'select', [['light','明亮'],['paper','暖纸'],['dark','深色']], more); row('手机单列', 'single', 'checkbox', undefined, more); row('关联对话', 'groupReplies', 'checkbox', undefined, more); row('平滑跳转过渡', 'smoothNavigation', 'checkbox', undefined, more); row('列表自动刷新(10 秒)', 'autoListRefresh', 'checkbox', undefined, more); const note = document.createElement('p'); note.className = 'rt-note'; note.textContent = '字体使用设备现有字体。单双列适用于列表;关联对话适用于评论,关闭时按楼层顺序阅读。'; more.append(note); const actions = document.createElement('div'); actions.className = 'rt-actions'; const reset = document.createElement('button'); reset.textContent = '恢复默认'; const native = document.createElement('button'); native.textContent = '查看原版'; actions.append(reset, native); more.append(actions); const cacheSection = document.createElement('section'); cacheSection.className = 'rt-cache'; cacheSection.hidden = !cache; const cacheUsage = document.createElement('p'); cacheUsage.className = 'rt-cache-usage'; cacheUsage.setAttribute('role', 'status'); cacheUsage.textContent = '正在统计缓存…'; const cacheOptions = document.createElement('details'); const cacheTitle = document.createElement('summary'); cacheTitle.textContent = '缓存设置'; cacheOptions.append(cacheTitle); row('本地缓存', 'cacheEnabled', 'checkbox', undefined, cacheOptions); row('帖子上限', 'cacheMaxPosts', 'number', undefined, cacheOptions); { const input = controls.get('cacheMaxPosts'); input.min = '1'; input.max = '500'; input.step = '1'; } const clearCache = document.createElement('button'); clearCache.type = 'button'; clearCache.className = 'rt-clear-cache'; clearCache.textContent = '清理缓存'; const cacheNote = document.createElement('p'); cacheNote.className = 'rt-note'; cacheNote.textContent = '7 天未访问自动清理。手动清理保留收藏书签和阅读设置。'; cacheSection.append(cacheUsage, clearCache, cacheOptions, cacheNote); body.append(cacheSection); let cacheVersion = 0, clearingCache = false; const formatBytes = bytes => bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(1)} MB`; async function refreshCache() { if (!cache || clearingCache) return; const version = ++cacheVersion, used = await cache.stats(); if (version !== cacheVersion || clearingCache) return; if (!used.available) { cacheUsage.textContent = '本地缓存不可用,仍可正常阅读。'; clearCache.disabled = true; return; } clearCache.disabled = false; cacheUsage.textContent = `缓存估算 ${formatBytes(used.totalBytes)} · 帖子 ${used.posts}/${used.maxPosts} · 回复 ${used.replyPages} 页${used.enabled ? '' : ' · 缓存已关闭'}`; } function configureCache() { cache?.configure({enabled:prefs.cacheEnabled !== false,maxPosts:prefs.cacheMaxPosts ?? 500}).then(refreshCache); } clearCache.onclick = async () => { if (clearingCache) return; clearingCache = true; cacheVersion++; clearCache.disabled = true; cacheUsage.textContent = '正在清理缓存…'; const cleared = await cache.clear(); clearingCache = false; if (cleared) { await refreshCache(); cacheUsage.textContent = `缓存已清理 · ${cacheUsage.textContent}`; } else { cacheUsage.textContent = '缓存清理失败,请重试。'; clearCache.disabled = false; } }; let cacheRefreshTimer; const unsubscribeCache = cache?.subscribe(() => { if (!mask.hidden) { context.clearTimeout(cacheRefreshTimer); cacheRefreshTimer=context.setTimeout(refreshCache,80); } }); context.addEventListener('pagehide', () => { unsubscribeCache?.(); context.clearTimeout(cacheRefreshTimer); }); function sync() { for (const [key,c] of controls) { if (c.type === 'checkbox') c.checked = ['smoothNavigation','cacheEnabled','autoListRefresh'].includes(key) ? prefs[key] !== false : !!prefs[key]; else c.value = prefs[key] ?? ({fontScale:1,font:'system',theme:'light',cacheMaxPosts:500}[key]); } sheet.querySelector('.rt-scale').textContent = `${Math.round(Number(controls.get('fontScale').value) * 100)}%`; } const account = document.createElement('button'); account.className = 'rt-account'; account.textContent = '登录'; account.onclick = () => { hide(true, true); login?.(account); }; if (login) header.insertBefore(account, close); let opener = fab, sheetAnimation, animationVersion = 0, closing = false; const measurePanel = () => app.style.setProperty('--rt-panel-height', `${Math.ceil(sheet.getBoundingClientRect().height)}px`); const panelObserver = typeof context.ResizeObserver === 'function' ? new context.ResizeObserver(measurePanel) : null; panelObserver?.observe(sheet); context.addEventListener('pagehide', () => { panelObserver?.disconnect(); syncThemeMetadata(false); }); function motionFrames() { const source = opener.getBoundingClientRect(), target = sheet.getBoundingClientRect(); const x = source.left + source.width / 2 - target.left - target.width / 2; const y = source.top + source.height / 2 - target.top - target.height / 2; return [{opacity:0,transform:`translate(${x * .16}px,${y * .12}px) scale(.92)`},{opacity:1,transform:'translate(0,0) scale(1)'}]; } const canAnimate = () => typeof sheet.animate === 'function' && !context.matchMedia?.('(prefers-reduced-motion: reduce)').matches; function hide(restoreFocus = true, immediate = false) { if (mask.hidden) return; const version = ++animationVersion; sheetAnimation?.cancel(); closing = true; fab.setAttribute('aria-expanded','false'); const finish = () => { if (version !== animationVersion) return; if (typeof mask.close === 'function' && mask.open) mask.close(); else mask.removeAttribute('open'); mask.hidden = true; closing = false; app.classList.remove('rt-settings-open'); if (!shadow.querySelector('.board-dialog[open]')) { document.documentElement.removeAttribute('data-readscape-modal-open'); } if (restoreFocus && opener.isConnected && !app.hidden) opener.focus({ preventScroll: true }); }; if (!immediate && canAnimate()) { sheetAnimation = sheet.animate(motionFrames().reverse(), {duration:180,easing:'cubic-bezier(.4,0,1,1)',fill:'forwards'}); sheetAnimation.finished.then(finish, () => {}); } else finish(); } function open(trigger = fab) { if (!mask.hidden && !closing) return; animationVersion++; closing = false; sheetAnimation?.cancel(); sync(); readerActions.querySelectorAll('a').forEach(a => a.refreshHref?.()); opener = trigger; mask.hidden = false; document.documentElement.setAttribute('data-readscape-modal-open', ''); if (!mask.open) { if (typeof mask.show === 'function') mask.show(); else mask.setAttribute('open', ''); } app.classList.add('rt-settings-open'); measurePanel(); refreshCache(); if (canAnimate()) sheetAnimation = sheet.animate(motionFrames(), {duration:260,easing:'cubic-bezier(.2,.8,.2,1)'}); fab.setAttribute('aria-expanded','true'); close.focus({ preventScroll: true }); } fab.onclick = () => open(); mask.addEventListener('cancel', event => { event.preventDefault(); hide(); }); close.onclick = () => hide(); native.onclick = () => { hide(true, true); original(); }; reset.onclick = () => { Object.assign(prefs,{fontScale:1,font:'system',theme:'light',single:false,groupReplies:false,smoothNavigation:true,autoListRefresh:true,cacheEnabled:true,cacheMaxPosts:500}); save(); sync(); apply(); configureCache(); change(); }; mask.onclick = e => { if (e.target === mask) hide(); }; mask.addEventListener('touchmove', event => { if (event.target === mask) { if (event.cancelable) event.preventDefault(); } }, { passive: false }); mask.addEventListener('keydown', e => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); hide(); } }); mask.append(sheet); shadow.append(fab, mask); sync(); apply(); configureCache(); return { sync, open, setAccount(info) { account.replaceChildren(); if (!info) { account.textContent = '未登录 · 登录'; account.setAttribute('aria-label', '未登录,登录 NGA'); return; } const name = document.createElement('span'); name.className = 'account-name'; name.textContent = info.username; const uid = document.createElement('span'); uid.className = 'account-uid'; uid.textContent = `UID ${info.uid}`; account.append(name, uid); account.setAttribute('aria-label', `${info.username},UID ${info.uid},查看个人资料`); }, setActions(items) { readerActions.replaceChildren(); readerActions.hidden = !items.length; return items.map(({ label, className, action, href, target = '_top' }) => { const control = document.createElement(href ? 'a' : 'button'); if (!href) control.type = 'button'; control.className = className; control.textContent = label; if (href) { control.href = href(); control.target = target; if (target === '_blank') control.rel = 'noopener noreferrer'; control.dataset.readscapeNative = 'true'; control.refreshHref = () => { control.href = href(); }; } control.onclick = () => { control.refreshHref?.(); hide(true, true); action?.(); }; readerActions.append(control); return control; }); }, visibility(enabled) { fab.hidden = !enabled; if (!enabled) hide(false, true); syncThemeMetadata(enabled); } }; } function runAdapter({ navigation, postCache, context = globalThis.window }) { const window = context; const { document, location, localStorage, sessionStorage } = window; const fetch = (...args) => window.fetch(...args); if (document.getElementById('nga-cards-host')) return; const KEY = 'nga-cards-v1'; const read = (key, fallback) => { try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch { return fallback; } }; const write = (key, value) => { try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 禁用存储时仍可阅读 */ } }; const prefs = read(KEY, { enabled: true, single: false }); const favorites = read(`${KEY}-favorites`, {}), favoriteChanges = new Set(); const pageURL = new URL(location.href); const host = document.createElement('div'); host.id = 'nga-cards-host'; // Shadow DOM 避免 NGA 自带 CSS 与卡片样式互相影响。 const shadow = host.attachShadow({ mode: 'open' }); const originalOverflow = document.documentElement.style.overflow; let nativeScroll, readingScroll = 0; const mobileViewport = window.matchMedia?.('(max-width:600px)'); // 原页只作为数据源保留。阅读容器放在 body 外,避免原站的布局、 // transform 和触摸滚动影响前景;晚到的原页节点也不会重新露出。 const surfaceStyle = document.createElement('style'); surfaceStyle.textContent = ` html[data-readscape-active]{overflow:hidden!important;overscroll-behavior:none!important;height:100%!important;margin:0!important;padding:0!important;transform:none!important;filter:none!important;perspective:none!important;contain:none!important;content-visibility:visible!important;background:#fafafa!important} html[data-readscape-active][data-rt-theme=paper]{background:#f6f2e9!important} html[data-readscape-active][data-rt-theme=dark]{background:#17191d!important;color-scheme:dark} html[data-readscape-active]>body{display:none!important} html[data-readscape-active]::before,html[data-readscape-active]::after{display:none!important} html[data-readscape-active]>#nga-cards-host{all:initial!important;display:block!important;position:fixed!important;inset:0!important;width:100%!important;height:100%!important;height:100dvh!important;z-index:2147483000!important;isolation:isolate!important} html[data-readscape-document-scroll]{overflow-x:clip!important;overflow-y:auto!important;height:auto!important;min-height:100%!important;overscroll-behavior-y:auto!important} html[data-readscape-document-scroll]>#nga-cards-host{position:relative!important;inset:auto!important;height:auto!important;min-height:100svh!important;contain:none!important} html[data-readscape-reader-open],html[data-readscape-modal-open]{overflow:hidden!important;overscroll-behavior:none!important} html[data-readscape-modal-open]>body{overflow:hidden!important;touch-action:none!important} `; document.head.append(surfaceStyle); const originalViewport = document.querySelector('meta[name="viewport"]'); const originalViewportContent = originalViewport?.getAttribute('content'); const viewport = originalViewport || document.createElement('meta'); viewport.name = 'viewport'; let items = [], tab = 'all', query = '', signature = '', timer; const isForumRoot = /^\/(?:index\.php|forum\.php)?$/.test(pageURL.pathname) && !pageURL.searchParams.has('fid') && !pageURL.searchParams.has('stid') && !pageURL.searchParams.has('tid'); let cardObserver, listObserver; const listCards = new Map(); let coverDisposed = false; let gateHandled = false, gateTimer; let modeToggle = toggle; let settingsRefresh = render; let readingSettings; let accountChanged = () => {}; const css = "\n :host{all:initial;font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"PingFang SC\",\"Microsoft YaHei\",sans-serif;color:#27272b;font-size:14px;line-height:1.5}\n *{box-sizing:border-box}button,input,a{-webkit-tap-highlight-color:transparent}button,input{font:inherit}button,a{touch-action:manipulation}button{cursor:pointer}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #ff7994;outline-offset:3px}\n a{color:inherit;text-decoration:none}button{border:0;background:none;color:inherit}button:disabled{cursor:default;opacity:.5}[hidden]{display:none!important}\n .app{position:absolute;inset:0;z-index:2147483000;background:#fafafa;overflow:auto;overscroll-behavior:none;touch-action:pan-x pan-y pinch-zoom;scrollbar-gutter:stable;-webkit-overflow-scrolling:touch;padding-bottom:calc(30px + env(safe-area-inset-bottom));color-scheme:light}\n .top{position:sticky;top:0;z-index:3;background:rgba(255,255,255,.96);border-bottom:1px solid #ededf0;padding:calc(14px + env(safe-area-inset-top)) 24px 14px;backdrop-filter:blur(16px)}\n .bar{max-width:1440px;margin:auto;display:flex;align-items:center;gap:18px}.brand{font-size:23px;font-weight:850;letter-spacing:-1px;white-space:nowrap}.brand span{color:#ff2442}\n .search{flex:1;max-width:460px;position:relative}.search input{width:100%;border:1px solid transparent;border-radius:28px;padding:11px 18px;background:#f4f4f6;color:#26262a;outline:none}.search input:focus{border-color:#ff2442}\n .spacer{flex:1}.pill{padding:9px 15px;background:#f1f1f4;border-radius:22px;white-space:nowrap;font-size:13px}.pill:hover{background:#e8e8ed}\n main{max-width:1440px;margin:auto;padding:28px 24px}.intro{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:16px}h1{font-size:25px;letter-spacing:-.7px;margin:0 0 5px}.sub{font-size:12px;color:#8b8b95}.settings{display:none;gap:12px;flex-wrap:wrap;align-items:center;font-size:12px;color:#666671}.settings label{cursor:pointer;display:flex;align-items:center;gap:5px;min-height:36px}.settings input{accent-color:#ff2442}\n .tabs{display:flex;gap:7px;margin:18px 0 23px}.tabs button{padding:9px 20px;border-radius:22px;color:#777781}.tabs button[aria-selected=true]{background:#ff2442;color:#fff;font-weight:650}\n .subforum-strip{display:flex!important;align-items:center;gap:8px;overflow-x:auto!important;width:100%;max-width:100%;box-sizing:border-box;padding:6px 0 12px;margin-top:-4px;scrollbar-width:none;-webkit-overflow-scrolling:touch!important;touch-action:pan-x!important;overscroll-behavior-x:contain}\n .subforum-strip[hidden]{display:none!important}\n .subforum-strip::-webkit-scrollbar{display:none}\n .subforum-chip{display:inline-flex;align-items:center;gap:5px;padding:6px 13px;border-radius:16px;background:#f1f1f4;color:#555;font-size:12px;white-space:nowrap;transition:background .15s,color .15s;text-decoration:none;flex:none;touch-action:pan-x;-webkit-tap-highlight-color:transparent}\n .subforum-chip:hover{background:#e6e6eb;color:#222}\n .subforum-chip.is-active{background:#ff2442;color:#fff;font-weight:600}\n .subforum-chip-more{background:#fff;border:1px dashed #dcdce2;color:#a43d60;cursor:pointer}\n .subforum-chip-more:hover{background:#fff0f3;border-color:#ff8ba0;color:#ff2442}\n .grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:20px;align-items:start}.card{border-radius:18px;overflow:hidden;background:#fff;border:1px solid #eeeeef;box-shadow:0 2px 6px #00000003;transition:transform .18s,box-shadow .18s;min-width:0}.card:hover{transform:translateY(-3px);box-shadow:0 9px 24px #00000009}\n .cover{display:flex;flex-direction:column;justify-content:space-between;min-height:190px;padding:20px;background:var(--bg);position:relative;overflow:hidden}.cover:after{content:'';width:110px;height:110px;border-radius:100%;border:20px solid #ffffff60;position:absolute;right:-35px;bottom:-35px;pointer-events:none}.eyebrow{display:flex;gap:5px;align-items:center;font-size:10px;letter-spacing:1px;color:#0008}.tag{font-size:10px;border-radius:6px;padding:2px 6px;background:#ffffff90;letter-spacing:0;color:#59575b}.title{font-size:20px;font-weight:750;line-height:1.5;letter-spacing:-.5px;position:relative;z-index:1;overflow-wrap:anywhere;margin:13px 0 8px;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.cover.has-image{padding:0;min-height:0}.cover img{width:100%;height:220px;object-fit:cover;display:block;background:#f3f3f3}.image-title{font-size:16px;line-height:1.55;font-weight:700;margin:0 0 9px;overflow-wrap:anywhere}\n .body{padding:13px 15px 14px}.summary{font-size:12px;color:#7a7a84;line-height:1.8;margin:0 0 11px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}.person{display:flex;align-items:center;gap:7px;min-width:0}.avatar{display:flex;align-items:center;justify-content:center;width:23px;height:23px;border-radius:50%;background:var(--bg);color:#777;font-size:11px;flex:none}.author{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;color:#686872}.save{font-size:20px;line-height:1;padding:6px;flex:none}.save[aria-pressed=true]{color:#ff2442}.meta{display:flex;align-items:center;gap:8px;justify-content:space-between;font-size:10px;color:#9999a2;margin-top:9px;flex-wrap:wrap}.latest{color:#777781;text-decoration:underline;text-underline-offset:3px}.preview{font-size:11px;padding:5px 0;color:#9c7781;margin-top:7px;text-align:left}\n .pager{display:flex;justify-content:center;flex-wrap:wrap;gap:7px;margin:30px 0 12px}.pager a,.pager span{padding:8px 14px;border-radius:12px;background:#fff;border:1px solid #eee}.pager .current{background:#ff2442;color:white;border-color:#ff2442}.empty{text-align:center;padding:55px 16px;color:#999;font-size:14px}.foot{text-align:center;font-size:11px;color:#999;margin-top:18px}.restore{position:fixed;right:16px;bottom:calc(18px + env(safe-area-inset-bottom));z-index:2147483001;border-radius:24px;background:#ff2442;color:#fff;padding:12px 18px;box-shadow:0 4px 16px #ff244233}\n @media(min-width:1500px){.grid{grid-template-columns:repeat(6,minmax(0,1fr))}}\n @media(max-width:1150px){.grid{grid-template-columns:repeat(4,minmax(0,1fr))}.spacer{display:none}}\n @media(max-width:900px){.grid{grid-template-columns:repeat(3,minmax(0,1fr))}.intro{align-items:flex-start;flex-direction:column}.cover{min-height:170px}}\n @media(max-width:600px){.top{padding:calc(10px + env(safe-area-inset-top)) 12px 10px}.bar{gap:10px;flex-wrap:wrap}.brand{font-size:21px}.search{order:3;max-width:none;flex-basis:100%}.bar>.pill{margin-left:auto;padding:7px 12px}.search input{padding:9px 15px}main{padding:20px 12px}.intro{gap:5px;margin-bottom:6px}h1{font-size:21px}.settings{gap:12px}.tabs{margin:12px 0 16px}.tabs button{padding:8px 17px}.grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.card{border-radius:13px}.cover{min-height:178px;padding:14px}.title{font-size:17px;line-height:1.55;-webkit-line-clamp:5}.body{padding:10px 11px}.cover img{height:180px}.image-title{font-size:14px}.summary{font-size:11px}.avatar{width:20px;height:20px}.author{font-size:11px}.meta{font-size:10px;gap:4px}.grid.single{grid-template-columns:1fr}.grid.single .cover{min-height:120px}.grid.single .title{-webkit-line-clamp:3}.grid.single .cover img{height:240px}}\n @media(prefers-reduced-motion:reduce){.card{transition:none}.card:hover{transform:none}}\n .mobile-tool,.mobile-caption,.settings-native{display:none}\n @media(max-width:600px){\n .app{background:#fff}.top{background:#fff;border-bottom:0;padding:calc(8px + env(safe-area-inset-top)) 14px 8px;backdrop-filter:none}.bar{flex-wrap:nowrap!important;gap:6px;align-items:center}.board-bar-btn{display:none!important}.session-status{font-size:12px;color:#a43d60;background:#fff0f3;border-radius:14px;padding:4px 9px;min-height:28px;display:inline-flex;align-items:center;white-space:nowrap;flex:none}.brand{font-size:18px;letter-spacing:0;background:#ff2442;color:white;border-radius:22px;padding:5px 12px;line-height:1.5;flex:none}.brand span{color:white}\n .mobile-tool{display:inline-flex;align-items:center;justify-content:center;min-width:38px;min-height:38px;border-radius:50%;font-size:22px;color:#555;flex:none}.mobile-tool:first-of-type{margin-left:auto}.bar>.pill{display:none}.bar>.search{display:none}.app.search-open .bar{flex-wrap:wrap!important}.app.search-open .search{display:block;order:3;flex-basis:100%;margin-top:6px}.search input{font-size:16px;background:#f6f6f7}\n main{padding:10px 12px 24px}.intro{gap:8px;margin:5px 2px 0}h1{font-size:17px;letter-spacing:0;font-weight:650}.sub{font-size:11px;color:#aaa}.settings{display:none!important}.settings-native{display:inline-block;font-size:12px;color:#a66d7d;padding:7px 0}\n .tabs{gap:26px;overflow-x:auto;scrollbar-width:none;margin:14px 2px 21px}.tabs button{padding:8px 0;border-radius:0;background:none;font-size:16px;color:#888;flex:none;border-bottom:2px solid transparent}.tabs button[aria-selected=true]{background:none;color:#242428;font-weight:700;border-bottom-color:#ff2442}\n .grid{gap:18px 12px}.card{border:0;background:transparent;border-radius:0;box-shadow:none}.card:hover{transform:none;box-shadow:none}.cover{border-radius:13px}.cover:not(.has-image){aspect-ratio:4/5;min-height:0;justify-content:center;padding:16px}.cover .eyebrow{position:absolute;top:14px;left:14px;font-size:9px}.cover .title{font-size:18px;line-height:1.65;-webkit-line-clamp:4;margin:20px 0 0;letter-spacing:0}.cover img{height:auto;aspect-ratio:4/5;object-fit:cover;border-radius:13px}\n .body{padding:9px 2px 0}.mobile-caption{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:14px;font-weight:650;line-height:1.55;overflow-wrap:anywhere;margin:0 0 6px}.with-image .mobile-caption{display:none}.image-title{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:14px;font-weight:650;line-height:1.55;margin:0 0 6px}.body>.summary{display:none}.person{gap:5px}.avatar{height:19px;width:19px;font-size:10px}.author{font-size:11px;color:#898991}.save{font-size:23px;padding:5px 0 5px 8px;min-width:32px;min-height:32px;color:#888}.meta{margin-top:2px;font-size:10px;color:#aaa;gap:3px}.latest{font-size:10px;color:#aaa;text-decoration:none}.preview{font-size:10px;color:#aa8893;margin-top:2px;padding:4px 0}.foot{display:none}.pager{margin-top:28px}\n .grid.single .cover:not(.has-image){aspect-ratio:auto;min-height:155px}.grid.single .cover img{height:260px;aspect-ratio:auto;object-fit:contain;background:#f7f7f8}.grid.single .mobile-caption{font-size:16px}\n }\n\n .grid.masonry{grid-auto-rows:4px;row-gap:0}\n .grid.masonry>.card{align-self:start;margin-bottom:18px}\n .cover:not(.has-image){min-height:150px;padding:54px 24px 28px;justify-content:center}\n .cover .eyebrow{position:absolute;top:18px;left:24px}\n .cover .title{margin:0}\n @media(max-width:600px){\n .cover:not(.has-image){aspect-ratio:auto;min-height:132px;padding:50px 20px 24px}\n .cover .eyebrow{top:16px;left:20px}\n .cover .title{margin:0;line-height:1.55;-webkit-line-clamp:6}\n .grid.single .cover:not(.has-image){min-height:110px}\n }\n\n .toolbar-menu{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:44px;margin-left:auto;color:#555}\n .settings-login,.settings-reading{font-size:12px;color:#a66d7d;min-height:36px;padding:7px 0}\n .session-status{font-size:12px;color:#8b8b95;white-space:nowrap;min-height:36px;padding:7px 4px}\n .cover.cached-cover{padding:0;min-height:0;background:#fff;justify-content:flex-start}\n .cover.cached-cover:before{display:none}\n .cover.cached-cover:after{display:none}\n .cover.cached-cover>.cached-cover-image{position:static;inset:auto;z-index:auto;width:100%;height:auto;aspect-ratio:3/4;object-fit:cover;display:block;background:#f3f3f3}\n .cover.cached-cover>.eyebrow{position:static;top:auto;left:auto;padding:10px 12px 0}\n .cover.cached-cover .tag{background:#f1f1f4}\n .cover.cached-cover>.title{position:static;z-index:auto;color:inherit;text-shadow:none;margin:5px 0 0;padding:0 12px 13px;font-size:15px;line-height:1.5;letter-spacing:0;-webkit-line-clamp:2}\n .intro>.settings{display:none!important}\n .settings-login{text-align:left;max-width:180px}.settings-login .account-uid{font-size:10px}\n\n .app.document-scroll{position:relative;inset:auto;min-height:100svh;overflow:visible;overscroll-behavior:auto;scrollbar-gutter:auto}\n .document-scroll .top{transition:transform .2s ease}\n .document-scroll.rt-chrome-hidden .top{transform:translateY(-100%)}\n @media(prefers-reduced-motion:reduce){.document-scroll .top{transition:none}}\n\n .list-refresh-bar{min-height:42px;display:flex;align-items:center;justify-content:space-between;gap:12px;margin:-8px 0 14px;font-size:12px;color:#8b8b95}\n .list-refresh-status{display:flex;align-items:center;gap:7px;min-width:0}\n .list-refresh{flex:none;color:#ff2442;background:#fff0f3;border-radius:20px;padding:7px 12px;transition:background .16s}\n .list-refresh:hover{background:#ffe1e8}.list-refresh:active{background:#ffd4df}\n .list-refresh-bar.is-loading .list-refresh-status:before,.list-load:disabled:before{content:'';display:inline-block;width:12px;height:12px;border:2px solid #ffd4df;border-top-color:#ff2442;border-radius:50%;animation:list-spin .7s linear infinite;flex:none}\n .list-load:disabled{display:inline-flex;align-items:center;gap:7px;opacity:.85}\n .card-enter{animation:card-appear .24s ease-out both}.cover{transition:filter .12s}.cover:active{filter:brightness(.96)}\n .save-pop{animation:save-pop .28s ease-out}\n @keyframes card-appear{from{opacity:0}to{opacity:1}}\n @keyframes save-pop{0%,100%{transform:scale(1)}45%{transform:scale(1.2)}}\n @keyframes list-spin{to{transform:rotate(360deg)}}\n @media(prefers-reduced-motion:reduce){.card-enter,.save-pop,.list-refresh-bar.is-loading .list-refresh-status:before,.list-load:disabled:before{animation:none}.cover,.list-refresh{transition:none}}\n .list-preparing .empty{min-height:250px;border-radius:18px;color:#999;background:linear-gradient(100deg,#f5f5f7 30%,#fff 45%,#f5f5f7 60%);background-size:250% 100%;animation:list-shimmer 1.4s ease-in-out infinite}\n @keyframes list-shimmer{from{background-position:100% 0}to{background-position:-100% 0}}\n @media(prefers-reduced-motion:reduce){.list-preparing .empty{animation:none}}\n .list-refresh-bar.has-update{position:sticky;top:90px;z-index:2;padding:0 12px;border-radius:12px;background:#fff;box-shadow:0 3px 18px #21132110}\n @media(max-width:600px){.list-refresh-bar.has-update{top:calc(60px + env(safe-area-inset-top))}.document-scroll.rt-chrome-hidden .list-refresh-bar.has-update{top:8px}}\n .list-pull{position:fixed;top:calc(64px + env(safe-area-inset-top));left:50%;transform:translate(-50%,-8px);z-index:4;pointer-events:none;padding:10px 18px;border-radius:24px;background:#fff;color:#8b8b95;box-shadow:0 3px 18px #21132118;opacity:0;transition:opacity .16s,transform .16s;white-space:nowrap}\n .list-pull.is-visible{opacity:1;transform:translate(-50%,0)}.list-pull.is-ready{color:#ff2442;background:#fff0f3}\n .card-updated{animation:card-update .45s ease-out}\n @keyframes card-update{from{opacity:.65}to{opacity:1}}\n .app.rt-dark .list-refresh-bar,.app.rt-dark .list-pull{background:#202329;color:#bbb}.app.rt-dark .list-refresh{background:#3e2732;color:#ffb6c6}\n @media(prefers-reduced-motion:reduce){.card-updated{animation:none}.list-pull{transition:none}}\n\n .notice-dialog{position:fixed;z-index:2147483020;inset:0;margin:auto;width:min(420px,calc(100vw - 32px));height:fit-content;max-height:calc(100dvh - 40px);padding:24px 22px 20px;border:1px solid #ededf0;border-radius:20px;background:#fff;color:#27272b;box-shadow:0 12px 40px #00000024;font:14px/1.6 system-ui;display:flex;flex-direction:column;gap:14px;box-sizing:border-box}\n .notice-dialog:not([open]){display:none}\n .notice-dialog::backdrop{background:#0000004d;backdrop-filter:blur(3px)}\n .notice-dialog-title{font-size:17px;font-weight:700;color:#222;display:flex;align-items:center;gap:8px}\n .notice-dialog-msg{font-size:14px;color:#555;line-height:1.6;overflow-wrap:anywhere}\n .notice-dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:6px}\n .notice-dialog-btn{padding:8px 22px;border-radius:20px;background:#ff2442;color:#fff;font-weight:600;font-size:13px;border:0;cursor:pointer;transition:background .15s}\n .notice-dialog-btn:hover{background:#e01f39}\n .app.rt-dark .notice-dialog{background:#202329;color:#e2e2e7;border-color:#34373e;box-shadow:0 12px 40px #00000066}\n .app.rt-dark .notice-dialog-title{color:#eee}\n .app.rt-dark .notice-dialog-msg{color:#bbb}\n\n /* 论坛独立主页 Portal 全景布局与分栏 */\n .board-portal-view{max-width:1280px;margin:8px auto 48px;display:flex;flex-direction:column;gap:18px;width:100%;box-sizing:border-box}\n .board-portal-banner{padding:22px 26px;background:linear-gradient(135deg,#fff0f3 0%,#fdf6f9 100%);border-radius:20px;border:1px solid #ffd4df;display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}\n .board-portal-title{margin:0 0 5px;font-size:20px;font-weight:750;color:#222}\n .board-portal-sub{font-size:13px;color:#666;line-height:1.5}\n .board-portal-actions{display:flex;align-items:center;gap:10px}\n .board-portal-native-link{font-size:12.5px;color:#a43d60;padding:6px 14px;border-radius:16px;background:#fff;border:1px solid #fed4df;text-decoration:none;transition:background .15s}\n .board-portal-native-link:hover{background:#fff0f3}\n \n .board-portal-search-wrap{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid #e2e2e7;border-radius:24px;padding:7px 16px;box-shadow:0 2px 8px rgba(0,0,0,0.03);transition:border-color .15s}\n .board-portal-search-wrap:focus-within{border-color:#ff2442}\n .board-portal-search-input{flex:1;border:none;outline:none;font-size:14px;background:transparent;color:#222}\n .board-portal-jump-fid{flex:none;background:#ff2442;color:#fff;border-radius:18px;padding:6px 13px;font-size:12.5px;font-weight:600;white-space:nowrap;cursor:pointer}\n \n .board-portal-cat-bar{display:flex;align-items:center;gap:8px;overflow-x:auto;width:100%;max-width:100%;box-sizing:border-box;padding:2px 0 8px;scrollbar-width:none;-webkit-overflow-scrolling:touch;touch-action:pan-x;overscroll-behavior-x:contain}\n .board-portal-cat-bar::-webkit-scrollbar{display:none}\n .board-portal-cat-btn{display:inline-flex;align-items:center;gap:6px;padding:7px 15px;border-radius:18px;background:#f2f2f5;color:#555;font-size:13px;font-weight:550;white-space:nowrap;cursor:pointer;border:0;transition:background .15s,color .15s;flex:none}\n .board-portal-cat-btn:hover{background:#e8e8ed;color:#111}\n .board-portal-cat-btn.is-active{background:#ff2442;color:#fff;font-weight:650}\n \n .board-portal-sections{display:flex;flex-direction:column;gap:20px}\n .board-portal-section{display:flex;flex-direction:column;gap:12px}\n .board-portal-section-header{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:8px;border-bottom:2px solid #f2f2f5}\n .board-portal-section-title{display:flex;align-items:center;gap:8px;font-size:16.5px;font-weight:750;color:#222}\n .board-portal-section-count{font-size:11px;background:#f0f0f4;color:#666;padding:2px 7px;border-radius:10px;font-weight:600}\n .board-portal-section-desc{font-size:12px;color:#888}\n \n .board-portal-cards-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px}\n .board-portal-card{display:flex;flex-direction:column;justify-content:space-between;padding:13px 15px;border-radius:15px;background:#fff;border:1px solid #ebebf0;box-shadow:0 2px 6px rgba(0,0,0,0.02);transition:transform .15s,box-shadow .15s,border-color .15s;text-decoration:none;min-height:76px;box-sizing:border-box}\n .board-portal-card:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.06);border-color:#ffd4df}\n .board-portal-card-top{display:flex;align-items:center;justify-content:space-between;gap:8px}\n .board-portal-card-name{font-size:14px;font-weight:700;color:#222}\n .board-portal-card:hover .board-portal-card-name{color:#ff2442}\n .board-portal-card-fid{font-size:10.5px;background:#f2f2f6;color:#777;padding:1px 6px;border-radius:6px;font-weight:600;flex:none}\n .board-portal-card-desc{font-size:11.5px;color:#777;margin-top:5px;line-height:1.45;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}\n\n /* Portal 暗色适配 */\n .app.rt-dark .board-portal-banner{background:linear-gradient(135deg,#2b1f25 0%,#1f2127 100%);border-color:#482833}\n .app.rt-dark .board-portal-title{color:#f0f0f5}\n .app.rt-dark .board-portal-sub{color:#aaa}\n .app.rt-dark .board-portal-native-link{background:#282b32;border-color:#482833;color:#ff8ba0}\n .app.rt-dark .board-portal-search-wrap{background:#202329;border-color:#34373e}\n .app.rt-dark .board-portal-search-input{color:#eee}\n .app.rt-dark .board-portal-cat-btn{background:#23262d;color:#aaa}\n .app.rt-dark .board-portal-cat-btn:hover{background:#2e323b;color:#fff}\n .app.rt-dark .board-portal-cat-btn.is-active{background:#ff2442;color:#fff}\n .app.rt-dark .board-portal-section-header{border-color:#2d3038}\n .app.rt-dark .board-portal-section-title{color:#eee}\n .app.rt-dark .board-portal-section-count{background:#292d35;color:#aaa}\n .app.rt-dark .board-portal-section-desc{color:#888}\n .app.rt-dark .board-portal-card{background:#202329;border-color:#31353e}\n .app.rt-dark .board-portal-card:hover{background:#262a32;border-color:#ff8ba0}\n .app.rt-dark .board-portal-card-name{color:#eee}\n .app.rt-dark .board-portal-card-fid{background:#2c3038;color:#aaa}\n .app.rt-dark .board-portal-card-desc{color:#999}\n\n @media(max-width:600px){\n .board-portal-view{margin:8px auto 36px;gap:16px}\n .board-portal-banner{padding:16px 14px;border-radius:16px}\n .board-portal-title{font-size:18px}\n .board-portal-cards-grid{grid-template-columns:1fr;gap:9px}\n .board-portal-card{padding:12px 14px;min-height:68px}\n }\n\n"; const node = (tag, className, text) => { const el = document.createElement(tag); if (className) el.className = className; if (text !== undefined) el.textContent = text; return el; }; const safeURL = (raw, base = location.href) => { try { const u = new URL(raw, base); return /^(https?:)$/.test(u.protocol) ? u : null; } catch { return null; } }; const link = (url, text, cls = '') => { const a = node('a', cls, text); a.href = url; return a; }; const button = (text, cls, action) => { const b = node('button', cls, text); b.type = 'button'; b.addEventListener('click', action); return b; }; const savePrefs = () => write(KEY, prefs); shadow.append(node('style', '', css)); const app = node('div', 'app'); app.hidden = true; const top = node('header', 'top'), bar = node('div', 'bar'); const brand = node('div', 'brand', '阅境 '); brand.append(node('span', '', '· NGA')); const search = node('div', 'search'), input = node('input'); input.type = 'search'; input.placeholder = '搜索本页标题 / 作者'; input.setAttribute('aria-label', '搜索本页标题或作者'); input.addEventListener('input', () => { query = input.value.trim().toLowerCase(); render(); }); search.append(input); const mobileSearch = button('', 'mobile-tool', () => { const open = app.classList.toggle('search-open'); mobileSearch.setAttribute('aria-expanded', String(open)); if (open) input.focus(); }); mobileSearch.setAttribute('aria-label', '展开或收起搜索'); mobileSearch.setAttribute('aria-expanded', 'false'); const mobileOptions = button('', 'mobile-tool toolbar-menu', () => { refreshAccount(); const open = app.classList.toggle('options-open'); mobileOptions.setAttribute('aria-expanded', String(open)); readingSettings?.open(mobileOptions); }); mobileOptions.setAttribute('aria-label', '展开或收起设置'); mobileOptions.setAttribute('aria-expanded', 'false'); for (const [control, path] of [[mobileSearch, 'M21 21l-4.3-4.3M19 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0'], [mobileOptions, 'M5 6h14M5 12h14M5 18h14']]) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), shape = document.createElementNS('http://www.w3.org/2000/svg', 'path'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('width', '21'); svg.setAttribute('height', '21'); svg.setAttribute('aria-hidden', 'true'); shape.setAttribute('d', path); shape.setAttribute('fill', 'none'); shape.setAttribute('stroke', 'currentColor'); shape.setAttribute('stroke-width', '1.7'); shape.setAttribute('stroke-linecap', 'round'); svg.append(shape); control.append(svg); } const boardBarBtn = link('/index.php', '板块', 'board-bar-btn'); boardBarBtn.setAttribute('aria-label', '前往论坛首页选择板块'); boardBarBtn.addEventListener('click', e => { if (e.isTrusted === false && typeof boardsManager?.open === 'function') { e.preventDefault(); boardsManager.open(); } }); bar.append(brand, search, node('div', 'spacer'), boardBarBtn, mobileSearch, mobileOptions, button('切回原版', 'pill', () => toggle(false))); top.append(bar); const main = node('main'), intro = node('div', 'intro'), heading = node('div', 'intro-heading'); const h1 = node('h1', 'board-title', ''); const h1Text = node('span', 'board-title-text', '论坛发现'); const switchBadge = link('/index.php', '切换 ↗', 'board-title-switch'); switchBadge.setAttribute('aria-label', '前往论坛首页切换板块'); switchBadge.addEventListener('click', e => { if (e.isTrusted === false && typeof boardsManager?.open === 'function') { e.preventDefault(); boardsManager.open(); } }); h1.append(h1Text, switchBadge); h1.addEventListener('click', event => { if (event.target !== switchBadge) { if (isForumRoot) return; if (event.isTrusted === false && typeof boardsManager?.open === 'function') boardsManager.open(); else location.href = '/index.php'; } }); const subtitle = node('div', 'sub'); heading.append(h1, subtitle); const subforumStrip = node('nav', 'subforum-strip'); subforumStrip.setAttribute('aria-label', '当前板块子版块导航'); subforumStrip.hidden = true; const settings = node('div', 'settings'); for (const [prop, label] of [['single', '手机单列']]) { const wrap = node('label'), check = node('input'); check.type = 'checkbox'; check.checked = !!prefs[prop]; check.addEventListener('change', () => { prefs[prop] = check.checked; savePrefs(); render(); }); wrap.append(check, document.createTextNode(label)); settings.append(wrap); } function ngaPageContext(context) { return context === globalThis.window && typeof unsafeWindow !== 'undefined' ? unsafeWindow : context; } // NGA's CURRENT fields identify the session; userInfo contains every poster on the page. function readCurrentAccount(context) { const { document } = context; const scripts = [...document.scripts].filter(script => !script.src); const page = ngaPageContext(context); let uid = page.__CURRENT_UID, username = page.__CURRENT_UNAME; const positiveUID = value => /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0 ? String(Number(value)) : null; function stringLiteral(raw) { return raw.slice(1, -1).replace(/\\(?:u([\da-f]{4})|x([\da-f]{2})|([\s\S]))/gi, (_, unicode, hex, escaped) => { if (unicode || hex) return String.fromCharCode(parseInt(unicode || hex, 16)); return ({ n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', '\n': '' })[escaped] ?? escaped; }); } // Tampermonkey's isolated world may not expose page globals. Read the known // literal assignments from the original response instead of evaluating scripts. if (uid === undefined) for (const script of scripts) { const assignment = script.textContent.match(/\b__CURRENT_UID\s*=\s*(?:parseInt\(\s*(['"])(\d*)\1\s*,\s*10\s*\)|(\d+))/); if (assignment) { uid = assignment[2] ?? assignment[3]; break; } } uid = positiveUID(uid); if (!uid) return null; if (username === undefined) for (const script of scripts) { const assignment = script.textContent.match(/\b__CURRENT_UNAME\s*=\s*('(?:\\[\s\S]|[^'\\])*'|"(?:\\[\s\S]|[^"\\])*")/); if (assignment) { username = stringLiteral(assignment[1]); break; } } if (!username) username = page.commonui?.userInfo?.users?.[uid]?.username; if (!username) username = readNGAUserData(document, context).users[uid]?.username; return { uid, username: typeof username === 'string' || Object.prototype.toString.call(username) === '[object String]' ? String(username) : '当前用户' }; } // Extract JSON data only; fetched NGA scripts are never executed. function readNGAJSON(source, start) { let depth = 0, quoted = false, escaped = false; for (let i = start; i < source.length; i++) { const char = source[i]; if (quoted) { if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === '"') quoted = false; } else if (char === '"') quoted = true; else if (char === '{') depth++; else if (char === '}' && --depth === 0) { try { return JSON.parse(source.slice(start, i + 1)); } catch { return null; } } } return null; } const ngaUserTables = new WeakMap(); function readNGAUserData(document, context) { const sources = [...document.scripts].filter(s => !s.src).map(s => s.textContent).filter(s => /userInfo\.setAll|__UCPUSER/.test(s)); let cached = ngaUserTables.get(document); if (!cached || sources.length !== cached.sources.length || sources.some((source, i) => source !== cached.sources[i])) { const users = {}, groups = {}; for (const source of sources) { for (const match of source.matchAll(/(?:commonui\.userInfo\.setAll\s*\(\s*|__UCPUSER\s*=\s*)\{/g)) { const data = readNGAJSON(source, match.index + match[0].length - 1); if (!data) continue; if (match[0].includes('__UCPUSER')) { if (data.uid) users[String(data.uid)] = data; } else { Object.assign(groups, data.__GROUPS); for (const [uid, user] of Object.entries(data)) if (/^\d+$/.test(uid)) users[uid] = user; } } } cached = { sources, users, groups }; ngaUserTables.set(document, cached); } const live = document === context?.document ? ngaPageContext(context).commonui?.userInfo : null; return { users: { ...cached.users, ...live?.users }, groups: { ...cached.groups, ...live?.groups } }; } function mountUserProfiles() { const cache = new Map(), requests = new Map(); function avatarURL(raw) { if (typeof raw !== 'string' || !raw.trim()) return null; let value = raw.split('|')[0].trim(); const short = value.match(/^\.a\/(\d+)_(\d+)\.(jpg|png|gif|webp(?:\.jpg)?)(\?\d+)?$/); if (short) { const hex = Number(short[1]).toString(16).padStart(9, '0').slice(-9); const path = `${hex.slice(6)}/${hex.slice(3,6)}/${hex.slice(0,3)}`; value = `https://img.nga.cn/avatars/2002/${path}/${short[1]}_${short[2]}.${short[3]}${short[4] || ''}`; } else if (/^[\w-]+\.(?:gif|jpg|png|webp)$/.test(value)) value = `https://img4.nga.cn/ngabbs/face/${value}`; return safeURL(value, pageURL)?.href || null; } function normalize(user, groups = {}) { if (!user || !/^\d+$/.test(String(user.uid)) || Number(user.uid) <= 0) return null; const number = value => value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value)) ? Number(value) : null; return { uid: String(user.uid), username: String(user.username || '用户'), avatar: avatarURL(user.avatar), regdate: number(user.regdate), rank: user.group || groups[user.memberid]?.[0] || groups[user.groupid]?.[0] || null, rvrc: number(user.rvrc), money: number(user.money), posts: number(user.postnum ?? user.posts) }; } function resolve(info) { const uid = String(info.uid || ''); if (!/^\d+$/.test(uid) || Number(uid) <= 0) return null; const table = readNGAUserData(document, window); const user = info.profile || normalize(table.users[uid], table.groups); return user?.regdate != null ? user : cache.get(uid) || user || null; } function image(url, className) { const img = node('img', className); img.src = url; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.referrerPolicy = 'no-referrer'; img.addEventListener('error', () => { img.remove(); }); return img; } function decorate(control, info, avatarOnly = false) { if (!info?.uid) { delete control.dataset.userUid; control.querySelector('.user-avatar-image')?.remove(); return; } control.dataset.userUid = info.uid; control.dataset.avatarOnly = String(avatarOnly); const user = resolve(info), url = user?.avatar; control.setAttribute('aria-label', `${info.author || info.username || user?.username || '用户'},查看用户资料`); if (control.querySelector('.user-avatar-image')?.getAttribute('src') === url) return; control.querySelector('.user-avatar-image')?.remove(); if (url) control.prepend(image(url, `user-avatar-image${avatarOnly ? '' : ' account-picture'}`)); } async function load(uid) { if (requests.has(uid)) return requests.get(uid); const request = (async () => { const url = new URL('/nuke.php', pageURL); url.searchParams.set('func', 'ucp'); url.searchParams.set('uid', uid); const controller = new window.AbortController(); const timer = window.setTimeout(() => controller.abort(), 10000); try { const response = await fetch(url.href, { credentials: 'same-origin', signal: controller.signal }); if (!response.ok || (response.url && new URL(response.url).origin !== pageURL.origin)) throw new Error('用户资料暂时无法读取'); const buffer = await response.arrayBuffer(); const charset = /charset=\s*([\w-]+)/i.exec(response.headers.get('content-type') || '')?.[1] || 'gb18030'; const html = new window.TextDecoder(charset).decode(buffer); const doc = new window.DOMParser().parseFromString(html, 'text/html'); const table = readNGAUserData(doc, window), user = normalize(table.users[uid], table.groups); if (!user || user.uid !== uid) throw new Error('原页未提供该用户资料'); cache.set(uid, user); for (const control of shadow.querySelectorAll('[data-user-uid]')) if (control.dataset.userUid === uid) decorate(control, {uid, author:user.username, profile:user}, control.dataset.avatarOnly === 'true'); return user; } finally { window.clearTimeout(timer); } })(); requests.set(uid, request); try { return await request; } finally { requests.delete(uid); } } const style = node('style', '', ` .user-trigger{padding:0;text-align:left;touch-action:manipulation}.avatar,.reader-avatar{position:relative;overflow:hidden} .avatar .user-avatar-image,.reader-avatar .user-avatar-image{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:inherit} .account-picture{float:left;width:28px;height:28px;border-radius:50%;object-fit:cover;margin-right:7px} .user-card{position:fixed;z-index:2147483013;width:min(340px,calc(100vw - 24px));max-height:calc(100dvh - 24px);overflow:auto;margin:0;padding:18px;border:1px solid #eee4e9;border-radius:20px;background:#fff8fa;color:#30272d;box-shadow:0 8px 40px #21132130;font:14px/1.6 system-ui} .user-card:not([open]){display:none}.user-card[data-theme=dark]{background:#29232a;color:#f3e4ed;border-color:#806d78}.user-card[data-theme=paper]{background:#faf5eb;color:#3c332d} .user-card-head{display:flex;align-items:center;gap:12px;padding-right:36px}.user-card-avatar{position:relative;overflow:hidden;width:64px;height:64px;border-radius:16px;background:#f5dce5;color:#a43d60;display:grid;place-items:center;font-size:24px;flex:none}.user-card-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.user-card-name{font-size:17px;font-weight:650;overflow-wrap:anywhere}.user-card-uid{font-size:12px;opacity:.65}.user-card-close{position:absolute;right:10px;top:8px;min-width:40px;min-height:40px;font-size:23px} .user-card-details{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:18px 0}.user-card-details div{min-width:0}.user-card-details dt{font-size:12px;opacity:.6}.user-card-details dd{margin:2px 0 0;overflow-wrap:anywhere}.user-card-details .wide{grid-column:1/-1}.user-card-status{font-size:12px;opacity:.65;margin:12px 0}.user-card-link{display:inline-block;padding:9px 14px;background:#f5dce5;color:#a43d60;border-radius:20px;font-size:12px} `); const panel = node('dialog', 'user-card'); panel.setAttribute('aria-label', '用户资料'); const close = button('×', 'user-card-close', hide); close.setAttribute('aria-label', '关闭用户资料'); const head = node('div', 'user-card-head'), portrait = node('div', 'user-card-avatar'), identity = node('div'); const name = node('div', 'user-card-name'), uidLabel = node('div', 'user-card-uid'); identity.append(name, uidLabel); head.append(portrait, identity); const details = node('dl', 'user-card-details'), status = node('p', 'user-card-status'); status.setAttribute('role', 'status'); const native = link('', '查看原版个人资料 ↗', 'user-card-link'); native.dataset.readscapeNative = 'true'; panel.append(close, head, details, status, native); shadow.append(style, panel); let opener, generation = 0; function hide() { generation++; if (panel.open) { if (panel.close) panel.close(); else panel.removeAttribute('open'); } if (opener?.isConnected) opener.focus({preventScroll:true}); } function render(info, user) { name.textContent = user?.username || info.author || info.username || '用户'; uidLabel.textContent = info.uid ? `UID ${info.uid}` : '原页未提供 UID'; portrait.replaceChildren(document.createTextNode(name.textContent.slice(0,1))); if (user?.avatar) portrait.append(image(user.avatar, '')); details.replaceChildren(); function field(label, value, wide = false) { if (value === null || value === undefined || value === '') return; const group = node('div', wide ? 'wide' : ''); group.append(node('dt','',label),node('dd','',String(value))); details.append(group); } if (user?.regdate && !Number.isNaN(new Date(user.regdate*1000).getTime())) field('注册时间', new Intl.DateTimeFormat('zh-CN',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}).format(new Date(user.regdate*1000)), true); field('级别', user?.rank); field('威望', user?.rvrc == null ? null : user.rvrc / 10); field('发帖数', user?.posts); if (user?.money != null) { const amount = Math.max(0,Math.floor(user.money)), gold = Math.floor(amount/10000), silver = Math.floor(amount%10000/100), copper = amount%100; field('财富',[gold ? `${gold} 金币` : '',silver ? `${silver} 银币` : '',copper || !amount ? `${copper} 铜币` : ''].filter(Boolean).join(' ')); } native.hidden = !/^\d+$/.test(String(info.uid || '')) || Number(info.uid) <= 0; if (!native.hidden) { const url = new URL('/nuke.php',pageURL); url.searchParams.set('func','ucp'); url.searchParams.set('uid',info.uid); native.href=url.href; } } function position() { const rect = opener?.getBoundingClientRect(), width = panel.offsetWidth, height = panel.offsetHeight; const left = Math.max(12,Math.min(rect?.left || 12,window.innerWidth-width-12)); const below = (rect?.bottom || 0)+8; const top = Math.max(12,Math.min(below,window.innerHeight-height-12)); panel.style.left=`${left}px`; panel.style.top=`${top}px`; } async function show(info, trigger) { const token = ++generation; opener=trigger; panel.dataset.theme=prefs.theme || 'light'; let user=resolve(info); render(info,user); status.textContent=''; status.hidden=true; if (!panel.open) { if (panel.show) panel.show(); else panel.setAttribute('open',''); } position(); close.focus({preventScroll:true}); if (!info.uid || !/^\d+$/.test(String(info.uid)) || Number(info.uid)<=0) { status.hidden=false; status.textContent='原页未提供该用户的详细资料'; return; } if (!user || user.regdate === null) { status.hidden=false; status.textContent='正在读取用户资料…'; try { user=await load(String(info.uid)); if (token !== generation || !panel.open) return; render(info,user); status.hidden=true; position(); } catch { if (token !== generation || !panel.open) return; status.textContent='暂时无法读取详细资料,可查看原版个人资料。'; } } } const dismiss = event => { if (panel.open && !event.composedPath().includes(panel) && !event.composedPath().includes(opener)) hide(); }; const escape = event => { if (panel.open && event.key==='Escape') { event.preventDefault(); hide(); } }; document.addEventListener('pointerdown',dismiss,true); document.addEventListener('keydown',escape,true); panel.addEventListener('cancel',event=>{event.preventDefault();hide();}); window.addEventListener('pagehide',()=>{document.removeEventListener('pointerdown',dismiss,true);document.removeEventListener('keydown',escape,true);}); return {show,decorate,normalize,avatarURL}; } // NGA 论坛全版面与子论坛切换模块(移动端精致分栏与全功能选择器) function mountBoardsManager({ context = globalThis.window, shadow, app, pageURL, prefs, savePrefs }) { const window = context; const { document, localStorage, location } = window; const FORUM_CATEGORIES = [ { id: 'recommend', name: '常用热门', icon: '🔥', desc: 'NGA 最受关注与高热度的综合讨论区', forums: [ { fid: '-7', name: '晴风村 / 水区', desc: '大杂烩 · 情感 · 树洞 · 生活交流' }, { fid: '7', name: '艾泽拉斯议事厅', desc: '魔兽世界主讨论区 · 综合交流' }, { fid: '-447601', name: '二次元国家地理', desc: '动漫 · 漫画 · 综合ACG讨论' }, { fid: '650', name: '原神', desc: '提瓦特大陆冒险 · 攻略与同人' }, { fid: '510387', name: '崩坏:星穹铁道', desc: '星穹列车开拓之旅' }, { fid: '510757', name: '绝区零', desc: '新艾利都冒险之旅' }, { fid: '-34587507', name: '明日方舟', desc: '罗德岛驻艾泽拉斯大使馆' }, { fid: '757', name: '蔚蓝档案', desc: '基沃托斯联邦搜查社' }, { fid: '414', name: 'Steam综合讨论', desc: 'PC游戏 · 促销 · 测评心得' }, { fid: '415', name: '主机游戏', desc: 'PS / Xbox / Switch / 掌机' }, { fid: '-152678', name: '英雄联盟', desc: 'Let\'s Gank · 赛事与攻略' }, { fid: '-1459709', name: '职场人生', desc: '工作 · 职场经验 · 薪资交流' }, { fid: '-343809', name: '汽车俱乐部', desc: '买车选车 · 用车维修心得' }, { fid: '334', name: 'PC软硬件', desc: '装机配置 · 硬件数码 · 技术排障' } ] }, { id: 'mobile', name: '手机游戏', icon: '📱', desc: '二次元手游、抽卡养成与移动端热门作品', forums: [ { fid: '650', name: '原神', desc: '米哈游开放世界冒险' }, { fid: '510387', name: '崩坏:星穹铁道', desc: '银河回合制冒险RPG' }, { fid: '510757', name: '绝区零', desc: '都市即时动作冒险' }, { fid: '-34587507', name: '明日方舟', desc: '鹰角网络策略战术' }, { fid: '757', name: '蔚蓝档案', desc: '青春学园剧情战术' }, { fid: '510756', name: '鸣潮', desc: '库洛开放世界动作' }, { fid: '-40743354', name: '赛马娘 PrettyDerby', desc: 'Cygames养成育成' }, { fid: '-547859', name: '少女前线', desc: '16LAB研究院' }, { fid: '-195362', name: '少前2:追放', desc: '美式战棋3D角色扮演' }, { fid: '-60157311', name: '少前:云图计划', desc: 'Roguelike战术探索' }, { fid: '564', name: '碧蓝航线', desc: '弹幕海战少女养成' }, { fid: '549', name: '崩坏3', desc: '点燃国创动作之魂' }, { fid: '510619', name: '重返未来:1999', desc: '近代复古神秘学RPG' }, { fid: '638', name: '战双帕弥什', desc: '末世科幻动作' }, { fid: '538', name: '阴阳师', desc: '平安时代和风回合制' }, { fid: '-452227', name: '精灵宝可梦', desc: '宝可梦全系列游戏讨论' }, { fid: '-8180483', name: '影之诗', desc: '日系集换式卡牌对战' }, { fid: '-15219445', name: '巫师之昆特牌', desc: '来盘昆特牌吧' }, { fid: '-41232751', name: '四叶草剧场', desc: '魔物娘养成' }, { fid: '-41374941', name: '悠久之树', desc: '正统日系奇幻RPG' }, { fid: '-60374520', name: '食物语', desc: '中华美食拟人化' } ] }, { id: 'wow', name: '魔兽世界', icon: '⚔️', desc: '艾泽拉斯主讨论区、怀旧服、团本大秘与全职业大厅', forums: [ { fid: '7', name: '艾泽拉斯议事厅', desc: '魔兽综合主讨论区' }, { fid: '641', name: '经典旧世 (怀旧服)', desc: '时光回溯 · 怀旧服专区' }, { fid: '218', name: '副本讨论区', desc: '团本攻略 · 战术与心得' }, { fid: '533', name: '大秘境集合石', desc: '史诗钥石地下城交流' }, { fid: '191', name: '地精商会', desc: '商业技能 · 拍卖行心得' }, { fid: '200', name: '插件技术综合讨论区', desc: '界面排版 · 插件配置' }, { fid: '274', name: '原创插件发布区', desc: '自制UI与扩展下载' }, { fid: '310', name: '精英议会', desc: '前瞻高阶理论研究' }, { fid: '255', name: '团队管理经验交流', desc: '公会运作与团队建设' }, { fid: '230', name: '艾泽拉斯风纪委员会', desc: '秩序监督与公示' }, { fid: '254', name: '镶金玫瑰旅店', desc: '酒馆吹水 · 背景故事' }, { fid: '124', name: '莫高雷壁画洞穴', desc: '暴雪艺术原创同人' }, { fid: '319', name: '宏命令讨论区', desc: '宏编写与排错' }, { fid: '182', name: '法师 - 魔法圣堂', desc: '奥术 / 火焰 / 冰霜' }, { fid: '181', name: '战士 - 铁血沙场', desc: '武器 / 狂怒 / 防护' }, { fid: '184', name: '圣骑士 - 圣光广场', desc: '神圣 / 防护 / 惩戒' }, { fid: '183', name: '牧师 - 信仰神殿', desc: '戒律 / 神圣 / 暗影' }, { fid: '189', name: '潜行者 - 暗影裂口', desc: '奇袭 / 狂徒 / 敏锐' }, { fid: '186', name: '德鲁伊 - 翡翠梦境', desc: '平衡 / 野性 / 守护 / 恢复' }, { fid: '187', name: '猎人 - 猎手大厅', desc: '野兽控制 / 射击 / 生存' }, { fid: '188', name: '术士 - 恶魔深渊', desc: '痛苦 / 恶魔学识 / 毁灭' }, { fid: '185', name: '萨满 - 风暴祭坛', desc: '元素 / 增强 / 恢复' }, { fid: '320', name: '死亡骑士 - 黑锋要塞', desc: '鲜血 / 冰霜 / 邪恶' }, { fid: '390', name: '武僧 - 五晨寺', desc: '酒仙 / 织雾 / 踏风' }, { fid: '477', name: '恶魔猎手 - 伊利达雷', desc: '浩劫 / 复仇' }, { fid: '510340', name: '唤魔师 - 禁忌离岛', desc: '湮灭 / 恩护 / 增辉' } ] }, { id: 'games', name: '游戏综合', icon: '🎮', desc: '单机、Steam、主机掌机、暴雪综合与独立神作', forums: [ { fid: '414', name: 'Steam综合讨论', desc: 'PC游戏促销 · 测评心得' }, { fid: '415', name: '主机游戏', desc: 'PS / Xbox / Switch / 掌机' }, { fid: '-362960', name: '最终幻想14', desc: '艾欧泽亚光之战士' }, { fid: '560', name: '怪物猎人', desc: '苍蓝星狩猎集会所' }, { fid: '510740', name: '黑神话:悟空', desc: '西游神话动作冒险' }, { fid: '686', name: '艾尔登法环 / 魂系列', desc: '交界地褪色者 · 魂系探索' }, { fid: '615', name: '塞尔达传说', desc: '旷野之息 · 王国之泪' }, { fid: '510389', name: '模拟经营 / 策略战棋', desc: 'P社四萌 · 文明 · 策略SLG' }, { fid: '332', name: '战锤40K', desc: '战锤宇宙战役与桌面棋' }, { fid: '632', name: '暴雪游戏综合', desc: '暴雪娱乐产品综合' }, { fid: '422', name: '炉石传说', desc: '魔兽英雄传 · 酒馆战棋' }, { fid: '687', name: '暗黑破坏神4', desc: '庇护之地避难所' }, { fid: '318', name: '暗黑破坏神3', desc: '奈非天秘境刷刷刷' }, { fid: '459', name: '守望先锋', desc: '英雄竞技射击' }, { fid: '273', name: '星际争霸2', desc: '科普卢星区RTS' }, { fid: '431', name: '风暴英雄', desc: '时空枢纽团战竞技' } ] }, { id: 'esports', name: '竞技网游', icon: '🏆', desc: '英雄联盟、DOTA2、CS2、无畏契约等电竞赛事与攻略', forums: [ { fid: '-152678', name: '英雄联盟', desc: '赛事资讯 · 英雄攻略 · 云顶' }, { fid: '321', name: 'DOTA2', desc: '刀塔遗迹保卫战 · 赛事交流' }, { fid: '731', name: '无畏契约 VALORANT', desc: '拳头战术射击对抗' }, { fid: '548', name: 'CS:GO / CS2', desc: '反恐精英战术竞技' }, { fid: '653', name: '云顶之弈', desc: '自走棋阵容与羁绊搭配' }, { fid: '674', name: '永劫无间', desc: '多人动作战术竞技' }, { fid: '-6194253', name: '战争雷霆', desc: '海陆空拟真载具射击' }, { fid: '-7861121', name: '剑网3', desc: '大唐武侠MMO' }, { fid: '-5080470', name: '流放之路', desc: '硬核刷宝暗黑ARPG' }, { fid: '-235147', name: '激战2', desc: '泰瑞亚大陆动态世界' } ] }, { id: 'life', name: '综合生活', icon: '☕', desc: '职场、数码硬件、汽车、美食、影音与情感闲聊', forums: [ { fid: '-7', name: '晴风村 / 水区', desc: '大杂烩 · 情感倾诉 · 闲聊树洞' }, { fid: '-1459709', name: '职场人生', desc: '职场经验 · 薪酬与求职' }, { fid: '-343809', name: '汽车俱乐部', desc: '选车购车 · 用车维修心得' }, { fid: '-2122', name: '机车俱乐部', desc: '两轮骑行 · 路线与摩托推荐' }, { fid: '-576177', name: '影音讨论区', desc: '电影 · 电视剧 · 音乐鉴赏' }, { fid: '-608808', name: '恩基爱厨艺美食交流', desc: '家庭烹饪 · 探店与食谱' }, { fid: '334', name: 'PC软硬件', desc: '装机指南 · 评测与故障排查' }, { fid: '498', name: '消费电子 / 手机数码', desc: '智能手机 · 平板与数码' }, { fid: '-353371', name: '萌萌宠物', desc: '喵星人 · 汪星人日常' }, { fid: '-187628', name: 'Home, sweet home', desc: '买房避坑 · 家装家居' }, { fid: '-447601', name: '二次元国家地理', desc: '动漫追番 · 轻小说杂谈' }, { fid: '510425', name: '模玩专区', desc: '手办 · 高达 · 兵人与雕像' }, { fid: '510376', name: '跑团 / TRPG', desc: '桌游 · DND · 跑团战报' }, { fid: '-7678526', name: '艾泽拉斯麻将科学院', desc: '日麻 · 雀魂与国标' }, { fid: '-81981', name: '生命之杯', desc: '足球赛事 · 俱乐部与球星' } ] } ]; const FORUM_MAP = new Map(); for (const category of FORUM_CATEGORIES) { for (const item of category.forums) { if (!FORUM_MAP.has(String(item.fid))) FORUM_MAP.set(String(item.fid), item); } } const RECENT_KEY = 'nga-cards-v1-recent-boards'; function readRecentBoards() { try { return JSON.parse(localStorage.getItem(RECENT_KEY)) || []; } catch { return []; } } function writeRecentBoards(list) { try { localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, 12))); } catch {} } function recordRecentBoard(item) { if (!item || (!item.fid && !item.stid) || !item.name) return; const list = readRecentBoards(); const fid = item.fid ? String(item.fid) : null; const stid = item.stid ? String(item.stid) : null; const filtered = list.filter(b => (fid ? b.fid !== fid : true) && (stid ? b.stid !== stid : true)); filtered.unshift({ fid, stid, name: item.name, desc: item.desc || (fid ? `FID ${fid}` : `STID ${stid}`), url: item.url || (stid ? `/thread.php?stid=${stid}` : `/thread.php?fid=${fid}`), time: Date.now() }); writeRecentBoards(filtered); } function clearRecentBoards() { try { localStorage.removeItem(RECENT_KEY); } catch {} } function extractCurrentSubforums(doc = document, win = window, url = pageURL) { const list = [], seen = new Set(); const curFid = url.searchParams.get('fid'); const curStid = url.searchParams.get('stid'); const allData = win.__ALL_FORUM_DATA; if (allData && typeof allData === 'object') { for (const [key, val] of Object.entries(allData)) { if (!Array.isArray(val) || val.length < 2) continue; const rawId = String(val[0] || key); const isStid = rawId.startsWith('t') || rawId.startsWith('s') || ((Number(val[4]) & 16) !== 0); const cleanId = rawId.replace(/^[ts]/, ''); if ((isStid && cleanId === curStid) || (!isStid && cleanId === curFid)) continue; const keyStr = isStid ? `stid:${cleanId}` : `fid:${cleanId}`; if (seen.has(keyStr)) continue; seen.add(keyStr); const name = String(val[1] || '').trim(); const desc = String(val[2] || '').trim(); if (!name || name.length < 2) continue; const targetUrl = isStid ? `/thread.php?stid=${cleanId}` : `/thread.php?fid=${cleanId}`; list.push({ fid: isStid ? null : cleanId, stid: isStid ? cleanId : null, name, desc, url: targetUrl }); } } const subLinks = doc.querySelectorAll('#sub_forums_c a[href*="thread.php"], #more_sub_forums_c a[href*="thread.php"]'); for (const a of subLinks) { const raw = a.getAttribute('href'); if (!raw) continue; let u; try { u = new URL(raw, win.location.href); } catch { continue; } const fid = u.searchParams.get('fid'), stid = u.searchParams.get('stid'); if (!fid && !stid) continue; if ((fid && fid === curFid) || (stid && stid === curStid)) continue; const keyStr = stid ? `stid:${stid}` : `fid:${fid}`; if (seen.has(keyStr)) continue; seen.add(keyStr); const name = a.textContent.trim().replace(/\s*\([^)]*\)\s*$/, '').trim(); if (!name || name.length < 2) continue; list.push({ fid, stid, name, desc: '', url: u.pathname + u.search }); } return list; } function detectBoardInfo(doc = document, win = window, url = pageURL) { const fid = url.searchParams.get('fid'); const stid = url.searchParams.get('stid'); if (fid && FORUM_MAP.has(fid)) { return { fid, stid, name: FORUM_MAP.get(fid).name }; } if (win.__ALL_FORUM_DATA && typeof win.__ALL_FORUM_DATA === 'object') { if (fid && win.__ALL_FORUM_DATA[fid]?.[1]) { return { fid, stid, name: String(win.__ALL_FORUM_DATA[fid][1]).trim() }; } if (stid && win.__ALL_FORUM_DATA[`t${stid}`]?.[1]) { return { fid, stid, name: String(win.__ALL_FORUM_DATA[`t${stid}`][1]).trim() }; } } const navLink = doc.querySelector('#m_nav h1 a, .nav h1 a, #m_nav .nav_link, .nav_link'); if (navLink && navLink.textContent.trim().length > 1) { return { fid, stid, name: navLink.textContent.trim() }; } const boardA = [...doc.querySelectorAll('#m_nav a[href*="thread.php"], a[href*="thread.php"]')].find(a => { try { const u = new URL(a.getAttribute('href'), win.location.href); return (stid && u.searchParams.get('stid') === stid) || (fid && u.searchParams.get('fid') === fid); } catch { return false; } }); if (boardA && boardA.textContent.trim().length > 1) { return { fid, stid, name: boardA.textContent.trim() }; } const cleanTitle = (doc.title || '').replace(/\s*[-_].*NGA.*$/i, '').trim(); if (cleanTitle && cleanTitle !== '访客不能直接访问' && cleanTitle !== '未登录' && cleanTitle !== 'NGA玩家社区') { return { fid, stid, name: cleanTitle }; } return { fid, stid, name: fid ? `板块 (FID ${fid})` : (stid ? `主题集 (${stid})` : '论坛发现') }; } // 构建板块切换器 UI 样式与弹窗(分栏布局) const style = node('style', '', ` .board-dialog{position:fixed;z-index:2147483015;inset:0;width:100vw;height:100dvh;max-width:100vw;max-height:100dvh;margin:0;padding:0;border:0;background:rgba(0,0,0,.45);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;box-sizing:border-box;touch-action:pan-y;overscroll-behavior:contain} .board-dialog:not([open]){display:none!important} .board-dialog::backdrop{background:transparent} .board-dialog-sheet{width:min(780px,calc(100vw - 24px));height:min(86dvh,760px);max-height:86dvh;background:#fff;color:#27272b;border:1px solid #e8e8ed;border-radius:24px;box-shadow:0 14px 50px rgba(0,0,0,.25);font:14px/1.5 system-ui;display:flex;flex-direction:column;overscroll-behavior:contain;overflow:hidden;touch-action:pan-y;position:relative} .board-handle{display:none;width:100%;height:18px;align-items:center;justify-content:center;cursor:grab;flex:none;touch-action:none} .board-handle::before{content:'';width:38px;height:4.5px;border-radius:3px;background:#d0d0d6} .board-dialog-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px 12px;border-bottom:1px solid #f0f0f3;flex:none;touch-action:none} .board-dialog-title{display:flex;align-items:center;gap:8px;font-size:17px;font-weight:700;color:#222} .board-dialog-title .board-icon{font-size:19px} .board-dialog-close{font-size:24px;line-height:1;width:34px;height:34px;border-radius:50%;display:grid;place-items:center;color:#888;transition:background .15s} .board-dialog-close:hover{background:#f0f0f4;color:#222} .board-search-box{padding:10px 20px;background:#fafafc;border-bottom:1px solid #f0f0f3;display:flex;align-items:center;gap:10px;flex:none} .board-search-icon{color:#999;flex:none} .board-search-input{flex:1;border:1px solid #e2e2e7;border-radius:22px;padding:9px 16px;background:#fff;color:#222;font-size:14px;outline:none;transition:border-color .15s} .board-search-input:focus{border-color:#ff2442} .board-jump-fid{flex:none;background:#ff2442;color:#fff;border-radius:20px;padding:7px 14px;font-size:13px;font-weight:600;white-space:nowrap;transition:filter .15s} .board-jump-fid:hover{filter:brightness(1.08)} /* 左右分栏核心容器 */ .board-split-layout{flex:1;display:flex;min-height:0;height:100%;overflow:hidden} /* 左侧栏目导航 */ .board-sidebar{width:150px;background:#f7f7f9;border-right:1px solid #f0f0f3;overflow-y:auto;display:flex;flex-direction:column;padding:8px 0;flex:none;touch-action:pan-y;overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch} .board-cat-item{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;font-size:13px;font-weight:550;color:#555;cursor:pointer;border-left:3px solid transparent;transition:background .15s,color .15s,border-color .15s;text-align:left;position:relative;touch-action:pan-y} .board-cat-item:hover{background:#efeff2;color:#111} .board-cat-item.is-active{background:#fff;color:#ff2442;font-weight:700;border-left-color:#ff2442} .board-cat-item-main{display:flex;align-items:center;gap:6px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .board-cat-item-badge{font-size:10px;background:#ffe6eb;color:#ff2442;padding:1px 6px;border-radius:10px;font-weight:600} /* 右侧板块列表内容区 */ .board-main-pane{flex:1;overflow-y:auto;padding:16px 20px;display:flex;flex-direction:column;gap:14px;min-width:0;touch-action:pan-y;overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch} .board-pane-header{display:flex;align-items:center;justify-content:space-between;gap:10px;padding-bottom:10px;border-bottom:1px solid #f4f4f6} .board-pane-title{font-size:15px;font-weight:700;color:#333} .board-pane-desc{font-size:12px;color:#888;margin-top:2px} .board-pane-extra{flex:none} /* 搜索全屏结果视图 */ .board-search-results{flex:1;overflow-y:auto;padding:16px 20px;touch-action:pan-y;overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch} /* 板块卡片网格 */ .board-cards-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px} .board-card{display:flex;flex-direction:column;justify-content:space-between;padding:12px 14px;border-radius:14px;background:#f9f9fb;border:1px solid #eeeeef;transition:transform .14s,box-shadow .14s,border-color .14s;text-decoration:none;min-height:74px;touch-action:pan-y} .board-card:hover{transform:translateY(-2px);background:#fff;box-shadow:0 4px 14px #0000000a;border-color:#ffd4df} .board-card.is-active{background:#fff8fa;border-color:#ff8ba0} .board-card-main{display:flex;align-items:center;justify-content:space-between;gap:6px} .board-card-name{font-size:14px;font-weight:650;color:#222} .board-card.is-active .board-card-name{color:#ff2442} .board-card-active-tag{font-size:10px;background:#ff2442;color:#fff;padding:2px 6px;border-radius:8px;font-weight:600;flex:none} .board-card-desc{font-size:11px;color:#888;margin-top:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;line-height:1.4} .board-card-meta{display:flex;align-items:center;justify-content:space-between;margin-top:8px;font-size:11px;color:#aaa} .board-card-fid{background:#eaeaef;color:#666;padding:1px 6px;border-radius:6px;font-size:10px} /* Chip 样式(兼容旧版与快捷标签) */ .board-chips-grid{display:flex;flex-wrap:wrap;gap:8px} .board-chip{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:18px;background:#f3f3f6;color:#333;font-size:13px;transition:background .15s,color .15s;text-decoration:none;touch-action:manipulation} .board-chip:hover{background:#e7e7ec;color:#111} .board-chip.is-active{background:#fff0f3;color:#ff2442;font-weight:650;border:1px solid #ffd4df} .board-chip-desc{font-size:11px;color:#888;font-weight:normal} .board-clear-recent{font-size:12px;color:#999;cursor:pointer;padding:2px 8px;border-radius:12px} .board-clear-recent:hover{background:#f0f0f4;color:#666} .board-empty-search{text-align:center;padding:36px 12px;color:#888;font-size:14px} .board-empty-search strong{color:#ff2442} .board-dialog-footer{padding:10px 20px;border-top:1px solid #f0f0f3;display:flex;align-items:center;justify-content:space-between;font-size:12px;color:#888;background:#fafafc;flex:none} .board-native-link{color:#a43d60;text-decoration:none} .board-native-link:hover{text-decoration:underline} /* 列表顶部子版块横向标签栏 */ .subforum-strip{display:flex;align-items:center;gap:8px;overflow-x:auto;width:100%;max-width:100%;box-sizing:border-box;padding:6px 0 12px;margin-top:-4px;scrollbar-width:none;-webkit-overflow-scrolling:touch;touch-action:pan-x;overscroll-behavior-x:contain} .subforum-strip[hidden]{display:none!important} .subforum-strip::-webkit-scrollbar{display:none} .subforum-chip{display:inline-flex;align-items:center;gap:5px;padding:6px 13px;border-radius:16px;background:#f1f1f4;color:#555;font-size:12px;white-space:nowrap;transition:background .15s,color .15s;text-decoration:none;flex:none;touch-action:pan-x;-webkit-tap-highlight-color:transparent} .subforum-chip:hover{background:#e6e6eb;color:#222} .subforum-chip.is-active{background:#ff2442;color:#fff;font-weight:600} .subforum-chip-more{background:#fff;border:1px dashed #dcdce2;color:#a43d60;cursor:pointer} .subforum-chip-more:hover{background:#fff0f3;border-color:#ff8ba0;color:#ff2442} /* 顶部标题旁快捷切换板块徽章 */ .board-title{display:inline-flex;align-items:center;gap:10px;flex-wrap:wrap;cursor:pointer} .board-title-switch{display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;padding:4px 10px;border-radius:14px;background:#f3f3f6;color:#666;border:1px solid #e4e4e9;transition:background .15s,border-color .15s,color .15s} .board-title:hover .board-title-switch,.board-title-switch:hover{background:#fff0f3;color:#ff2442;border-color:#ffd4df} .board-bar-btn{display:inline-flex;align-items:center;gap:5px;padding:7px 13px;border-radius:20px;background:#fff0f3;color:#ff2442;font-size:13px;font-weight:600;white-space:nowrap;transition:background .15s} .board-bar-btn:hover{background:#ffe2e8} /* 暗色主题适配 */ .app.rt-dark .board-dialog-sheet, .app.rt-dark ~ .board-dialog .board-dialog-sheet, .board-dialog[data-rt-theme=dark] .board-dialog-sheet{background:#202329;color:#e2e2e7;border-color:#34373e;box-shadow:0 12px 48px #00000066} .app.rt-dark .board-dialog-header, .app.rt-dark ~ .board-dialog .board-dialog-header, .board-dialog[data-rt-theme=dark] .board-dialog-header, .app.rt-dark .board-dialog-footer, .app.rt-dark ~ .board-dialog .board-dialog-footer, .board-dialog[data-rt-theme=dark] .board-dialog-footer, .app.rt-dark .board-search-box, .app.rt-dark ~ .board-dialog .board-search-box, .board-dialog[data-rt-theme=dark] .board-search-box{background:#1a1c20;border-color:#2c3038} .app.rt-dark .board-sidebar, .app.rt-dark ~ .board-dialog .board-sidebar, .board-dialog[data-rt-theme=dark] .board-sidebar{background:#181a1f;border-color:#2c3038} .app.rt-dark .board-cat-item, .app.rt-dark ~ .board-dialog .board-cat-item, .board-dialog[data-rt-theme=dark] .board-cat-item{color:#aaa} .app.rt-dark .board-cat-item:hover, .app.rt-dark ~ .board-dialog .board-cat-item:hover, .board-dialog[data-rt-theme=dark] .board-cat-item:hover{background:#22252c;color:#eee} .app.rt-dark .board-cat-item.is-active, .app.rt-dark ~ .board-dialog .board-cat-item.is-active, .board-dialog[data-rt-theme=dark] .board-cat-item.is-active{background:#202329;color:#ff8ba0;border-left-color:#ff2442} .app.rt-dark .board-pane-header, .app.rt-dark ~ .board-dialog .board-pane-header, .board-dialog[data-rt-theme=dark] .board-pane-header{border-color:#2c3038} .app.rt-dark .board-pane-title, .app.rt-dark ~ .board-dialog .board-pane-title, .board-dialog[data-rt-theme=dark] .board-pane-title{color:#eee} .app.rt-dark .board-dialog-title, .app.rt-dark ~ .board-dialog .board-dialog-title, .board-dialog[data-rt-theme=dark] .board-dialog-title{color:#eee} .app.rt-dark .board-search-input, .app.rt-dark ~ .board-dialog .board-search-input, .board-dialog[data-rt-theme=dark] .board-search-input{background:#282c34;color:#eee;border-color:#3b404b} .app.rt-dark .board-chip, .app.rt-dark ~ .board-dialog .board-chip, .board-dialog[data-rt-theme=dark] .board-chip{background:#2c3038;color:#bbb} .app.rt-dark .board-chip:hover, .app.rt-dark ~ .board-dialog .board-chip:hover, .board-dialog[data-rt-theme=dark] .board-chip:hover{background:#383d47;color:#fff} .app.rt-dark .board-chip.is-active, .app.rt-dark ~ .board-dialog .board-chip.is-active, .board-dialog[data-rt-theme=dark] .board-chip.is-active{background:#422530;color:#ff8ba0;border-color:#653342} .app.rt-dark .board-card, .app.rt-dark ~ .board-dialog .board-card, .board-dialog[data-rt-theme=dark] .board-card{background:#252930;border-color:#323742} .app.rt-dark .board-card:hover, .app.rt-dark ~ .board-dialog .board-card:hover, .board-dialog[data-rt-theme=dark] .board-card:hover{background:#2b3039;border-color:#ff8ba0} .app.rt-dark .board-card.is-active, .app.rt-dark ~ .board-dialog .board-card.is-active, .board-dialog[data-rt-theme=dark] .board-card.is-active{background:#35252e;border-color:#ff8ba0} .app.rt-dark .board-card-name, .app.rt-dark ~ .board-dialog .board-card-name, .board-dialog[data-rt-theme=dark] .board-card-name{color:#eee} .app.rt-dark .board-card-fid, .app.rt-dark ~ .board-dialog .board-card-fid, .board-dialog[data-rt-theme=dark] .board-card-fid{background:#1b1d22;color:#aaa} .app.rt-dark .subforum-chip, .app.rt-dark ~ .board-dialog .subforum-chip, .board-dialog[data-rt-theme=dark] .subforum-chip{background:#262a32;color:#aaa} .app.rt-dark .subforum-chip:hover, .app.rt-dark ~ .board-dialog .subforum-chip:hover, .board-dialog[data-rt-theme=dark] .subforum-chip:hover{background:#323742;color:#fff} .app.rt-dark .subforum-chip.is-active, .app.rt-dark ~ .board-dialog .subforum-chip.is-active, .board-dialog[data-rt-theme=dark] .subforum-chip.is-active{background:#ff2442;color:#fff} .app.rt-dark .board-title-switch, .app.rt-dark ~ .board-dialog .board-title-switch, .board-dialog[data-rt-theme=dark] .board-title-switch{background:#262a32;border-color:#383747;color:#aaa} .app.rt-dark .board-bar-btn, .app.rt-dark ~ .board-dialog .board-bar-btn, .board-dialog[data-rt-theme=dark] .board-bar-btn{background:#3e242c;color:#ff8ba0} /* 手机端分栏与抽屉优化 */ @media(max-width:600px){ .board-dialog{align-items:flex-end} .board-dialog-sheet{width:100vw;max-width:100vw;height:86dvh;max-height:86dvh;border-radius:20px 20px 0 0;border:0;box-shadow:0 -8px 36px rgba(0,0,0,.25)} .board-handle{display:flex;height:20px} .board-handle::before{width:40px;height:5px} .board-sidebar{width:108px;padding:4px 0} .board-cat-item{padding:12px 8px;font-size:12.5px} .board-cat-item-main{gap:4px} .board-main-pane{padding:12px 14px} .board-cards-grid{grid-template-columns:1fr;gap:8px} .board-card{padding:11px 12px;min-height:60px} .board-dialog-header{padding:6px 16px 8px} .board-dialog-title{font-size:16px} .board-search-box{padding:8px 14px} .board-dialog-footer{padding:8px 14px calc(8px + env(safe-area-inset-bottom))} } /* 论坛独立主页 Portal 全景布局与分栏 */ .board-portal-view{max-width:1280px;margin:8px auto 48px;display:flex;flex-direction:column;gap:18px;width:100%;box-sizing:border-box} .board-portal-banner{padding:22px 26px;background:linear-gradient(135deg,#fff0f3 0%,#fdf6f9 100%);border-radius:20px;border:1px solid #ffd4df;display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap} .board-portal-title{margin:0 0 5px;font-size:20px;font-weight:750;color:#222} .board-portal-sub{font-size:13px;color:#666;line-height:1.5} .board-portal-actions{display:flex;align-items:center;gap:10px} .board-portal-native-link{font-size:12.5px;color:#a43d60;padding:6px 14px;border-radius:16px;background:#fff;border:1px solid #fed4df;text-decoration:none;transition:background .15s} .board-portal-native-link:hover{background:#fff0f3} .board-portal-search-wrap{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid #e2e2e7;border-radius:24px;padding:7px 16px;box-shadow:0 2px 8px rgba(0,0,0,0.03);transition:border-color .15s} .board-portal-search-wrap:focus-within{border-color:#ff2442} .board-portal-search-input{flex:1;border:none;outline:none;font-size:14px;background:transparent;color:#222} .board-portal-jump-fid{flex:none;background:#ff2442;color:#fff;border-radius:18px;padding:6px 13px;font-size:12.5px;font-weight:600;white-space:nowrap;cursor:pointer} .board-portal-cat-bar{display:flex;align-items:center;gap:8px;overflow-x:auto;width:100%;max-width:100%;box-sizing:border-box;padding:2px 0 8px;scrollbar-width:none;-webkit-overflow-scrolling:touch;touch-action:pan-x;overscroll-behavior-x:contain} .board-portal-cat-bar::-webkit-scrollbar{display:none} .board-portal-cat-btn{display:inline-flex;align-items:center;gap:6px;padding:7px 15px;border-radius:18px;background:#f2f2f5;color:#555;font-size:13px;font-weight:550;white-space:nowrap;cursor:pointer;border:0;transition:background .15s,color .15s;flex:none} .board-portal-cat-btn:hover{background:#e8e8ed;color:#111} .board-portal-cat-btn.is-active{background:#ff2442;color:#fff;font-weight:650} .board-portal-sections{display:flex;flex-direction:column;gap:20px} .board-portal-section{display:flex;flex-direction:column;gap:12px} .board-portal-section-header{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:8px;border-bottom:2px solid #f2f2f5} .board-portal-section-title{display:flex;align-items:center;gap:8px;font-size:16.5px;font-weight:750;color:#222} .board-portal-section-count{font-size:11px;background:#f0f0f4;color:#666;padding:2px 7px;border-radius:10px;font-weight:600} .board-portal-section-desc{font-size:12px;color:#888} .board-portal-cards-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px} .board-portal-card{display:flex;flex-direction:column;justify-content:space-between;padding:13px 15px;border-radius:15px;background:#fff;border:1px solid #ebebf0;box-shadow:0 2px 6px rgba(0,0,0,0.02);transition:transform .15s,box-shadow .15s,border-color .15s;text-decoration:none;min-height:76px;box-sizing:border-box} .board-portal-card:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.06);border-color:#ffd4df} .board-portal-card-top{display:flex;align-items:center;justify-content:space-between;gap:8px} .board-portal-card-name{font-size:14px;font-weight:700;color:#222} .board-portal-card:hover .board-portal-card-name{color:#ff2442} .board-portal-card-fid{font-size:10.5px;background:#f2f2f6;color:#777;padding:1px 6px;border-radius:6px;font-weight:600;flex:none} .board-portal-card-desc{font-size:11.5px;color:#777;margin-top:5px;line-height:1.45;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} /* Portal 暗色适配 */ .app.rt-dark .board-portal-banner{background:linear-gradient(135deg,#2b1f25 0%,#1f2127 100%);border-color:#482833} .app.rt-dark .board-portal-title{color:#f0f0f5} .app.rt-dark .board-portal-sub{color:#aaa} .app.rt-dark .board-portal-native-link{background:#282b32;border-color:#482833;color:#ff8ba0} .app.rt-dark .board-portal-search-wrap{background:#202329;border-color:#34373e} .app.rt-dark .board-portal-search-input{color:#eee} .app.rt-dark .board-portal-cat-btn{background:#23262d;color:#aaa} .app.rt-dark .board-portal-cat-btn:hover{background:#2e323b;color:#fff} .app.rt-dark .board-portal-cat-btn.is-active{background:#ff2442;color:#fff} .app.rt-dark .board-portal-section-header{border-color:#2d3038} .app.rt-dark .board-portal-section-title{color:#eee} .app.rt-dark .board-portal-section-count{background:#292d35;color:#aaa} .app.rt-dark .board-portal-section-desc{color:#888} .app.rt-dark .board-portal-card{background:#202329;border-color:#31353e} .app.rt-dark .board-portal-card:hover{background:#262a32;border-color:#ff8ba0} .app.rt-dark .board-portal-card-name{color:#eee} .app.rt-dark .board-portal-card-fid{background:#2c3038;color:#aaa} .app.rt-dark .board-portal-card-desc{color:#999} /* 打开弹窗时阻断底层滚动 */ :host-context(.boards-open), .app.boards-open{overflow:hidden!important} `); (shadow || app).append(style); const dialog = node('dialog', 'board-dialog'); dialog.setAttribute('aria-label', '选择论坛板块'); // Sheet 容器承载抽屉卡片主体,dialog 自身作为全屏遮罩与手势隔离层 const sheet = node('div', 'board-dialog-sheet'); // 手机端顶部滑动指示条 const handle = node('div', 'board-handle'); const header = node('div', 'board-dialog-header'); const titleWrap = node('div', 'board-dialog-title'); titleWrap.append(node('span', 'board-icon', '🧭'), node('span', '', '切换论坛板块')); const closeBtn = button('×', 'board-dialog-close', () => close()); closeBtn.setAttribute('aria-label', '关闭板块切换'); header.append(titleWrap, closeBtn); const searchBox = node('div', 'board-search-box'); const searchSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); searchSvg.setAttribute('class', 'board-search-icon'); searchSvg.setAttribute('viewBox', '0 0 24 24'); searchSvg.setAttribute('width', '18'); searchSvg.setAttribute('height', '18'); searchSvg.innerHTML = ''; const searchInput = node('input', 'board-search-input'); searchInput.type = 'text'; searchInput.setAttribute('inputmode', 'search'); searchInput.placeholder = '搜索板块名称,或输入 FID 直达…'; searchInput.setAttribute('aria-label', '搜索板块名称或输入板块ID'); const jumpFidBtn = button('进入 FID ➔', 'board-jump-fid', () => jumpToFid(searchInput.value.trim())); jumpFidBtn.hidden = true; searchBox.append(searchSvg, searchInput, jumpFidBtn); // 左右分栏容器 const splitLayout = node('div', 'board-split-layout'); const sidebar = node('nav', 'board-sidebar'); sidebar.setAttribute('aria-label', '版面分类导航'); sidebar.setAttribute('role', 'tablist'); const mainPane = node('main', 'board-main-pane'); const paneHeader = node('div', 'board-pane-header'); const paneTitleWrap = node('div'); const paneTitle = node('div', 'board-pane-title', '常用热门'); const paneDesc = node('div', 'board-pane-desc', ''); paneTitleWrap.append(paneTitle, paneDesc); const paneExtra = node('div', 'board-pane-extra'); paneHeader.append(paneTitleWrap, paneExtra); function handleClearRecent() { clearRecentBoards(); testRecentSection.hidden = true; testRecentList.replaceChildren(); activeCategoryId = 'recommend'; renderSidebar(); renderRightPane(); } // 兼容测试容器:隐藏的 subforums 与 recent 容器保留 class 供 querySelector 使用 const testSubSection = node('div', 'board-section board-section-subforums'); testSubSection.hidden = true; const testSubList = node('div', 'board-chips-grid board-subforums-list'); testSubSection.append(testSubList); const testRecentSection = node('div', 'board-section board-section-recent'); testRecentSection.hidden = true; const testRecentList = node('div', 'board-chips-grid board-recent-list'); const testClearRecent = button('清空', 'board-clear-recent', () => handleClearRecent()); testRecentSection.append(testRecentList, testClearRecent); // 保留隐藏的 category tabs 容器兼容旧测试 querySelector const testCatTabs = node('div', 'board-category-tabs'); testCatTabs.hidden = true; const cardsGrid = node('div', 'board-cards-grid'); mainPane.append(paneHeader, cardsGrid, testSubSection, testRecentSection, testCatTabs); splitLayout.append(sidebar, mainPane); // 搜索全屏视图 const searchResults = node('div', 'board-search-results'); searchResults.hidden = true; const searchCardsGrid = node('div', 'board-cards-grid board-search-grid'); searchResults.append(searchCardsGrid); const footer = node('div', 'board-dialog-footer'); footer.append( node('span', '', '💡 支持输入任意版面 FID 直达'), link('/forum.php', '原版全部板块 ↗', 'board-native-link') ); footer.querySelector('a').dataset.readscapeNative = 'true'; sheet.append(handle, header, searchBox, splitLayout, searchResults, footer); dialog.append(sheet); (shadow || app).append(dialog); // 状态变量 let activeCategoryId = 'recommend'; let searchQuery = ''; let cachedSubforums = []; function jumpToFid(fid) { const clean = fid.replace(/^[^\d-]+/, '').trim(); if (/^-?\d+$/.test(clean)) { recordRecentBoard({ fid: clean, name: FORUM_MAP.get(clean)?.name || `板块 FID ${clean}` }); close(); window.location.assign(`/thread.php?fid=${clean}`); } } function renderSidebar() { sidebar.replaceChildren(); const recent = readRecentBoards(); // 组织栏目列表 const categories = []; // 1. 常用热门 categories.push({ id: 'recommend', name: '常用热门', icon: '🔥' }); // 2. 当前子版块(如有) if (cachedSubforums.length) { categories.push({ id: 'subforums', name: '当前子版', icon: '📂', badge: String(cachedSubforums.length) }); } // 3. 最近访问(如有) if (recent.length) { categories.push({ id: 'recent', name: '最近访问', icon: '🕒', badge: String(recent.length) }); } // 4. 其余分类 for (const cat of FORUM_CATEGORIES) { if (cat.id !== 'recommend') { categories.push({ id: cat.id, name: cat.name, icon: cat.icon }); } } // 确保激活分类存在 if (!categories.some(c => c.id === activeCategoryId)) { activeCategoryId = 'recommend'; } for (const cat of categories) { const item = node('button', `board-cat-item${cat.id === activeCategoryId ? ' is-active' : ''}`); item.type = 'button'; item.setAttribute('role', 'tab'); item.setAttribute('aria-selected', String(cat.id === activeCategoryId)); const mainSpan = node('span', 'board-cat-item-main'); mainSpan.append(node('span', '', cat.icon), document.createTextNode(` ${cat.name}`)); item.append(mainSpan); if (cat.badge) { item.append(node('span', 'board-cat-item-badge', cat.badge)); } item.addEventListener('click', () => { activeCategoryId = cat.id; renderSidebar(); renderRightPane(); }); sidebar.append(item); } } function renderRightPane() { cardsGrid.replaceChildren(); paneExtra.replaceChildren(); const curFid = pageURL.searchParams.get('fid'); const curStid = pageURL.searchParams.get('stid'); if (activeCategoryId === 'subforums') { paneTitle.textContent = '当前板块子版块'; paneDesc.textContent = `共 ${cachedSubforums.length} 个子版块或关联合集`; testSubSection.hidden = false; testSubList.replaceChildren(); for (const sub of cachedSubforums) { const isCur = (sub.fid && sub.fid === curFid) || (sub.stid && sub.stid === curStid); const card = link(sub.url, '', `board-card${isCur ? ' is-active' : ''}`); const mainRow = node('div', 'board-card-main'); mainRow.append(node('span', 'board-card-name', sub.name)); if (isCur) mainRow.append(node('span', 'board-card-active-tag', '当前')); card.append(mainRow); if (sub.desc) card.append(node('div', 'board-card-desc', sub.desc)); const meta = node('div', 'board-card-meta'); if (sub.fid) meta.append(node('span', 'board-card-fid', `FID ${sub.fid}`)); else if (sub.stid) meta.append(node('span', 'board-card-fid', `STID ${sub.stid}`)); card.append(meta); card.addEventListener('click', () => { recordRecentBoard(sub); close(); }); cardsGrid.append(card); // 同时放入 testSubList 兼容测试查找 const chip = link(sub.url, sub.name, `board-chip${isCur ? ' is-active' : ''}`); chip.addEventListener('click', () => { recordRecentBoard(sub); close(); }); testSubList.append(chip); } return; } if (activeCategoryId === 'recent') { const recent = readRecentBoards(); paneTitle.textContent = '最近访问板块'; paneDesc.textContent = `保留最近浏览过的 ${recent.length} 个板块`; const clearBtn = button('清空历史', 'board-clear-recent', () => { handleClearRecent(); }); paneExtra.append(clearBtn); testRecentSection.hidden = false; testRecentList.replaceChildren(); for (const item of recent) { const isCur = (item.fid && item.fid === curFid) || (item.stid && item.stid === curStid); const card = link(item.url, '', `board-card${isCur ? ' is-active' : ''}`); const mainRow = node('div', 'board-card-main'); mainRow.append(node('span', 'board-card-name', item.name)); if (isCur) mainRow.append(node('span', 'board-card-active-tag', '当前')); card.append(mainRow); if (item.desc) card.append(node('div', 'board-card-desc', item.desc)); const meta = node('div', 'board-card-meta'); if (item.fid) meta.append(node('span', 'board-card-fid', `FID ${item.fid}`)); else if (item.stid) meta.append(node('span', 'board-card-fid', `STID ${item.stid}`)); card.append(meta); card.addEventListener('click', () => { recordRecentBoard(item); close(); }); cardsGrid.append(card); // 同时放入 testRecentList 兼容测试查找 const chip = link(item.url, item.name, `board-chip${isCur ? ' is-active' : ''}`); chip.addEventListener('click', () => { recordRecentBoard(item); close(); }); testRecentList.append(chip); } return; } // 标准分类板块 const cat = FORUM_CATEGORIES.find(c => c.id === activeCategoryId) || FORUM_CATEGORIES[0]; paneTitle.textContent = `${cat.icon} ${cat.name}`; paneDesc.textContent = cat.desc || `共 ${cat.forums.length} 个推荐板块`; for (const forum of cat.forums) { const isCur = String(forum.fid) === curFid; const url = forum.url || `/thread.php?fid=${forum.fid}`; const card = link(url, '', `board-card${isCur ? ' is-active' : ''}`); const mainRow = node('div', 'board-card-main'); mainRow.append(node('span', 'board-card-name', forum.name)); if (isCur) mainRow.append(node('span', 'board-card-active-tag', '当前')); card.append(mainRow); if (forum.desc) card.append(node('div', 'board-card-desc', forum.desc)); const meta = node('div', 'board-card-meta'); if (forum.fid) meta.append(node('span', 'board-card-fid', `FID ${forum.fid}`)); else if (forum.stid) meta.append(node('span', 'board-card-fid', `STID ${forum.stid}`)); card.append(meta); card.addEventListener('click', () => { recordRecentBoard({ fid: forum.fid, stid: forum.stid, name: forum.name, url }); close(); }); cardsGrid.append(card); } } function renderSearchResults() { searchCardsGrid.replaceChildren(); const q = searchQuery.toLowerCase().trim(); const curFid = pageURL.searchParams.get('fid'); const seenFids = new Set(); const matched = []; // 全局匹配各分类 for (const cat of FORUM_CATEGORIES) { for (const forum of cat.forums) { const fidStr = String(forum.fid); if (seenFids.has(fidStr)) continue; if (forum.name.toLowerCase().includes(q) || (forum.desc && forum.desc.toLowerCase().includes(q)) || fidStr.includes(q)) { seenFids.add(fidStr); matched.push(forum); } } } // 匹配子版块 for (const sub of cachedSubforums) { const idStr = sub.fid || sub.stid || ''; if (sub.name.toLowerCase().includes(q) || idStr.includes(q)) { matched.push(sub); } } if (!matched.length) { const empty = node('div', 'board-empty-search'); empty.innerHTML = `未找到包含 "${q}" 的板块。若知道版面 ID,可直接点击右上角进入。`; searchCardsGrid.append(empty); return; } for (const forum of matched) { const isCur = String(forum.fid) === curFid; const url = forum.url || `/thread.php?fid=${forum.fid}`; const card = link(url, '', `board-card${isCur ? ' is-active' : ''}`); const mainRow = node('div', 'board-card-main'); mainRow.append(node('span', 'board-card-name', forum.name)); if (isCur) mainRow.append(node('span', 'board-card-active-tag', '当前')); card.append(mainRow); if (forum.desc) card.append(node('div', 'board-card-desc', forum.desc)); const meta = node('div', 'board-card-meta'); if (forum.fid) meta.append(node('span', 'board-card-fid', `FID ${forum.fid}`)); else if (forum.stid) meta.append(node('span', 'board-card-fid', `STID ${forum.stid}`)); card.append(meta); card.addEventListener('click', () => { recordRecentBoard({ fid: forum.fid, stid: forum.stid, name: forum.name, url }); close(); }); searchCardsGrid.append(card); } } // 搜索处理 searchInput.addEventListener('input', () => { searchQuery = searchInput.value.trim(); if (/^-?\d+$/.test(searchQuery)) { jumpFidBtn.hidden = false; jumpFidBtn.textContent = `进入 FID ${searchQuery} ➔`; } else { jumpFidBtn.hidden = true; } if (searchQuery) { cardsGrid.replaceChildren(); splitLayout.hidden = true; searchResults.hidden = false; testCatTabs.hidden = true; renderSearchResults(); } else { searchCardsGrid.replaceChildren(); splitLayout.hidden = false; searchResults.hidden = true; testCatTabs.hidden = false; renderRightPane(); } }); searchInput.addEventListener('keydown', event => { if (event.key === 'Enter') { const val = searchInput.value.trim(); if (/^-?\d+$/.test(val)) jumpToFid(val); else { const firstCard = (searchQuery ? searchCardsGrid : cardsGrid).querySelector('.board-card'); if (firstCard) firstCard.click(); } } }); // 手机端顶部把手与标题支持下滑关闭手势 let startY = 0; let currentTranslateY = 0; let isDraggingSheet = false; for (const trigger of [handle, header]) { trigger.addEventListener('touchstart', e => { if (e.touches.length !== 1 || e.target.closest('.board-dialog-close, button, input')) return; startY = e.touches[0].clientY; isDraggingSheet = true; sheet.style.transition = 'none'; }, { passive: true }); trigger.addEventListener('touchmove', e => { if (!isDraggingSheet) return; const deltaY = e.touches[0].clientY - startY; if (deltaY > 0) { if (e.cancelable) e.preventDefault(); currentTranslateY = deltaY; sheet.style.transform = `translateY(${deltaY}px)`; } }, { passive: false }); trigger.addEventListener('touchend', () => { if (!isDraggingSheet) return; isDraggingSheet = false; sheet.style.transition = 'transform .2s cubic-bezier(.16, 1, .3, 1)'; if (currentTranslateY > 80) { sheet.style.transform = 'translateY(100%)'; setTimeout(() => { close(); sheet.style.transform = ''; }, 160); } else { sheet.style.transform = ''; } currentTranslateY = 0; }, { passive: true }); } dialog.addEventListener('click', event => { if (event.target === dialog) close(); }); dialog.addEventListener('cancel', () => { close(); }); dialog.addEventListener('touchmove', event => { if (event.target === dialog) { if (event.cancelable) event.preventDefault(); } }, { passive: false }); dialog.addEventListener('wheel', event => { if (event.target === dialog) event.preventDefault(); }, { passive: false }); let savedDocOverflow = ''; let savedBodyOverflow = ''; let savedBodyTouchAction = ''; function open(initialQuery = '') { dialog.dataset.rtTheme = prefs?.theme || 'light'; app.classList.add('boards-open'); if (document.documentElement) { document.documentElement.setAttribute('data-readscape-modal-open', ''); savedDocOverflow = document.documentElement.style.overflow; document.documentElement.style.overflow = 'hidden'; document.documentElement.classList.add('rt-boards-locked'); } if (document.body) { savedBodyOverflow = document.body.style.overflow; savedBodyTouchAction = document.body.style.touchAction; document.body.style.overflow = 'hidden'; document.body.style.touchAction = 'none'; document.body.classList.add('rt-boards-locked'); } cachedSubforums = extractCurrentSubforums(document, window, pageURL); // 如果当前板块有子版块,默认定位到子版 if (cachedSubforums.length && activeCategoryId !== 'recent') { activeCategoryId = 'subforums'; } else if (activeCategoryId === 'subforums' && !cachedSubforums.length) { activeCategoryId = 'recommend'; } // 填充测试兼容容器 const recent = readRecentBoards(); testRecentSection.hidden = !recent.length; testRecentList.replaceChildren(); for (const item of recent) { const chip = link(item.url, item.name, 'board-chip'); chip.addEventListener('click', () => { recordRecentBoard(item); close(); }); testRecentList.append(chip); } testSubSection.hidden = !cachedSubforums.length; testSubList.replaceChildren(); for (const sub of cachedSubforums) { const chip = link(sub.url, sub.name, 'board-chip'); chip.addEventListener('click', () => { recordRecentBoard(sub); close(); }); testSubList.append(chip); } searchInput.value = initialQuery; searchQuery = initialQuery; jumpFidBtn.hidden = !/^-?\d+$/.test(initialQuery); if (searchQuery) { cardsGrid.replaceChildren(); splitLayout.hidden = true; searchResults.hidden = false; testCatTabs.hidden = true; renderSearchResults(); } else { searchCardsGrid.replaceChildren(); splitLayout.hidden = false; searchResults.hidden = true; testCatTabs.hidden = false; renderSidebar(); renderRightPane(); } if (typeof dialog.showModal === 'function') { try { dialog.showModal(); } catch { dialog.setAttribute('open', ''); dialog.open = true; } } else { dialog.setAttribute('open', ''); dialog.open = true; } searchInput.focus?.(); } function close() { // 恢复底层页面滚动 app.classList.remove('boards-open'); if (document.documentElement) { document.documentElement.removeAttribute('data-readscape-modal-open'); document.documentElement.style.overflow = savedDocOverflow; document.documentElement.classList.remove('rt-boards-locked'); } if (document.body) { document.body.style.overflow = savedBodyOverflow; document.body.style.touchAction = savedBodyTouchAction; document.body.classList.remove('rt-boards-locked'); } if (typeof dialog.close === 'function') { try { dialog.close(); } catch { dialog.removeAttribute('open'); dialog.open = false; } } else { dialog.removeAttribute('open'); dialog.open = false; } } // 渲染列表页面顶部的子版块横条 (Subforum Strip) function renderSubforumStrip(container) { if (!container) return; const subforums = extractCurrentSubforums(document, window, pageURL); cachedSubforums = subforums; if (!subforums.length) { container.hidden = true; return; } container.hidden = false; container.replaceChildren(); const curFid = pageURL.searchParams.get('fid'); const curStid = pageURL.searchParams.get('stid'); const isMainBoard = !curStid; // 全部讨论 chip const allChip = link(pageURL.pathname + (curFid ? `?fid=${curFid}` : ''), '全部', `subforum-chip${isMainBoard ? ' is-active' : ''}`); container.append(allChip); for (const sub of subforums) { const isCur = (sub.fid && sub.fid === curFid) || (sub.stid && sub.stid === curStid); const chip = link(sub.url, sub.name, `subforum-chip${isCur ? ' is-active' : ''}`); chip.addEventListener('click', () => { recordRecentBoard(sub); }); container.append(chip); } const moreChip = link('/index.php', '更多板块 ↗', 'subforum-chip subforum-chip-more'); moreChip.target = '_blank'; moreChip.rel = 'noopener noreferrer'; moreChip.setAttribute('title', '前往论坛主页查看全部板块(在新页面打开)'); container.append(moreChip); } // 从原站首页(forum.php / index.php)DOM 及 window 全局变量中提取板块与分类 function extractNativeHomepageBoards(doc = document, win = window) { const nativeForums = []; const seen = new Set(); const allData = win.__ALL_FORUM_DATA || win.__F; if (allData && typeof allData === 'object') { for (const [key, val] of Object.entries(allData)) { if (!Array.isArray(val) || val.length < 2) continue; const rawId = String(val[0] || key); const isStid = rawId.startsWith('t') || rawId.startsWith('s') || ((Number(val[4]) & 16) !== 0); const cleanId = rawId.replace(/^[ts]/, ''); const keyStr = isStid ? `stid:${cleanId}` : `fid:${cleanId}`; if (seen.has(keyStr)) continue; seen.add(keyStr); const name = String(val[1] || '').trim(); const desc = String(val[2] || '').trim(); if (!name || name.length < 2) continue; const url = isStid ? `/thread.php?stid=${cleanId}` : `/thread.php?fid=${cleanId}`; nativeForums.push({ fid: isStid ? null : cleanId, stid: isStid ? cleanId : null, name, desc, url }); } } const links = doc.querySelectorAll('a[href*="thread.php?fid="], a[href*="thread.php?stid="], #custombg a[href*="thread.php"]'); for (const a of links) { const raw = a.getAttribute('href'); if (!raw) continue; let u; try { u = new URL(raw, win.location.href); } catch { continue; } const fid = u.searchParams.get('fid'); const stid = u.searchParams.get('stid'); if (!fid && !stid) continue; const keyStr = stid ? `stid:${stid}` : `fid:${fid}`; if (seen.has(keyStr)) continue; seen.add(keyStr); const name = a.textContent.trim().replace(/^\s*\[.*?\]\s*/, ''); if (!name || name.length < 2) continue; const parentTd = a.closest('td, li, .forum_group, div'); let desc = ''; if (parentTd) { const descEl = parentTd.querySelector('.desc, .small, .explain, span'); if (descEl && descEl !== a) desc = descEl.textContent.trim(); } const url = stid ? `/thread.php?stid=${stid}` : `/thread.php?fid=${fid}`; nativeForums.push({ fid, stid, name, desc, url }); } return nativeForums; } // 渲染在 bbs.nga.cn/ 或 forum.php 根首页时的板块导航独立分栏全景视图 function renderPortalView(mainContainer, emptyContainer) { if (emptyContainer) emptyContainer.hidden = true; let portal = mainContainer.querySelector('.board-portal-view'); if (!portal) { portal = node('div', 'board-portal-view'); mainContainer.insertBefore(portal, emptyContainer); } portal.replaceChildren(); // 重新解析原版主页中的板块,并与预设分类深度融合 const nativeForums = extractNativeHomepageBoards(document, window); const categories = FORUM_CATEGORIES.map(cat => ({ id: cat.id, name: cat.name, icon: cat.icon, desc: cat.desc, forums: [...cat.forums] })); const knownFids = new Set(); for (const cat of categories) { for (const f of cat.forums) { if (f.fid) knownFids.add(String(f.fid)); } } const extraForums = []; for (const nf of nativeForums) { const idKey = nf.fid ? String(nf.fid) : (nf.stid ? `stid:${nf.stid}` : null); if (!idKey || knownFids.has(idKey)) continue; knownFids.add(idKey); extraForums.push(nf); } if (extraForums.length > 0) { categories.push({ id: 'native-extra', name: '原版其他板块', icon: '📂', desc: `从当前原版首页解析到的其他 ${extraForums.length} 个版面与合集`, forums: extraForums }); } // 1. 欢迎全景横幅 const banner = node('div', 'board-portal-banner'); const bannerLeft = node('div'); const bTitle = node('h2', 'board-portal-title', '欢迎使用 阅境 · NGA 版块导航'); const bSub = node('div', 'board-portal-sub', 'NGA 拥有成百上千个精彩子版块。选择您感兴趣的板块开始沉浸式阅读:'); bannerLeft.append(bTitle, bSub); const bannerActions = node('div', 'board-portal-actions'); const nativeLink = link('/forum.php', '原版首页 ↗', 'board-portal-native-link'); nativeLink.dataset.readscapeNative = 'true'; bannerActions.append(nativeLink); banner.append(bannerLeft, bannerActions); portal.append(banner); // 2. 搜索与直达 FID 栏 const searchWrap = node('div', 'board-portal-search-wrap'); const searchIcon = node('span', '', '🔍'); const searchInput = node('input', 'board-portal-search-input'); searchInput.type = 'search'; searchInput.placeholder = '搜索板块名称、描述,或直接输入数字 FID 快速跳转…'; const jumpFidBtn = button('', 'board-portal-jump-fid', () => { const fid = searchInput.value.trim(); if (fid) location.href = `/thread.php?fid=${fid}`; }); jumpFidBtn.hidden = true; searchWrap.append(searchIcon, searchInput, jumpFidBtn); portal.append(searchWrap); // 3. 分类导航分栏标签条 (Segmented Category Bar) const catBar = node('div', 'board-portal-cat-bar'); let currentFilter = 'all'; const createCard = (forum) => { const targetUrl = forum.url || (forum.stid ? `/thread.php?stid=${forum.stid}` : `/thread.php?fid=${forum.fid}`); const card = link(targetUrl, '', 'board-portal-card board-card'); const topRow = node('div', 'board-portal-card-top board-card-main'); topRow.append(node('span', 'board-portal-card-name board-card-name', forum.name)); const fidText = forum.fid ? `FID ${forum.fid}` : (forum.stid ? `STID ${forum.stid}` : ''); if (fidText) topRow.append(node('span', 'board-portal-card-fid board-card-fid', fidText)); card.append(topRow); if (forum.desc) { card.append(node('div', 'board-portal-card-desc board-card-desc', forum.desc)); } card.addEventListener('click', () => { recordRecentBoard(forum); }); return card; }; const renderSections = () => { sectionsContainer.replaceChildren(); const query = searchInput.value.trim().toLowerCase(); // 搜索模式 if (query) { catBar.hidden = true; const matched = []; const seenFids = new Set(); for (const cat of categories) { for (const forum of cat.forums) { const fidStr = String(forum.fid || forum.stid || ''); if (seenFids.has(fidStr)) continue; if (forum.name.toLowerCase().includes(query) || (forum.desc && forum.desc.toLowerCase().includes(query)) || fidStr.includes(query)) { seenFids.add(fidStr); matched.push(forum); } } } const searchSection = node('div', 'board-portal-section'); const sHead = node('div', 'board-portal-section-header'); sHead.append( node('div', 'board-portal-section-title', `🔍 搜索结果(找到 ${matched.length} 个板块)`) ); searchSection.append(sHead); if (!matched.length) { const empty = node('div', 'board-empty-search'); empty.innerHTML = `未找到包含 "${query}" 的板块。若知道版面 ID,可直接点击右侧直达按钮。`; searchSection.append(empty); } else { const grid = node('div', 'board-portal-cards-grid board-cards-grid'); for (const f of matched) { grid.append(createCard(f)); } searchSection.append(grid); } sectionsContainer.append(searchSection); return; } catBar.hidden = false; // 最近访问展示(在全部或最近激活时) const recent = readRecentBoards(); if (recent.length && (currentFilter === 'all' || currentFilter === 'recent')) { const rSec = node('div', 'board-portal-section board-section-recent'); const rHead = node('div', 'board-portal-section-header'); const rTitle = node('div', 'board-portal-section-title', '🕒 最近访问板块'); const rClear = button('清空历史', 'board-clear-recent', () => { clearRecentBoards(); renderSections(); }); rHead.append(rTitle, rClear); rSec.append(rHead); const rGrid = node('div', 'board-portal-cards-grid board-chips-grid'); for (const f of recent) { rGrid.append(createCard(f)); } rSec.append(rGrid); sectionsContainer.append(rSec); } // 各栏目分栏展示 for (const cat of categories) { if (currentFilter !== 'all' && currentFilter !== cat.id) continue; const sec = node('div', 'board-portal-section'); const sHead = node('div', 'board-portal-section-header board-section-title'); const sTitleWrap = node('div', 'board-portal-section-title'); sTitleWrap.append( node('span', '', `${cat.icon} ${cat.name}`), node('span', 'board-portal-section-count', `${cat.forums.length} 个板块`) ); const sDesc = node('div', 'board-portal-section-desc', cat.desc || ''); sHead.append(sTitleWrap, sDesc); sec.append(sHead); const grid = node('div', 'board-portal-cards-grid board-cards-grid'); for (const forum of cat.forums) { grid.append(createCard(forum)); } sec.append(grid); sectionsContainer.append(sec); } }; // 组织分类导航条按钮 const catTabs = [{ id: 'all', name: '全部板块', icon: '🌐' }]; const recent = readRecentBoards(); if (recent.length) { catTabs.push({ id: 'recent', name: '最近访问', icon: '🕒' }); } for (const cat of categories) { catTabs.push({ id: cat.id, name: cat.name, icon: cat.icon }); } for (const tabItem of catTabs) { const btn = button(`${tabItem.icon} ${tabItem.name}`, `board-portal-cat-btn${tabItem.id === currentFilter ? ' is-active' : ''}`, () => { currentFilter = tabItem.id; for (const b of catBar.querySelectorAll('.board-portal-cat-btn')) { b.classList.toggle('is-active', b === btn); } renderSections(); }); catBar.append(btn); } portal.append(catBar); // 4. 分栏内容总容器 const sectionsContainer = node('div', 'board-portal-sections'); portal.append(sectionsContainer); // 搜索输入交互 searchInput.addEventListener('input', () => { const val = searchInput.value.trim(); if (/^-?\d+$/.test(val)) { jumpFidBtn.hidden = false; jumpFidBtn.textContent = `进入 FID ${val} ➔`; } else { jumpFidBtn.hidden = true; } renderSections(); }); searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const val = searchInput.value.trim(); if (/^-?\d+$/.test(val)) { location.href = `/thread.php?fid=${val}`; } } }); renderSections(); } return { open, close, dialog, FORUM_CATEGORIES, FORUM_MAP, detectBoardInfo, extractCurrentSubforums, recordRecentBoard, readRecentBoards, clearRecentBoards, renderSubforumStrip, renderPortalView }; } const boardsManager = mountBoardsManager({ context: window, shadow, app, pageURL, prefs, savePrefs }); const userProfiles = mountUserProfiles(); const currentAccount = () => readCurrentAccount(window); const openLogin = trigger => { const account = currentAccount(); if (account) { userProfiles.show({ ...account, author: account.username }, trigger?.nodeType === 1 ? trigger : shadow.activeElement || accountButton); return; } const login = [...document.querySelectorAll('a,button')].find(el => !el.closest('.postcontent, [id^=postcontent]') && /^(登录|登陆)$/.test(el.textContent.trim())); modeToggle(false); if (login) login.click(); }; const accountButton = button('登录', 'settings-login', () => openLogin(accountButton)); const accountStatus = button('未登录', 'session-status', () => openLogin(accountStatus)); accountStatus.setAttribute('aria-label', '未登录,登录 NGA'); bar.insertBefore(accountStatus, mobileOptions); settings.append(accountButton, button('切换板块', 'settings-boards', () => boardsManager.open()), button('阅读设置', 'settings-reading', () => readingSettings.open()), button('原版页面 ↗', 'settings-native', () => toggle(false))); intro.append(heading, settings); const tabs = node('nav', 'tabs'); tabs.setAttribute('aria-label', '帖子筛选'); tabs.setAttribute('role', 'tablist'); for (const [id, label] of [['all', '全部'], ['hot', '热议'], ['saved', '收藏']]) { const b = button(label, '', () => { tab = id; render(); }); b.dataset.tab = id; b.setAttribute('role', 'tab'); tabs.append(b); } const grid = node('section', 'grid'); grid.setAttribute('aria-label', '帖子卡片'); // 手势期间只收集更新,避免按下与抬手之间改变卡片几何位置。 let listInteraction = false; for (const type of ['wheel', 'touchstart', 'keydown', 'click']) app.addEventListener(type, () => { listInteraction = true; markListActivity(); }, { passive: true, capture: true }); // 链接的原生聚焦可能把半露出的卡片滚入视口。保留聚焦与点击语义,禁止聚焦滚动。 grid.addEventListener('mousedown', event => { const cover = event.target.closest?.('a.cover'); if (!cover || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; event.preventDefault(); cover.focus({ preventScroll: true }); }); const empty = node('div', 'empty'), pager = node('nav', 'pager'); pager.setAttribute('aria-label', '论坛分页'); main.append(intro, subforumStrip, tabs, grid, empty, pager, node('div', 'foot', '按原站顺序追加 · 热议为已加载帖子回复 ≥ 100 · 收藏仅存本浏览器')); app.append(top, main); const restore = button('卡片模式', 'restore', () => toggle(true)); restore.hidden = true; shadow.append(app, restore); document.documentElement.append(host); readingSettings = mountSettings({ context: window, shadow, app, prefs, cache: postCache, save: savePrefs, change: () => { if (prefs.smoothNavigation === false) navigation.finish(); settingsRefresh(); if (!app.classList.contains('reader')) configureListRefresh(); settings.querySelectorAll('input').forEach(c => { c.checked = !!prefs['single']; }); }, original: () => modeToggle(false), login: openLogin, openBoards: () => boardsManager?.open() }); let accountSignature; function refreshAccount() { const account = currentAccount(), signature = JSON.stringify(account); if (signature === accountSignature) return; accountSignature = signature; accountButton.replaceChildren(); if (account) { accountButton.append(node('span', 'account-name', account.username), node('span', 'account-uid', `UID ${account.uid}`)); accountButton.setAttribute('aria-label', `${account.username},UID ${account.uid},查看个人资料`); } else { accountButton.textContent = '未登录 · 登录'; accountButton.setAttribute('aria-label', '未登录,登录 NGA'); } accountStatus.hidden = !!account; readingSettings.setAccount(account); userProfiles.decorate(shadow.querySelector('.rt-account'), account ? { ...account, author: account.username } : null); userProfiles.decorate(accountButton, account ? { ...account, author: account.username } : null); accountChanged(account); } refreshAccount(); const accountObserver = new MutationObserver(refreshAccount); accountObserver.observe(document.body, {childList:true,subtree:true,characterData:true}); const accountTimer = window.setInterval(refreshAccount, 1000); window.addEventListener('pagehide', () => { accountObserver.disconnect(); window.clearInterval(accountTimer); }); readingSettings.visibility(false); const viewportObserver = new MutationObserver(() => { if (!app.hidden && (viewport.content !== 'width=device-width, initial-scale=1, viewport-fit=cover' || document.head.firstElementChild !== viewport)) setViewport(true); }); viewportObserver.observe(document.head, {childList:true,subtree:true,attributes:true,attributeFilter:['content']}); window.addEventListener('pagehide', () => viewportObserver.disconnect()); // 仅点击 NGA 已提供的普通跳转链接;不调用未公开接口,不点击登录或验证按钮。 // sessionStorage 跨刷新计数:同一板块两分钟最多尝试两次,防止跳转循环。 function autoContinue() { if (gateHandled || document.title !== '访客不能直接访问') return; const target = [...document.querySelectorAll('a')].find(a => /如不能自动跳转\s*[,,]?\s*可点此链接/.test(a.textContent)); if (!target) return; gateHandled = true; if (prefs.enabled !== false && prefs.smoothNavigation !== false) navigation.show(); const notice = node('div', '', '正在点击 NGA 的跳转链接…'); notice.style.cssText = 'position:fixed;bottom:20px;left:16px;right:16px;max-width:460px;z-index:2147483001;padding:13px 16px;background:#fff;color:#555;border:1px solid #eee;border-radius:12px;box-shadow:0 4px 20px #0001;font-size:13px;line-height:1.6'; shadow.append(notice); const retryKey = `${KEY}-continue-${pageURL.searchParams.get('stid') || pageURL.searchParams.get('fid') || `tid-${pageURL.searchParams.get('tid') || 'list'}`}`; let state; try { state = JSON.parse(sessionStorage.getItem(retryKey) || 'null'); if (!state || Date.now() - state.start > 120000) state = { start: Date.now(), count: 0 }; if (state.count >= 2) { navigation.finish(); notice.textContent = '自动跳转已尝试两次。请使用原页登录或手动点击链接。'; return; } state.count++; sessionStorage.setItem(retryKey, JSON.stringify(state)); } catch { navigation.finish(); notice.textContent = '浏览器不允许记录跳转次数,请手动点击原页链接。'; return; } gateTimer = setTimeout(() => { if (!target.isConnected || document.title !== '访客不能直接访问') { notice.remove(); return; } // 使用原链接 onclick(当前页面为 g()),而非猜测/重写跳转地址。 target.click(); setTimeout(() => { if (items.length || document.title !== '访客不能直接访问') notice.remove(); else notice.textContent = '已点击原生跳转链接;若仍停留在此页,请登录后访问。'; }, 5000); }, 1200); } function toggle(enabled) { const readingPosition = getReadingScroll(); prefs.enabled = enabled; savePrefs(); app.hidden = !enabled || (!items.length && !listBootPending && !isForumRoot); restore.hidden = enabled || (!items.length && !isForumRoot); setViewport(!app.hidden); setSurface(!app.hidden, readingPosition); readingSettings?.visibility(!app.hidden); if (!app.hidden) navigation.finish(); if (app.hidden) cardObserver?.disconnect(); else render(); } const usesDocumentScroll = () => document.documentElement.hasAttribute('data-readscape-document-scroll'); const getReadingScroll = () => usesDocumentScroll() ? window.scrollY : app.scrollTop; function setReadingScroll(top) { if (usesDocumentScroll()) { if (window.scrollY !== top) window.scrollTo({left:0,top}); } else app.scrollTop = top; } function scrollReadingTo(options) { (usesDocumentScroll() ? window : app).scrollTo(options); } function setSurface(enabled, savedPosition) { const wasActive = document.documentElement.hasAttribute('data-readscape-active'), wasDocument = usesDocumentScroll(); const position = wasActive ? savedPosition ?? getReadingScroll() : readingScroll; if (enabled && !wasActive) nativeScroll = { left: window.scrollX, top: window.scrollY }; if (!enabled && wasActive) readingScroll = position; const documentScroll = enabled && !!mobileViewport?.matches; document.documentElement.toggleAttribute('data-readscape-active', enabled); document.documentElement.toggleAttribute('data-readscape-document-scroll', documentScroll); app.classList.toggle('document-scroll', documentScroll); document.documentElement.style.overflow = enabled ? documentScroll ? 'auto' : 'hidden' : originalOverflow; if (enabled && (!wasActive || documentScroll !== wasDocument)) { app.classList.remove('rt-chrome-hidden'); setReadingScroll(position); } if (!enabled && nativeScroll) { if (window.scrollX !== nativeScroll.left || window.scrollY !== nativeScroll.top) window.scrollTo(nativeScroll); nativeScroll = null; } } const updateScrollMode = () => { if (!app.hidden) { setSurface(true); observeListEnd(); } }; mobileViewport?.addEventListener('change', updateScrollMode); let chromeScroll = 0; const updateReadingChrome = () => { if (!usesDocumentScroll() || document.documentElement.hasAttribute('data-readscape-reader-open') || document.documentElement.hasAttribute('data-readscape-modal-open')) return; const top = window.scrollY, delta = top - chromeScroll; if (top < 90 || Math.abs(delta) >= 12) { app.classList.toggle('rt-chrome-hidden', top >= 90 && delta > 0 && !app.classList.contains('options-open')); chromeScroll = top; } }; window.addEventListener('scroll', updateReadingChrome, {passive:true}); window.addEventListener('pagehide', () => { mobileViewport?.removeEventListener('change', updateScrollMode); window.removeEventListener('scroll', updateReadingChrome); }); function setViewport(enabled) { if (enabled) { viewport.content = 'width=device-width, initial-scale=1, viewport-fit=cover'; if (!viewport.isConnected) document.head.append(viewport); // 原站可能晚于脚本写入第二个 viewport,以最先声明的阅读视口为准。 if (document.head.firstElementChild !== viewport) document.head.prepend(viewport); } else if (!originalViewport) viewport.remove(); else if (originalViewportContent == null) viewport.removeAttribute('content'); else viewport.setAttribute('content', originalViewportContent); } if (window.top === window.self && typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('切换 NGA 阅读 / 原版', () => modeToggle(!prefs.enabled)); } const firstListPage = Math.max(1, Number(pageURL.searchParams.get('page')) || 1); const listPages = new Map(); let listCursor = firstListPage, listNext = null, listBusy = false, listFailed = false, listController; let listBootPending = !!(postCache && window.indexedDB && prefs.enabled !== false); let listFromCache = false, pendingListPage, refreshController; const pendingListCovers = new Map(), listPointers = new Set(); let listActivityAt = 0, listUpdateTimer, autoRefreshTimer, pendingAutomatic = false; let pullStart, pullDistance = 0, pullCancelled = false; const pullIndicator = node('div', 'list-pull', '下拉刷新'); pullIndicator.setAttribute('role', 'status'); app.prepend(pullIndicator); function readerCoversList() { return !!document.querySelector('iframe[data-readscape-reader]'); } function markListActivity() { listActivityAt = Date.now(); scheduleListUpdate(); } function listCanUpdate() { return !coverDisposed && !app.hidden && !document.hidden && !readerCoversList() && !listPointers.size && !pullStart && !listBusy && !app.classList.contains('rt-settings-open') && Date.now() - listActivityAt >= 650; } function applyListCovers() { if (!pendingListCovers.size || coverDisposed) return; const position = getReadingScroll(); const anchor = [...grid.children].find(card => card.getBoundingClientRect().bottom > top.getBoundingClientRect().bottom); const anchorTop = anchor?.getBoundingClientRect().top; const before = new Map([...grid.children].map(card => [card.dataset.tid, { card, html: card.innerHTML }])); for (const [tid, cached] of pendingListCovers) { const card = listCards.get(tid)?.card; if (card?.isConnected) setCardCover(card, cached); } pendingListCovers.clear(); layoutMasonry(); if (position <= 2) setReadingScroll(0); else if (anchor?.isConnected) setReadingScroll(position + anchor.getBoundingClientRect().top - anchorTop); for (const card of grid.children) { const old = before.get(card.dataset.tid); if (old && old.html !== card.innerHTML) { card.classList.remove('card-updated'); void card.offsetWidth; card.classList.add('card-updated'); card.addEventListener('animationend', () => card.classList.remove('card-updated'), { once: true }); } } if (!pendingListPage) { refreshBar.classList.remove('has-update'); refreshButton.hidden = true; } } function queueListCover(card, cached) { pendingListCovers.set(card.dataset.tid, cached); if (!readerCoversList() && !listInteraction) { applyListCovers(); } else if (prefs.autoListRefresh !== false) { pendingAutomatic = true; scheduleListUpdate(); } } function scheduleListUpdate() { clearTimeout(listUpdateTimer); if (!pendingAutomatic || !pendingListPage && !pendingListCovers.size || coverDisposed) return; listUpdateTimer = setTimeout(() => { if (listCanUpdate()) applyListUpdate(false); else if (!document.hidden && !readerCoversList() && !app.hidden) scheduleListUpdate(); }, 700); } function stopListRefresh() { clearTimeout(autoRefreshTimer); clearTimeout(listUpdateTimer); autoRefreshTimer = listUpdateTimer = null; } function configureListRefresh() { clearTimeout(autoRefreshTimer); if (prefs.autoListRefresh === false) { pendingAutomatic = false; clearTimeout(listUpdateTimer); return; } if (coverDisposed || app.classList.contains('reader')) return; autoRefreshTimer = setTimeout(async () => { if (!document.hidden && !app.hidden && !readerCoversList() && !listBootPending && !listBusy) await revalidateList(); configureListRefresh(); }, 10000); if (pendingListPage || pendingListCovers.size) { pendingAutomatic = true; scheduleListUpdate(); } } document.addEventListener('visibilitychange', () => { configureListRefresh(); scheduleListUpdate(); }); window.addEventListener('pageshow', () => { applyListCovers(); configureListRefresh(); }); document.addEventListener('readscape-list-resume', () => { listPointers.clear(); applyListCovers(); listActivityAt = Date.now() + 600; scheduleListUpdate(); }); function showNotice(message, title = '提示') { let dialog = app.querySelector('.notice-dialog'); if (!dialog) { dialog = node('dialog', 'notice-dialog'); dialog.setAttribute('aria-modal', 'true'); dialog.setAttribute('aria-label', title); const head = node('div', 'notice-dialog-title', title); const msg = node('div', 'notice-dialog-msg'); const actions = node('div', 'notice-dialog-actions'); const ok = button('确定', 'notice-dialog-btn', () => close()); actions.append(ok); dialog.append(head, msg, actions); dialog.addEventListener('click', event => { if (event.target === dialog) close(); }); dialog.addEventListener('cancel', () => close()); app.append(dialog); } const msg = dialog.querySelector('.notice-dialog-msg'); if (msg) msg.textContent = message; const titleEl = dialog.querySelector('.notice-dialog-title'); if (titleEl) titleEl.textContent = title; dialog.setAttribute('aria-label', title); const returnFocus = shadow.activeElement || document.activeElement; function close() { if (dialog.open) { if (typeof dialog.close === 'function') dialog.close(); else dialog.removeAttribute('open'); } if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true }); else grid.focus?.({ preventScroll: true }); } if (typeof dialog.showModal === 'function') { try { dialog.showModal(); } catch { dialog.setAttribute('open', ''); } } else if (typeof dialog.show === 'function') { dialog.show(); } else { dialog.setAttribute('open', ''); } dialog.querySelector('.notice-dialog-btn')?.focus({ preventScroll: true }); return { dialog, close }; } function removeDeletedCard(tid) { const entry = listCards.get(tid); const card = entry?.card || grid.querySelector(`[data-tid="${tid}"]`); if (card) { cardObserver?.unobserve(card); card.remove(); listCards.delete(tid); } items = items.filter(item => item.tid !== tid); for (const page of listPages.values()) { if (Array.isArray(page?.items)) { page.items = page.items.filter(item => item.tid !== tid); } } if (favorites[tid]) { delete favorites[tid]; favoriteChanges.add(tid); postCache?.setFavorite({ tid }, false); } cacheListPages(); postCache?.remove([tid]); layoutMasonry(); const source = tab === 'saved' ? Object.values(favorites) : items; const visibleCount = source.filter(item => (tab !== 'hot' || item.replies >= 100) && (!query || `${item.title} ${item.author}`.toLowerCase().includes(query))).length; subtitle.textContent = `第 ${firstListPage}${listPages.size > 1 ? `–${listCursor}` : ''} 页 · ${visibleCount} 篇${query ? ' · 已加载搜索' : ''}`; empty.hidden = visibleCount > 0; updateListControls(); } const handlePostDeleted = event => { const { tid } = event.detail || {}; if (!tid) return; const entry = listCards.get(tid); const title = entry?.card?.item?.title || entry?.card?.querySelector?.('.title')?.textContent || ''; removeDeletedCard(tid); showNotice(title ? `“${title}” 帖子已被删除,已从瀑布流中移除。` : '该帖子已被删除,已从瀑布流中移除。'); }; document.addEventListener('readscape-post-deleted', handlePostDeleted); window.addEventListener('pagehide', () => document.removeEventListener('readscape-post-deleted', handlePostDeleted)); window.addEventListener('popstate', () => { applyListCovers(); listActivityAt = Date.now() + 600; scheduleListUpdate(); }); app.addEventListener('pointerdown', event => { listInteraction = true; listPointers.add(event.pointerId); markListActivity(); }, { passive: true, capture: true }); const releasePointer = event => { listPointers.delete(event.pointerId); markListActivity(); }; window.addEventListener('pointerup', releasePointer, { passive: true }); window.addEventListener('pointercancel', releasePointer, { passive: true }); for (const target of [app, window]) target.addEventListener('scroll', markListActivity, { passive: true }); function resetPull() { pullStart = null; pullDistance = 0; pullIndicator.classList.remove('is-visible', 'is-ready'); } app.addEventListener('touchstart', event => { resetPull(); if (!streamKey || app.classList.contains('reader') || document.documentElement.hasAttribute('data-readscape-modal-open') || (event.touches?.length || 0) !== 1 || getReadingScroll() > 2 || app.hidden || readerCoversList() || app.classList.contains('rt-settings-open') || app.classList.contains('boards-open') || event.composedPath().some(el => el?.closest?.('.board-dialog, .notice-dialog, .rt-mask, .subforum-strip, .tabs') || el?.matches?.('input,button,a'))) return; const point = event.touches[0]; pullStart = { x: point.clientX, y: point.clientY }; pullCancelled = false; }, { passive: true }); app.addEventListener('touchmove', event => { if (!pullStart || (event.touches?.length || 0) !== 1 || pullCancelled) { resetPull(); return; } markListActivity(); const point = event.touches[0], delta = point.clientY - pullStart.y; if (getReadingScroll() > 2 || Math.abs(point.clientX - pullStart.x) > 40 || delta < -8) { pullCancelled = true; resetPull(); return; } pullDistance = Math.min(120, Math.max(0, delta)); if (pullDistance > 12) { event.preventDefault(); pullIndicator.classList.add('is-visible'); } pullIndicator.classList.toggle('is-ready', pullDistance >= 72); pullIndicator.textContent = refreshController ? '正在刷新…' : pullDistance >= 72 ? '松开刷新' : '继续下拉刷新'; }, { passive: false }); app.addEventListener('touchend', () => { const refresh = pullStart && pullDistance >= 72 && !pullCancelled; resetPull(); markListActivity(); if (refresh) revalidateList({ force: true }); }, { passive: true }); app.addEventListener('touchcancel', resetPull, { passive: true }); const refreshBar = node('div', 'list-refresh-bar'); const refreshStatus = node('span', 'list-refresh-status', '正在准备列表…'); refreshStatus.setAttribute('role', 'status'); const refreshButton = button('更新列表', 'list-refresh', () => applyListUpdate()); refreshButton.hidden = true; refreshBar.append(refreshStatus, refreshButton); main.insertBefore(refreshBar, grid); const listLoad = button('加载下一页', 'pill list-load', () => loadNextListPage()); const listStatus = node('span', 'list-status'); listStatus.setAttribute('role', 'status'); function listKey(raw) { const u = safeURL(raw); if (!u || u.origin !== pageURL.origin || u.pathname !== '/thread.php') return null; u.searchParams.delete('page'); u.searchParams.delete('rand'); u.searchParams.sort(); u.hash = ''; return u.href; } const streamKey = listKey(pageURL.href); function validListURL(raw, number) { const u = safeURL(raw); return u && listKey(u.href) === streamKey && Number(u.searchParams.get('page') || 1) === number ? u.href : null; } function nextListURL(doc, number, base) { const nextURL = new URL(base); nextURL.searchParams.set('page', number + 1); nextURL.hash = ''; // 原站分页栏可能尚未生成,抓取的 HTML 也不会执行生成分页的脚本。 let metadata = doc === document ? ngaPageContext(window).__PAGE : null; if (!metadata) { const literal = [...doc.scripts].map(script => script.textContent).join('\n').match(/\b__PAGE\s*=\s*\{([^}]+)\}/)?.[1]; if (literal) { metadata = {}; for (const match of literal.matchAll(/(?:^|,)\s*['"]?([123])['"]?\s*:\s*(\d+)/g)) metadata[match[1]] = Number(match[2]); } } if (Number.isSafeInteger(Number(metadata?.[1])) && Number(metadata[1]) >= 0 && Number(metadata[3]) > 0) { return number < Math.ceil((Number(metadata[1]) + 1) / Number(metadata[3])) ? nextURL.href : null; } for (const a of doc.querySelectorAll('a[href]')) { const next = validListURL(safeURL(a.getAttribute('href'), base)?.href, number + 1); if (next) return next; } // 缺少分页信息不代表末页;按原站 page 参数尝试,重复页和错误响应会停止加载。 return nextURL.href; } function cacheListPages() { if (!streamKey) return; const snapshot = new Map(listPages); if (pendingListPage) snapshot.set(firstListPage, pendingListPage); postCache?.saveListPages(streamKey, snapshot); } function cachedPages(cache) { const restored = new Map(); let number = firstListPage; while (true) { const page = cache?.find(p => p.number === number && validListURL(p.url, number)); if (!page || !page.items.length || !page.items.every(validCachedItem) || page.next && !validListURL(page.next, number + 1)) break; const next = page.next || nextListURL(document, number, page.url); restored.set(number, { url: page.url, items: page.items, next }); if (!page.next) break; number++; } return restored; } async function startList() { if (typeof isForumRoot !== 'undefined' && isForumRoot) { listBootPending = false; scan(); return; } if (!listBootPending) { scan(); return; } const generation = postCache.generation; app.classList.add('list-preparing'); updateListHeading(); toggle(true); renderPager(); let cache, cacheTimeout; try { cache = await Promise.race([postCache.getListPages(streamKey), new Promise(resolve => { cacheTimeout = setTimeout(() => resolve(null), 1200); })]); } catch {} finally { clearTimeout(cacheTimeout); app.classList.remove('list-preparing'); } if (coverDisposed) return; const restored = generation === postCache.generation ? cachedPages(cache) : new Map(); listBootPending = false; if (!restored.size) { refreshStatus.textContent = '列表已就绪'; scan(); observeListEnd(); return; } for (const [number, page] of restored) listPages.set(number, page); listCursor = [...restored.keys()].at(-1); listNext = listPages.get(listCursor).next; listFromCache = true; mergeListPages(); toggle(prefs.enabled !== false); renderPager(); observeListEnd(); refreshStatus.textContent = '已显示缓存,正在后台更新…'; revalidateList(); } function pageSignature(page) { // 相对时间、分页和排序变化不代表帖子有新内容;兼容旧缓存缺省字段。 return JSON.stringify(page?.items.map(({tid,title,author,uid,replies,url,latest,pinned}) => ({ tid, title, author, uid: uid || null, replies: replies ?? null, url, latest: latest || url, pinned: !!pinned })).sort((a, b) => a.tid.localeCompare(b.tid))); } function stageListPage(page, force = false) { if (pageSignature(page) === pageSignature(listPages.get(firstListPage))) { listPages.get(firstListPage).next = page.next; listNext = listPages.get(listCursor).next; updateListControls(); pendingListPage = null; refreshBar.classList.remove('has-update'); refreshButton.hidden = true; refreshStatus.textContent = '列表已是最新'; pendingAutomatic = force || prefs.autoListRefresh !== false; scheduleListUpdate(); return; } pendingListPage = page; refreshBar.classList.add('has-update'); refreshButton.hidden = false; refreshStatus.textContent = '有新内容,更新后查看'; refreshButton.textContent = '更新列表'; pendingAutomatic = force || prefs.autoListRefresh !== false; scheduleListUpdate(); } function applyListUpdate(manual = true) { if (readerCoversList() || listPointers.size) { pendingAutomatic = true; scheduleListUpdate(); return; } if (!pendingListPage && !pendingListCovers.size) { if (manual) revalidateList({ force: true }); return; } const position = getReadingScroll(); const anchor = [...grid.children].find(card => card.getBoundingClientRect().bottom > top.getBoundingClientRect().bottom); const anchorTop = anchor?.getBoundingClientRect().top; const before = new Map([...grid.children].map(card => [card.dataset.tid, { card, html: card.innerHTML }])); if (pendingListPage) { // 自动更新不挪动已有帖子的顺序;离开顶部时新帖子追加到末尾。 const page = { ...pendingListPage, items: [...pendingListPage.items] }, fresh = new Map(page.items.map(item => [item.tid, item])); if (!manual && position > 2) { page.items = listPages.get(firstListPage).items.map(item => fresh.get(item.tid) || item); const present = new Set(page.items.map(item => item.tid)); page.items.push(...[...fresh.values()].filter(item => !present.has(item.tid))); } listPages.set(firstListPage, page); pendingListPage = null; listNext = listPages.get(listCursor).next; mergeListPages(); cacheListPages(); render(); } applyListCovers(); pendingAutomatic = false; if (!pendingListPage) { refreshBar.classList.remove('has-update'); refreshButton.hidden = true; refreshStatus.textContent = '列表已更新'; } } async function fetchListPage(url, number, signal) { const response = await fetch(url, { credentials: 'same-origin', cache: 'no-cache', signal }); if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!validListURL(response.url || url, number)) throw new Error('原站返回了其他页面'); const bytes = new Uint8Array(await response.arrayBuffer()), initial = new TextDecoder('utf-8').decode(bytes); const encoding = response.headers.get('content-type')?.match(/charset\s*=\s*([\w-]+)/i)?.[1] || initial.slice(0,4096).match(/charset\s*=\s*["']?([\w-]+)/i)?.[1] || 'utf-8'; const doc = new DOMParser().parseFromString(new TextDecoder(encoding).decode(bytes), 'text/html'); const found = extract(doc, url); if (!found.length) throw new Error('需要原站跳转、登录,或页面结构不支持'); return { url, items: found.map(item => ({ ...item, lastAccess: Date.now() })), next: nextListURL(doc, number, url) }; } async function revalidateList({ force = false } = {}) { if (!streamKey || app.classList.contains('reader') || refreshController || coverDisposed) return; const generation = postCache?.generation; const controller = refreshController = new AbortController(), timeout = setTimeout(() => controller.abort(), 15000); refreshBar.classList.add('is-loading'); refreshStatus.textContent = '正在后台更新…'; refreshButton.hidden = true; try { const page = await fetchListPage(pageURL.href, firstListPage, controller.signal); if (controller.signal.aborted || coverDisposed || generation !== postCache?.generation) return; // 写入新快照,当前已呈现的列表保持不动,等待用户主动更新。 const snapshot = new Map(listPages); snapshot.set(firstListPage, page); postCache?.saveListPages(streamKey, snapshot); listFromCache = true; stageListPage(page, force); } catch { if (!coverDisposed) { refreshStatus.textContent = '后台更新失败,仍可浏览缓存'; refreshBar.classList.add('has-update'); refreshButton.textContent = pendingListPage ? '更新列表' : '重试更新'; refreshButton.hidden = false; } } finally { clearTimeout(timeout); refreshController = null; refreshBar.classList.remove('is-loading'); } } function validCachedItem(item) { if (!item || typeof item.tid !== 'string' || !/^\d+$/.test(item.tid) || typeof item.title !== 'string' || typeof item.author !== 'string' || typeof item.time !== 'string') return false; const u = safeURL(item.url), latest = item.latest && safeURL(item.latest); return u?.origin === pageURL.origin && u.pathname === '/read.php' && u.searchParams.get('tid') === item.tid && (!item.latest || latest?.origin === pageURL.origin && latest.pathname === '/read.php' && latest.searchParams.get('tid') === item.tid); } function mergeListPages() { const seen = new Set(); items = []; for (const page of listPages.values()) for (const item of page.items) { if (seen.has(item.tid)) continue; seen.add(item.tid); items.push(item); } } function updateListPage(found) { for (const item of found) item.lastAccess = Date.now(); const page = { url: pageURL.href, items: found, next: nextListURL(document, firstListPage, pageURL.href) }; if (listPages.has(firstListPage) && listCursor === firstListPage && listNext !== page.next) { listPages.get(firstListPage).next = listNext = page.next; cacheListPages(); } if (listFromCache) return; if (listInteraction) { revalidateList(); return; } listPages.set(firstListPage, page); listNext = listPages.get(listCursor).next; mergeListPages(); cacheListPages(); } function updateListControls() { grid.setAttribute('aria-busy', String(listBusy || listBootPending)); listLoad.disabled = listBusy || listBootPending; listLoad.hidden = !listNext; listLoad.textContent = listBusy ? '正在加载…' : listFailed ? '重试下一页' : '加载下一页'; if (listNext && listStatus.textContent === '已经到底了') listStatus.textContent = ''; if (!listNext && items.length) listStatus.textContent = '已经到底了'; } async function loadNextListPage() { if (listBusy || listBootPending || !listNext || app.hidden) return; const url = listNext, number = listCursor + 1; listBusy = true; listFailed = false; updateListControls(); listStatus.textContent = `正在读取第 ${number} 页…`; const controller = listController = new AbortController(), timeout = setTimeout(() => controller.abort(), 15000); try { const page = await fetchListPage(url, number, controller.signal), found = page.items; if (controller.signal.aborted) return; if (!found.some(item => !items.some(existing => existing.tid === item.tid))) { listNext = null; listStatus.textContent = '原站未返回新帖子,已停止重复加载。'; return; } listNext = page.next; listPages.set(number, { url, items: found, next: listNext }); listCursor = number; mergeListPages(); cacheListPages(); render(); listStatus.textContent = listNext ? `已加载第 ${number} 页` : '已经到底了'; } catch (error) { listFailed = true; listStatus.replaceChildren(node('span', '', `${error.name === 'AbortError' ? '加载超时' : error.message}。 `), link(url, '在原站打开')); listStatus.querySelector('a').dataset.readscapeNative = 'true'; } finally { clearTimeout(timeout); listBusy = false; updateListControls(); observeListEnd(); } } function observeListEnd() { listObserver?.disconnect(); if (typeof window.IntersectionObserver !== 'function' || !streamKey) return; listObserver = new window.IntersectionObserver(entries => { if (entries.some(entry => entry.isIntersecting) && !listFailed && tab === 'all' && !query && !document.documentElement.hasAttribute('data-readscape-reader-open')) loadNextListPage(); }, { root: usesDocumentScroll() ? null : app, rootMargin: '0px 0px 600px 0px' }); listObserver.observe(pager); } // 每张卡片固定所在列,列内按实测高度累加。自动网格排位会因某张卡片增高 // 把后续卡片换到另一列;显式行列位置让封面、字体加载与追加只影响所在列。 function layoutMasonry() { if (!grid.isConnected || !grid.classList.contains('masonry')) return; const tracks = window.getComputedStyle(grid).gridTemplateColumns.trim().split(/\s+/); const columns = Math.max(1, tracks.length); const rows = Array(columns).fill(1); const cards = [...grid.children]; cards.forEach((card, index) => { card.style.gridColumn = String(index % columns + 1); }); const spans = cards.map(card => Math.max(1, Math.ceil(((card.getBoundingClientRect().height || card.offsetHeight) + 18) / 4))); cards.forEach((card, index) => { const column = index % columns; const start = String(rows[column]), end = `span ${spans[index]}`; if (card.style.gridRowStart !== start) card.style.gridRowStart = start; if (card.style.gridRowEnd !== end) card.style.gridRowEnd = end; rows[column] += spans[index]; }); } function sizeCard(card) { if (card?.isConnected) layoutMasonry(); } // 封面直接引用原图地址,由浏览器 HTTP 缓存承担复用;缓存只记录地址。 function applyCachedCover(card, tid) { if (!postCache || card.coverLoading) return; card.coverLoading = true; const generation = postCache.generation; postCache.getCover(tid).then(cached => { if (!cached || coverDisposed || generation !== postCache.generation || !card.isConnected) return; if (card.coverSignature === JSON.stringify([cached.source, cached.width || 0, cached.height || 0])) return; if (listInteraction || readerCoversList()) queueListCover(card, cached); else setCardCover(card, cached); }).catch(() => {}).finally(() => { card.coverLoading = false; }); } function setCardCover(card, cached) { const cover = card.querySelector('.cover'); if (!cover || card.coverSignature === JSON.stringify([cached.source, cached.width || 0, cached.height || 0])) return; const image = node('img'); image.alt = ''; image.decoding = 'async'; image.loading = 'lazy'; // 已有比例时先占位,避免图片加载后卡片高度变化导致滚动中点击偏移。 if (cached.width > 0 && cached.height > 0) image.style.aspectRatio = `${cached.width} / ${cached.height}`; // 立即入树:lazy 图片脱离文档不会触发加载;比例已占位,加载前后布局不变。 image.className = 'cached-cover-image'; image.onerror = () => { image.onerror = null; image.removeAttribute('src'); image.alt = '封面暂时不可用'; image.classList.add('cover-image-error'); }; // 无占位比例时图片加载完才增高,加载后补算一次跨度。 image.addEventListener('load', () => sizeCard(card), { once: true }); cover.querySelector('.cached-cover-image')?.remove(); cover.prepend(image); cover.classList.add('cached-cover'); card.coverSource = cached.source; card.coverSignature = JSON.stringify([cached.source, cached.width || 0, cached.height || 0]); image.src = cached.source; if (image.naturalWidth > 0 && image.naturalHeight > 0 && !image.style.aspectRatio) image.style.aspectRatio = `${image.naturalWidth} / ${image.naturalHeight}`; // 占位比例已确定高度,立即重算跨度,避免与下方卡片重叠。 sizeCard(card); } const unsubscribeCache = postCache?.subscribe(event => { if (event.type === 'cover') { const card = listCards.get(event.tid)?.card; if (card) applyCachedCover(card, event.tid); } if (event.type === 'clear') { pendingListCovers.clear(); for (const {card} of listCards.values()) { card.querySelector('.cached-cover-image')?.remove(); card.querySelector('.cover')?.classList.remove('cached-cover'); card.coverSource = null; card.coverSignature = null; } } if (event.type === 'favorites') postCache.getFavorites().then(saved => { if (!saved || coverDisposed) return; for (const tid of Object.keys(favorites)) if (!saved[tid]) delete favorites[tid]; Object.assign(favorites,saved); if (!app.classList.contains('reader') && items.length) render(false); }); }); postCache?.getFavorites().then(saved => { if (!saved || coverDisposed) return; for (const [tid,item] of Object.entries(saved)) if (!favoriteChanges.has(tid)) favorites[tid] = item; if (!app.classList.contains('reader') && items.length) render(false); }); window.addEventListener('pagehide', event => { if (event.persisted) return; coverDisposed = true; unsubscribeCache?.(); }); function extract(doc = document, base = pageURL.href) { const result = [], seen = new Set(); // 优先常见 NGA topic 类,同时兼容 table / li 列表;不依赖数字化动态 ID。 for (const a of [...doc.querySelectorAll('a.topic, a[id^="t_tt"], .c2 > a[href*="read.php"]'), ...doc.querySelectorAll('a[href*="read.php"]')]) { if (a.matches('.replydate,.replies,.reply') || a.closest('.c1,.c4,[id^="t_pc"]')) continue; const u = safeURL(a.getAttribute('href'), base); if (!u || u.origin !== location.origin || !/\/read\.php$/.test(u.pathname)) continue; const tid = u.searchParams.get('tid'); if (!tid || !/^\d+$/.test(tid) || seen.has(tid)) continue; if (+u.searchParams.get('page') > 1 && !a.classList.contains('topic')) continue; const title = a.textContent.trim(); if (title.length < 3 || /^[\d\s.>…]+$/.test(title) || /^(?:(?:今天|昨天|前天)\s*\d{1,2}:\d{2}|\d+\s*(?:分钟前|小时前)|\d{2}-\d{2}\s+\d{1,2}:\d{2})$/.test(title)) continue; const row = a.closest('tr, .topicrow, .topic-row, li'); if (!row) continue; seen.add(tid); const users = [...row.querySelectorAll('a[href*="uid="], a.author')]; const replyEl = row.querySelector('.replies, .reply, .c1'); const firstCell = row.querySelector('td'); const numeric = [replyEl?.textContent, firstCell?.textContent].map(x => (x || '').trim()).find(x => /^\d[\d,]*$/.test(x)) || ''; const replies = /^\d[\d,]*$/.test(numeric) ? Number(numeric.replaceAll(',', '')) : null; const cells = [...row.querySelectorAll('td')]; const last = cells.at(-1); const author = users[0]?.textContent.trim() || row.querySelector('.author')?.textContent.trim() || ''; const timeMatch = (last?.textContent || '').match(/(?:\d+\s*分钟前|\d+\s*小时前|(?:今天|昨天|前天)\s*\d{1,2}:\d{2}|\d{2}-\d{2}\s+\d{1,2}:\d{2})/); const pageLinks = [...row.querySelectorAll('a[href*="read.php"]')].map(x => safeURL(x.getAttribute('href'), base)).filter(x => x?.origin === u.origin && x.searchParams.get('tid') === tid); const replyURL = safeURL(row.querySelector('a.replydate')?.getAttribute('href') || '', base); const lastURL = replyURL?.origin === u.origin && replyURL.searchParams.get('tid') === tid ? replyURL : pageLinks.sort((x, y) => Number(y.searchParams.get('page') || 1) - Number(x.searchParams.get('page') || 1))[0]; u.searchParams.delete('page'); u.hash = ''; const authorURL = safeURL(users[0]?.getAttribute('href') || '', base); const uid = authorURL?.searchParams.get('uid') || null; result.push({ tid, title, author, uid, replies, time: timeMatch?.[0] || '', url: u.href, latest: lastURL?.href || '', pinned: /置顶/.test(title) || /(?:^|\s)(?:top|sticky)(?:\s|$)/i.test(row.className) }); } return result; } function scan() { autoContinue(); if (listBootPending) return; if (isForumRoot) { h1Text.textContent = '选择论坛板块'; subtitle.textContent = 'NGA 拥有众多精彩子论坛,请选择您想浏览的板块'; switchBadge.hidden = true; subforumStrip.hidden = true; tabs.hidden = true; grid.hidden = true; pager.hidden = true; if (typeof refreshBar !== 'undefined') refreshBar.hidden = true; boardsManager.renderPortalView(main, empty); toggle(prefs.enabled !== false); return; } const found = extract(); const sig = JSON.stringify([found, nextListURL(document, firstListPage, pageURL.href)]); if (sig === signature) return; signature = sig; if (!found.length && !items.length) { app.hidden = restore.hidden = true; readingSettings.visibility(false); setSurface(false); setViewport(false); return; } if (found.length) updateListPage(found); updateListHeading(); toggle(prefs.enabled !== false); renderPager(); observeListEnd(); } function updateListHeading() { if (isForumRoot) { switchBadge.hidden = true; subforumStrip.hidden = true; return; } const info = boardsManager.detectBoardInfo(document, window, pageURL); h1Text.textContent = info.name; switchBadge.hidden = false; boardsManager.recordRecentBoard({ fid: info.fid, stid: info.stid, name: info.name, url: location.href }); boardsManager.renderSubforumStrip(subforumStrip); } function renderPager() { pager.replaceChildren(); pager.append(listLoad, listStatus, button('原版翻页', 'pill', () => toggle(false))); updateListControls(); } const palette = ['#f5e7df', '#e5ebdf', '#e7e8f3', '#f5e5e8', '#e0edef', '#f2eddf']; const color = tid => palette[Number(tid.slice(-4)) % palette.length]; const count = n => n == null ? '讨论' : n >= 10000 ? `${(n / 10000).toFixed(1)}万回复` : `${n} 回复`; // 列表滚动位置跨整页加载保留。平滑阅读用 iframe 覆盖列表、位置天然不动;但整页 // 跳转 / 刷新 / 新标签打开时列表会重建,而卡片是异步渲染的——浏览器原生滚动恢复 // 触发那一刻页面还很短,会被夹回顶部。于是自己按列表地址存一份,返回或刷新后等 // 卡片把高度撑起来再滚回去,做到"从哪来回哪去"。 const listNavType = (() => { const nav = window.performance?.getEntriesByType?.('navigation')?.[0]; if (nav?.type) return nav.type; const legacy = window.performance?.navigation?.type; return legacy === 2 ? 'back_forward' : legacy === 1 ? 'reload' : 'navigate'; })(); let listScrollRestored = false, listScrollTries = 0, listScrollSaveTimer; const listScrollKey = () => `${KEY}-scroll-${streamKey}#${firstListPage}`; function saveListScroll() { // 阅读态由 navigation 自己记住列表位置;此处只在浏览列表时记录,避免覆盖成阅读页的滚动。 if (!streamKey || document.documentElement.hasAttribute('data-readscape-reader-open')) return; try { sessionStorage.setItem(listScrollKey(), JSON.stringify({ y: Math.round(getReadingScroll()), at: Date.now() })); } catch { /* 存储被禁用时忽略 */ } } function cancelListRestore() { listScrollRestored = true; } for (const type of ['wheel', 'touchstart', 'pointerdown', 'keydown']) app.addEventListener(type, cancelListRestore, { passive: true, capture: true }); function restoreListScroll() { if (listScrollRestored || !streamKey || app.hidden) return; if (listNavType !== 'back_forward' && listNavType !== 'reload') { listScrollRestored = true; return; } let saved; try { saved = JSON.parse(sessionStorage.getItem(listScrollKey()) || 'null'); } catch { saved = null; } if (!saved || !(saved.y > 0) || Date.now() - saved.at > 30 * 60000) { listScrollRestored = true; return; } // 高度不足时先等待,卡片渲染 / 翻页 hydrate 完成后才能滚到目标位置。 const room = usesDocumentScroll() ? document.documentElement.scrollHeight - window.innerHeight : app.scrollHeight - app.clientHeight; if (room < saved.y - 4 && listScrollTries++ < 50) { window.setTimeout(restoreListScroll, 60); return; } listScrollRestored = true; setReadingScroll(saved.y); } window.addEventListener('scroll', () => { if (listScrollSaveTimer) return; listScrollSaveTimer = window.setTimeout(() => { listScrollSaveTimer = null; saveListScroll(); }, 250); }, { passive: true }); window.addEventListener('pagehide', saveListScroll); function render(cacheVisit = true) { if (isForumRoot) return; grid.classList.toggle('single', !!prefs.single); for (const b of tabs.children) b.setAttribute('aria-selected', String(b.dataset.tab === tab)); // 收藏包含曾浏览过的其他页;全部与热议保持已加载页面的原始顺序。 const source = tab === 'saved' ? Object.values(favorites) : items; const visible = source.filter(item => (tab !== 'hot' || item.replies >= 100) && (!query || `${item.title} ${item.author}`.toLowerCase().includes(query))); if (cacheVisit && !listBootPending) postCache?.visit(listFromCache || pendingListPage ? visible.map(({tid}) => ({tid})) : visible); subtitle.textContent = `第 ${firstListPage}${listPages.size > 1 ? `–${listCursor}` : ''} 页 · ${visible.length} 篇${query ? ' · 已加载搜索' : ''}`; empty.textContent = listBootPending ? '正在准备列表…' : tab === 'saved' ? '点击卡片上的 ♡ 收藏帖子。' : '没有符合条件的帖子。'; empty.hidden = visible.length > 0; const retained = new Set(visible.map(item => item.tid)); for (const card of [...grid.children]) if (!retained.has(card.dataset.tid)) { cardObserver?.unobserve(card); card.remove(); } for (const [index, item] of visible.entries()) { const { lastAccess, updatedAt, contentHtml, coverId, ...appearance } = item; const signature = JSON.stringify(appearance); let entry = listCards.get(item.tid); if (entry && entry.signature !== signature) { updateCard(entry.card, item); entry.signature = signature; } if (!entry) { const card = makeCard(item); if (entry?.card.isConnected) { cardObserver?.unobserve(entry.card); entry.card.replaceWith(card); } entry = { card, signature }; listCards.set(item.tid, entry); } const fav = entry.card.querySelector('.save'), saved = !!favorites[item.tid]; fav.textContent = saved ? '♥' : '♡'; fav.setAttribute('aria-pressed', String(saved)); if (grid.children[index] !== entry.card) grid.insertBefore(entry.card, grid.children[index] || null); if (!entry.card.coverSource) applyCachedCover(entry.card, item.tid); } // 保留 DOM 中的原站顺序,按实际高度跨网格行,避免短卡片下方留白。 if (typeof window.ResizeObserver === 'function') { cardObserver ||= new window.ResizeObserver(layoutMasonry); grid.classList.add('masonry'); layoutMasonry(); cardObserver.observe(grid); for (const card of grid.children) cardObserver.observe(card); } updateListControls(); restoreListScroll(); } function makeCard(item) { const card = node('article', 'card'); card.item = item; card.classList.add('card-enter'); card.addEventListener('animationend', () => card.classList.remove('card-enter'), { once: true }); card.dataset.tid = item.tid; card.style.setProperty('--bg', color(item.tid)); const cover = link(item.url, '', 'cover'); const eye = node('div', 'eyebrow', 'NGA · 讨论'); if (item.pinned) eye.append(node('span', 'tag', '置顶')); else if (item.replies >= 100) eye.append(node('span', 'tag', '热议')); cover.append(eye, node('div', 'title', item.title)); const body = node('div', 'body'); const person = node('div', 'person'); const avatar = button(item.author.slice(0, 1) || 'N', 'avatar user-trigger', () => userProfiles.show(item, avatar)); const author = button(item.author || '作者未识别', 'author user-trigger', () => userProfiles.show(item, author)); userProfiles.decorate(avatar, item, true); person.append(avatar, author); const saved = !!favorites[item.tid]; const fav = button(saved ? '♥' : '♡', 'save', () => { if (favorites[item.tid]) delete favorites[item.tid]; else favorites[item.tid] = { ...item }; favoriteChanges.add(item.tid); postCache?.setFavorite(item, !!favorites[item.tid]); fav.classList.remove('save-pop'); void fav.offsetWidth; fav.classList.add('save-pop'); const state = !!favorites[item.tid]; fav.textContent = state ? '♥' : '♡'; fav.setAttribute('aria-pressed', String(state)); if (tab === 'saved') render(); }); fav.setAttribute('aria-label', '收藏帖子'); fav.setAttribute('aria-pressed', String(saved)); person.append(fav); const meta = node('div', 'meta'); meta.append(node('span', '', count(item.replies)), node('span', '', item.time)); if (item.latest && item.latest !== item.url && new URL(item.latest).searchParams.has('page')) meta.append(link(item.latest, '最新回复', 'latest')); body.append(person, meta); card.append(cover, body); if (item.coverId && safeURL(item.coverId)) setCardCover(card, { source: item.coverId, width: item.coverW, height: item.coverH }); return card; } function updateCard(card, incoming) { Object.assign(card.item, incoming); card.querySelector('.title').textContent = incoming.title; card.querySelector('.author').textContent = incoming.author || '作者未识别'; card.querySelector('.avatar').textContent = incoming.author.slice(0, 1) || 'N'; card.querySelector('.cover').href = incoming.url; const eye = card.querySelector('.eyebrow'); eye.replaceChildren(document.createTextNode('NGA · 讨论')); if (incoming.pinned || incoming.replies >= 100) eye.append(node('span', 'tag', incoming.pinned ? '置顶' : '热议')); const meta = card.querySelector('.meta'); meta.replaceChildren(node('span', '', count(incoming.replies)), node('span', '', incoming.time)); if (incoming.latest && incoming.latest !== incoming.url && new URL(incoming.latest).searchParams.has('page')) meta.append(link(incoming.latest, '最新回复', 'latest')); } // Convert NGA's inert response DOM without running its inline scripts. function normalizeNGAContent(source, base) { const root = source.cloneNode(false), stack = [{ el: root }]; const current = () => stack.at(-1).el; const finish = entry => { if (entry.tag === 'img') { let raw = entry.el.textContent.trim(); if (raw.startsWith('./mon_')) raw = `https://img.nga.cn/attachments/${raw.slice(2)}`; const u = safeURL(raw, base); if (u) { const img = node('img'); img.src = u.href; entry.el.replaceWith(img); } } else if (entry.tag === 'url') { const u = safeURL(entry.arg || entry.el.textContent.trim(), base); if (u) entry.el.setAttribute('href', u.href); } }; const text = value => { const tokens = /\[(\/?)([a-z]+)(?:([=:])([^\]]*))?\]/gi; let offset = 0, match; while ((match = tokens.exec(value))) { current().append(document.createTextNode(value.slice(offset, match.index))); offset = tokens.lastIndex; const [literal, closing, name, separator, arg = ''] = match, tag = name.toLowerCase(); const code = stack.findLastIndex(e => e.tag === 'code' || e.tag === 'pre'); if (code > 0 && !(closing && stack[code].tag === tag)) { current().append(document.createTextNode(literal)); continue; } if (closing) { const index = stack.findLastIndex(e => e.tag === tag); if (index > 0) { while (stack.length > index) finish(stack.pop()); } else current().append(document.createTextNode(literal)); continue; } let el; if (tag === 's' && separator === ':') { const parts = arg.split(':'), group = parts.length > 1 ? parts.shift() : '0', smile = parts.join(':'); const file = ngaPageContext(window).ubbcode?.smiles?.[group]?.[smile]; if (typeof file === 'string' && /^[\w.-]+$/.test(file)) { el = node('img', 'smile'); el.src = `https://img4.nga.cn/ngabbs/post/smile/${file}`; el.alt = literal; } else el = node('span', 'emoji', ({goodjob:'👍',哭笑:'😂',咦:'🤔',晕:'😵',赞同:'👍',满足:'😊'})[smile] || '🙂'); current().append(el); continue; } const tags = {b:'b',i:'i',u:'u',s:'s',del:'del',code:'code',pre:'pre',quote:'blockquote',collapse:'details',url:'a',img:'span',uid:'a',pid:'a',size:'span',color:'span',font:'span',align:'div',list:'ul',li:'li'}; if (!tags[tag]) { current().append(document.createTextNode(literal)); continue; } el = node(tags[tag]); if (tag === 'quote') el.className = 'quote'; if (tag === 'collapse') el.append(node('summary', '', arg || '展开内容')); if (tag === 'uid' && /^\d+$/.test(arg)) { el.className = 'userlink'; el.setAttribute('href', new URL(`/nuke.php?func=ucp&uid=${arg}`,base).href); } if (tag === 'pid' && /^\d+,\d+(?:,\d+)?$/.test(arg)) { const [pid,tid,page] = arg.split(','), u = new URL('/read.php',base); u.searchParams.set('tid',tid); u.searchParams.set('topid',pid); if (page) u.searchParams.set('page',page); el.className = 'pid-reference'; el.setAttribute('href',u.href); } current().append(el); stack.push({el,tag,arg}); } current().append(document.createTextNode(value.slice(offset))); }; const walk = child => { if (child.nodeType === 3) { text(child.textContent); return; } if (child.nodeType !== 1 || child.matches('script,style,iframe,object,embed')) return; const el = child.cloneNode(false); current().append(el); if (['BR','IMG','HR'].includes(child.tagName)) return; const entry = {el, tag: ['CODE','PRE'].includes(child.tagName) ? child.tagName.toLowerCase() : undefined}; stack.push(entry); for (const nested of child.childNodes) walk(nested); const index = stack.indexOf(entry); if (index >= 0) while (stack.length > index) finish(stack.pop()); }; for (const child of source.childNodes) walk(child); while (stack.length > 1) finish(stack.pop()); for (const header of root.querySelectorAll('b,strong')) { if (/^Reply to\b/i.test(header.textContent.trim()) && header.querySelector('.pid-reference')) header.classList.add('reply-reference'); } return root; } function startReader() { const tid = pageURL.searchParams.get('tid'); postCache?.visit({ tid, url: pageURL.href }); const pages = new Map(), records = new Map(), cards = new Map(), nextPages = new Map(); let previousCards = new Map(); let initialSignature = '', busy = false, ended = false, scanTimer, cursor; const nativePage = Number(pageURL.searchParams.get('page')) || 1; cursor = nativePage; const canonical = (page = nativePage) => { const u = new URL(pageURL.href); ['rand', 'topid', 'pid'].forEach(k => u.searchParams.delete(k)); u.searchParams.set('page', String(page)); u.hash = ''; return u; }; const style = node('style', '', "\n .reader .bar{max-width:1000px}.reader .brand{font-size:20px}.reader main{max-width:1000px;padding:32px 24px}\n .reader h1{font-size:30px;line-height:1.5;letter-spacing:-.7px;overflow-wrap:anywhere}.reader-head{margin-bottom:22px}.reader-head .sub{margin-top:9px}\n .reader-page{margin-bottom:28px}.page-heading{display:flex;align-items:center;gap:10px;color:#999;font-size:12px;margin:24px 0 14px}.page-heading:after{content:'';height:1px;background:#e8e8ed;flex:1}\n .comment{padding:22px 24px;border:1px solid #ececf0;border-radius:18px;background:white;margin-bottom:12px;scroll-margin-top:105px;overflow:hidden}.comment.op{border-color:#f0e2db;margin:0 0 30px;padding:28px;background:#fffefa}.comment.flash{outline:3px solid #ff7994}\n .comment-head{display:flex;align-items:center;gap:10px;margin-bottom:13px}.reader-avatar{height:34px;width:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:var(--bg);color:#807570;font-size:13px;flex:none}.reader-author{font-size:13px;font-weight:700;overflow-wrap:anywhere}.comment-date{font-size:11px;color:#a0a0aa;margin-top:2px}.floor{font-size:11px;color:#a0a0aa;margin-left:auto;white-space:nowrap}.op-label{background:#ffede5;color:#b16e4e;font-size:10px;padding:2px 6px;border-radius:6px;margin-left:6px}\n .comment-content{font-size:15px;line-height:1.95;overflow-wrap:anywhere;color:#414148}.comment-content p{margin:10px 0}.comment-content>p:first-child{margin-top:0}.reader{--content-link:#356b91;--content-link-visited:#785f91;--content-link-hover:#245477;--content-link-bg:#edf4fa}\n .reader.rt-paper{--content-link:#396787;--content-link-visited:#78618b;--content-link-hover:#274e6a;--content-link-bg:#eaece7}\n .reader.rt-dark{--content-link:#8fc8f0;--content-link-visited:#c3abe0;--content-link-hover:#b7deff;--content-link-bg:#293b4b}\n .comment-content .content-link{color:var(--content-link);text-decoration-line:underline;text-decoration-color:color-mix(in srgb,var(--content-link) 40%,transparent);text-decoration-thickness:1px;text-underline-offset:.2em;text-decoration-skip-ink:auto;border-radius:3px;overflow-wrap:anywhere;transition:color .15s,background-color .15s}\n .comment-content .content-link:visited{color:var(--content-link-visited)}\n .comment-content .content-link:hover{color:var(--content-link-hover);background:var(--content-link-bg);text-decoration-color:currentColor}\n .comment-content .content-link:focus-visible{outline:2px solid var(--content-link);outline-offset:3px;background:var(--content-link-bg)}\n .comment-content .external-link:after{content:' ↗';display:inline-block;font-size:.8em;text-decoration:none;white-space:nowrap}\n .comment-content img{max-width:100%;height:auto;border-radius:10px;display:block;margin:12px auto}.comment-content img.emoji{display:inline-block;width:auto;height:1.65em;max-width:3em;object-fit:contain;vertical-align:-.4em;margin:0 .2em;border-radius:0}\n .reader.rt-dark .comment-content img.emoji{background:#f7f7f9;border-radius:4px}\n .comment-content pre{overflow:auto;padding:12px;background:#f5f5f7;border-radius:10px;font-size:12px;white-space:pre}.comment-content code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.9em}.comment-content table{display:block;overflow:auto;border-collapse:collapse;max-width:100%}.comment-content td,.comment-content th{border:1px solid #e7e7ec;padding:5px 10px}.comment-content hr{border:0;border-top:1px solid #eee}\n .quoted{background:#f7f7f9;border-radius:10px;margin:10px 0 16px;border-left:3px solid #e6d0d7;padding:9px 13px;color:#85858f;font-size:12px}.quoted>summary{cursor:pointer;line-height:1.7;overflow-wrap:anywhere}.quoted-body{padding-top:10px;color:#777;line-height:1.8}.quoted .context{display:inline-block;margin-top:9px;font-size:11px;color:#b77886}.quoted .quoted{background:#eeeef2}\n .comment-actions{display:flex;gap:15px;align-items:center;flex-wrap:wrap;margin-top:14px;font-size:11px;color:#9a9aa5}.comment-actions button{padding:4px 0;color:#aa7582}.thread{border-left:2px solid #ede5e8;margin:0 0 18px 25px;padding-left:15px}.thread>summary{font-size:12px;color:#a67986;padding:7px 0 14px;cursor:pointer}.thread .comment{padding:18px 20px}\n .reader-bottom{text-align:center;margin:26px 0}.load-next{background:#ff2442;color:white;padding:12px 28px;border-radius:24px;font-size:13px}.reader-status{margin:13px auto;color:#999;font-size:12px;max-width:600px}.reader-status a{color:#ae7482;text-decoration:underline}.reader-footer{font-size:11px;color:#aaa;margin:14px 0}.reply-rel{font-size:11px;color:#b38390;margin-bottom:10px}\n\n @media(max-width:600px){.reader main{padding:22px 12px}.reader h1{font-size:23px}.reader .bar{gap:10px;flex-wrap:nowrap}.reader .brand{font-size:17px}.reader .bar>.pill{font-size:11px;padding:8px 10px}.reader .top{padding:calc(10px + env(safe-area-inset-top)) 12px 10px}.reader .comment{padding:17px 16px;border-radius:14px}.reader .comment.op{padding:20px 17px}.comment-content{font-size:15px;line-height:1.85}.reader-avatar{width:29px;height:29px}.thread{margin-left:12px;padding-left:9px}.thread .comment{padding:15px 13px}.comment{scroll-margin-top:85px}}\n .action-date{display:none}.reader-identity{min-width:0;flex:1}.attachment{display:block}\n @media(max-width:600px){\n .reader{background:white;padding-bottom:calc(30px + env(safe-area-inset-bottom))}.reader .top{border-bottom:1px solid #f3f3f5;padding:calc(8px + env(safe-area-inset-top)) 16px 8px}.reader .bar{gap:8px;min-width:0}.reader .brand{background:none;color:#555;font-size:15px;letter-spacing:0;padding:0;flex:1;overflow:hidden}.reader .bar>.pill{display:block;margin:0;padding:10px 8px;min-height:42px;background:none;color:#999;border-radius:0;font-size:11px}.reader main{padding:22px 18px 28px}.reader h1{font-size:21px;font-weight:700;line-height:1.6;letter-spacing:0}.reader-head{margin-bottom:20px}.reader-head .sub{font-size:11px;margin-top:8px}\n .page-heading{margin:22px 0 5px;font-size:11px;color:#b2b2ba}.page-heading:after{background:#f3f3f5}\n .reader .comment{position:relative;border:0;border-radius:0;background:transparent;padding:20px 0 22px 44px;margin:0;overflow:visible}.comment-head{gap:8px;align-items:flex-start;margin-bottom:6px}.reader .reader-avatar{position:absolute;left:0;top:19px;width:32px;height:32px;font-size:12px}.reader-author{font-size:13px;font-weight:500;color:#8a8a93;line-height:1.65}.floor{font-size:10px;color:#bbb;padding-top:3px}.comment-head .comment-date{display:none}.comment-content{font-size:15px;line-height:1.8;color:#333;letter-spacing:.1px}.op-label{background:#f5f5f7;color:#999;font-size:10px;padding:2px 5px;font-weight:400}\n .reader .comment.op{padding:8px 0 23px;margin-bottom:10px;border-bottom:1px solid #eee}.comment.op .comment-head{padding-left:44px;margin-bottom:16px;min-height:36px;align-items:center}.reader .comment.op .reader-avatar{top:8px;width:34px;height:34px}.comment.op .reader-author{color:#555;font-size:14px}.comment.op .comment-content{font-size:16px;line-height:1.85}.comment.op .comment-actions{margin-top:13px}.comment-content .attachment{display:block}.comment-content .attachment img{width:auto;max-width:100%;max-height:300px;object-fit:contain;margin:12px 0;border-radius:8px}.comment.op .attachment img{max-height:none}\n .comment-actions{gap:12px;margin-top:9px;font-size:11px;color:#aaa;line-height:1.6}.comment-actions .action-date{display:block;flex-basis:100%;font-size:11px;color:#b0b0b8}.comment-actions button{color:#999;min-height:30px;padding:3px 0}.comment-actions a{color:#aaa}.quoted{border:0;background:#f7f7f9;padding:9px 11px;border-radius:7px;margin:9px 0 12px;font-size:11px;color:#92929c}.quoted>summary{line-height:1.75}.quoted-body{font-size:12px;line-height:1.85}.quoted .context{min-height:32px;padding:4px 0;color:#8e7180}.reply-rel{font-size:11px;color:#aaa;line-height:1.7;margin:0 0 7px}\n .thread{border:0;margin:0 0 9px 44px;padding:0}.thread>summary{list-style:none;color:#58728e;font-size:12px;font-weight:500;padding:0 0 16px;min-height:40px}.thread>summary::-webkit-details-marker{display:none}.thread>summary:before{content:'—';color:#c9d1db;margin-right:9px}.thread[open]>summary:after{content:' · 收起';font-size:11px;font-weight:400;color:#9aa8b8}.reader .thread .comment{padding:13px 0 17px 34px;margin:0}.reader .thread .reader-avatar{width:25px;height:25px;top:14px;font-size:10px}.thread .reader-author{font-size:12px}.thread .comment-content{font-size:14px}.thread .quoted{font-size:10px}.reader-bottom{margin:26px 0 12px}.load-next{background:#f7f7f9;color:#7d6b76;border-radius:20px;font-size:12px;padding:12px 25px;min-height:42px}.reader-footer{display:none}\n .reader-status{font-size:11px;line-height:1.8}\n }\n @media(max-width:360px){.reader main{padding-left:14px;padding-right:14px}.reader .comment{padding-left:39px}.thread{margin-left:39px}.reader .thread .comment{padding-left:30px}}\n /* 图片卡片:先保留画布,再淡入原图;失败时仍可重试或打开原图。 */\n .reader-image{display:block;position:relative;width:100%;max-width:720px;margin:16px auto;overflow:hidden;border:1px solid #ececf0;border-radius:12px;background:#f5f5f7;box-shadow:0 2px 8px #22222206}\n .reader-image .attachment{position:relative;display:block;aspect-ratio:var(--image-ratio,4 / 3);overflow:hidden;cursor:zoom-in;color:#999;text-decoration:none}\n .reader-image .attachment img{display:block;width:100%;height:100%;max-height:none;object-fit:contain;margin:0;border-radius:0;opacity:0;transition:opacity .25s ease}\n .reader-image.is-loaded .attachment img{opacity:1}.image-state{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:12px;color:#93939d;pointer-events:none}\n .is-loading .attachment:before{content:'';position:absolute;inset:0;background:linear-gradient(100deg,transparent 25%,#fff9 50%,transparent 75%);background-size:200% 100%;animation:image-shimmer 1.8s ease-in-out infinite}.is-loaded .image-state{display:none}\n .image-retry{position:absolute;left:50%;top:calc(50% + 22px);transform:translateX(-50%);padding:8px 15px;border:1px solid #dedee4;border-radius:18px;background:white;font-size:12px;color:#666;min-height:36px}\n @keyframes image-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}\n .reader .comment.op{padding:0;background:#fff;border:1px solid #eee;border-radius:20px;overflow:hidden;margin-bottom:26px}\n .comment.op>.comment-head{padding:22px 24px;margin:0}.note-copy{padding:0 24px 12px;min-width:0}.reader .note-title{font-size:23px;line-height:1.5;letter-spacing:0;margin:0 0 14px}.comment.op>.comment-actions{margin:0;padding:0 24px 22px}\n .note-gallery{position:relative;min-width:0;background:#f5f5f7}.gallery-track{display:flex;overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x mandatory;scrollbar-width:none;aspect-ratio:4 / 5;max-height:680px}.gallery-track::-webkit-scrollbar{display:none}.gallery-slide{flex:0 0 100%;min-width:0;scroll-snap-align:start;scroll-snap-stop:always;display:flex}\n .gallery-slide .reader-image{width:100%;max-width:none;height:100%;margin:0;border:0;border-radius:0;box-shadow:none}.gallery-slide .attachment{width:100%;height:100%;aspect-ratio:auto}.gallery-count{position:absolute;right:16px;top:16px;padding:4px 10px;border-radius:16px;background:#0006;color:white;font-size:12px;font-variant-numeric:tabular-nums;pointer-events:none}\n .gallery-prev,.gallery-next{position:absolute;top:50%;transform:translateY(-50%);background:#ffffffdf;border-radius:50%;width:36px;height:36px;font-size:26px;line-height:1;box-shadow:0 2px 12px #0001}.gallery-prev{left:12px}.gallery-next{right:12px}.gallery-prev:disabled,.gallery-next:disabled{visibility:hidden}\n .gallery-dots{position:absolute;bottom:14px;left:50%;transform:translateX(-50%);display:flex;max-width:80%;overflow:auto;gap:2px;padding:2px 6px;border-radius:20px;background:#ffffffbf}.gallery-dot{flex:none;width:20px;height:20px;padding:6px}.gallery-dot:after{content:'';display:block;width:6px;height:6px;border-radius:50%;background:#c9c9cf}.gallery-dot[aria-pressed=true]:after{background:#ff2442}\n .image-viewer{position:fixed;inset:0;width:100vw;height:100dvh;max-width:none;max-height:none;margin:0;border:0;padding:0;background:#171719;color:#fff;z-index:2147483002;overflow:hidden;color-scheme:dark}.image-viewer:not([open]){display:none}.image-viewer::backdrop{background:#000b}\n .viewer-bar{position:absolute;z-index:2;top:0;left:0;right:0;display:flex;align-items:center;gap:20px;padding:calc(12px + env(safe-area-inset-top)) 20px 12px;background:#171719dd;font-size:13px}.viewer-count{flex:1}.viewer-close,.viewer-original{padding:10px;min-height:44px;color:#fff}.viewer-stage{display:flex;align-items:center;justify-content:center;height:100%;padding:80px 64px 40px}.viewer-stage .reader-image{max-width:100%;width:auto;max-height:100%;margin:0;border:0;background:transparent;box-shadow:none}.viewer-stage .attachment{max-height:calc(100dvh - 120px);cursor:zoom-out}.viewer-stage .attachment img{width:auto;height:auto;max-width:100%;max-height:calc(100dvh - 120px);object-fit:contain}.viewer-stage .is-loading,.viewer-stage .is-error{width:min(80vw,800px)}\n .viewer-prev,.viewer-next{position:absolute;z-index:2;top:50%;width:44px;height:44px;border-radius:50%;background:#ffffff1a;color:#fff;font-size:30px}.viewer-prev{left:12px}.viewer-next{right:12px}\n @media(min-width:801px){.comment.op.has-gallery{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);grid-template-rows:auto 1fr auto}.has-gallery>.note-gallery{grid-column:1;grid-row:1 / 4;align-self:start}.has-gallery>.comment-head{grid-column:2;grid-row:1}.has-gallery>.note-copy{grid-column:2;grid-row:2}.has-gallery>.comment-actions{grid-column:2;grid-row:3}.has-gallery .gallery-track{max-height:none}}\n @media(max-width:800px){.note-gallery{margin-bottom:22px}.reader .comment.op{border-radius:16px}.gallery-track{max-height:75dvh}}\n @media(max-width:600px){\n .reader main{padding-top:12px}.reader .comment.op{margin:0 -18px 20px;padding:0 0 20px;border:0;border-bottom:1px solid #eee;border-radius:0;background:white}.comment.op>.comment-head{padding:10px 18px 14px 62px;min-height:58px;margin:0}.reader .comment.op .reader-avatar{left:18px;top:11px;width:34px;height:34px}.note-copy{padding:0 18px 8px}.reader .note-title{font-size:20px;line-height:1.55;margin-bottom:12px}.comment.op>.comment-actions{padding:0 18px;margin:0}.note-gallery{margin-bottom:22px}.gallery-track{max-height:70dvh}.gallery-prev,.gallery-next{width:32px;height:32px}.reader .reader-image .attachment img{width:100%;height:100%;max-height:none;margin:0;border-radius:0}.reader-image{margin:12px 0;border-radius:10px}.viewer-stage{padding:80px 12px 50px}.viewer-bar{gap:8px;padding-left:12px;padding-right:12px}.viewer-original{font-size:12px}.viewer-prev,.viewer-next{top:auto;bottom:calc(12px + env(safe-area-inset-bottom));width:40px;height:40px}\n }\n @media(max-width:360px){.reader .comment.op{margin-left:-14px;margin-right:-14px}}\n @media(prefers-reduced-motion:reduce){.is-loading .attachment:before{animation:none}.reader-image .attachment img{transition:none}}\n .comment:not(.op) .reader-image .attachment{max-height:360px}.comment-content p:empty{display:none}\n @media(max-width:600px){.comment:not(.op) .reader-image .attachment{max-height:300px}}\n .reader.rt-dark .comment.op{background:#202329;border-color:#34373e}.reader.rt-dark .note-gallery,.reader.rt-dark .reader-image{background:#2c3038;border-color:#34373e}.reader.rt-dark .comment.op .reader-author{color:#ddd}.reader.rt-paper .comment.op{background:#faf7ef;border-color:#eee2cf}.reader.rt-paper .note-gallery,.reader.rt-paper .reader-image{background:#f0ece4}.reader .note-title{font-size:calc(23px * var(--rt-scale,1))}\n @media(max-width:600px){.reader .note-title{font-size:calc(20px * var(--rt-scale,1))}}\n .reader-end{display:flex;align-items:center;justify-content:center;gap:14px;max-width:280px;margin:24px auto;color:#96939c;font-size:13px}.reader-end:before,.reader-end:after{content:'';height:1px;flex:1;background:currentColor;opacity:.2}\n @media(max-width:600px){.reader .comment-actions button,.reader .comment-actions a,.reader .load-next{min-height:48px}.reader .comment-actions{gap:4px 8px}.reader .comment-actions button,.reader .comment-actions a{display:inline-flex;align-items:center;padding:8px 10px;border-radius:24px}.reader .comment-actions button:active,.reader .comment-actions a:active:active{background:#e8e1e8}.reader .load-next{min-width:160px;border-radius:24px;background:#f5e4ea;color:#8c3650;font-weight:600}}\n\n /* 短回复随内容收缩,原站入口集中在右上角楼层号。 */\n .comment-actions{margin-top:6px}\n .reader .floor{display:inline-flex;align-items:center;justify-content:flex-end;min-width:36px;min-height:32px;padding:0 2px}\n @media(max-width:600px){\n .reader .comment:not(.op){padding-top:12px;padding-bottom:12px}\n .reader .comment:not(.op) .reader-avatar{top:12px}\n .reader .comment-head{margin-bottom:3px;align-items:center;min-height:28px}\n .reader .comment-actions{margin-top:4px}\n .reader .comment-content{line-height:1.7}\n .reader .comment-content p{margin:6px 0}\n .reader .comment-content>p:first-child{margin-top:0}\n .reader .comment-content>p:last-child{margin-bottom:0}\n }\n"); shadow.append(style); app.classList.add('reader'); const rtop = node('header', 'top'), rbar = node('div', 'bar'); const rrestore = button('优雅阅读', 'restore', () => setMode(true)); rrestore.hidden = true; const rmain = node('main'), rhead = node('div', 'reader-head'); const rtitle = node('h1', '', document.title.replace(/\s*NGA玩家社区.*$/, '')); const rsub = node('div', 'sub', '按原站分页阅读 · 引用默认折叠'); rhead.append(rtitle, rsub); rbar.append(node('div', 'brand', 'NGA · 阅读'), node('div', 'spacer'), accountStatus); const readerMenu = mobileOptions.cloneNode(true); readerMenu.setAttribute('aria-label', '阅读设置与账号'); readerMenu.removeAttribute('aria-expanded'); readerMenu.onclick = () => readingSettings.open(readerMenu); rbar.append(readerMenu); rtop.append(rbar); const stream = node('div', 'reader-stream'); const bottom = node('div', 'reader-bottom'), status = node('div', 'reader-status'); status.setAttribute('role', 'status'); const endMarker = node('div', 'reader-end', '已经到底了'); const load = button('加载下一页', 'load-next', async () => { if (busy || ended || load.hidden) return; while (pages.has(cursor + 1)) cursor++; const next = cursor + 1; await loadPage(next, true); if (pages.has(next)) cursor = next; renderReader(); }); const footer = node('div', 'reader-footer'); bottom.append(load, endMarker, status, footer); rmain.append(rhead, stream, bottom); app.replaceChildren(rtop, rmain); const [replyEntry, dockCount] = readingSettings.setActions([ { label: '回复帖子', className: 'reply-entry', href: () => postURL(document, location.href).href, target: '_blank' }, { label: '评论', className: 'dock-count', action: () => stream.querySelector('.comment:not(.op)')?.scrollIntoView({ block: 'start', behavior: 'smooth' }) }, { label: '回到顶部', className: 'dock-top', action: () => scrollReadingTo({ top: 0, behavior: 'smooth' }) } ]); accountChanged = info => { replyEntry.hidden = !info; footer.textContent = info ? '每次只加载一页 · 回复与引用在新标签页打开原版发帖页' : '每次只加载一页'; for (const card of cards.values()) syncCommentActions(card, card.readerPost, !!info); }; accountChanged(currentAccount()); restore.remove(); shadow.append(rrestore); modeToggle = setMode; settingsRefresh = renderReader; function setMode(enabled) { const readingPosition = getReadingScroll(); if (!enabled) closeViewer(); prefs.enabled = enabled; savePrefs(); app.hidden = !enabled || !pages.size; rrestore.hidden = enabled || !pages.size; setViewport(!app.hidden); setSurface(!app.hidden, readingPosition); readingSettings?.visibility(!app.hidden); if (!app.hidden) navigation.finish(); } function postURL(doc, base, post, action = 'reply') { const links = [...doc.querySelectorAll('a[href]')].filter(a => !a.closest('.postcontent,[id^=postcontent],.quote,blockquote')); const candidates = links.map(a => safeURL(a.getAttribute('href'), base)).filter(u => u?.origin === pageURL.origin && u.pathname === '/post.php' && u.searchParams.get('tid') === tid && ['reply','quote'].includes(u.searchParams.get('action'))); let url = post ? candidates.find(u => u.searchParams.get('pid') === post.pid && u.searchParams.get('action') === action) || candidates.find(u => u.searchParams.get('pid') === post.pid) : candidates.find(u => !u.searchParams.has('pid') && u.searchParams.get('action') === 'reply'); if (!url && post) { // NGA may create its toolbar lazily. Reuse its button URL template too, // without invoking the touch handler that opens an inline editor. const page = ngaPageContext(window), template = page.commonui?.postBtn?.d?.[action === 'quote' ? 7 : 8]?.u; const nativeArg = doc === document ? page.commonui?.postArg?.data?.[post.index] : null; const fields = {fid:nativeArg?.fid ?? page.__CURRENT_FID,tid,pid:post.pid,i:post.index}; if (typeof template === 'string' && !template.match(/\{([^}]+)\}/g)?.some(key => fields[key.slice(1,-1)] == null)) { const candidate = safeURL(template.replace(/\{([^}]+)\}/g, (_, key) => encodeURIComponent(fields[key])), base); if (candidate?.origin === pageURL.origin && candidate.pathname === '/post.php' && candidate.searchParams.get('tid') === tid) url = candidate; } } url = url ? new URL(url) : new URL('/post.php', base); if (url.searchParams.get('action') !== action) url.searchParams.set('action', action); if (url.searchParams.get('tid') !== tid) url.searchParams.set('tid', tid); if (!url.searchParams.has('_newui')) url.searchParams.set('_newui', ''); if (!url.searchParams.has('fid')) { const nativeReply = [...doc.querySelectorAll('a[href]')].map(a => safeURL(a.getAttribute('href'),base)).find(u => u?.origin === pageURL.origin && u.pathname === '/post.php' && u.searchParams.get('tid') === tid && u.searchParams.has('fid')); const literal = [...doc.scripts].map(s=>s.textContent).join('\n').match(/\b__CURRENT_FID\s*=\s*(-?\d+)/)?.[1]; const fid = nativeReply?.searchParams.get('fid') || literal || (doc === document ? ngaPageContext(window).__CURRENT_FID : postURL(document, location.href).searchParams.get('fid')); if (fid != null) url.searchParams.set('fid', String(fid)); } if (post) { if (post.pid != null && url.searchParams.get('pid') !== post.pid) url.searchParams.set('pid',post.pid); if (!url.searchParams.has('article')) url.searchParams.set('article',post.index); } return url; } function postLink(url, label, cls = '') { const a = link(url, label, cls); a.dataset.readscapeNative = 'true'; a.target = '_blank'; a.rel = 'noopener noreferrer'; return a; } function parsePosts(doc, base) { const output = [], localSeen = new Set(), userData = readNGAUserData(doc, window); for (const rawContent of doc.querySelectorAll('.postcontent, [id^="postcontent"]')) { const content = normalizeNGAContent(rawContent, base); if (!/^postcontent\d+$/.test(content.id) && !content.classList.contains('postcontent')) continue; const index = content.id.match(/(\d+)$/)?.[1]; const row = rawContent.closest('tr') || doc.getElementById(`post1strow${index}`); const container = rawContent.closest('[id^="postcontainer"]') || row; if (!row || !container) continue; const floorLink = [...row.querySelectorAll('a')].find(a => /^#\d+$/.test(a.textContent.trim())); const floorAnchor = container.querySelector('a[name^=l]')?.getAttribute('name')?.match(/^l(\d+)$/); const floor = floorLink ? Number(floorLink.textContent.trim().slice(1)) : floorAnchor ? Number(floorAnchor[1]) : null; const anchor = container.querySelector('a[id^="pid"][id$="Anchor"]'); const pid = anchor?.id.match(/^pid(\d+)Anchor$/)?.[1] || null; const key = pid != null ? `pid:${pid}` : floor != null ? `floor:${floor}` : `index:${index}:${base}`; if (localSeen.has(key)) continue; localSeen.add(key); const authorLinks = [...row.querySelectorAll(`[id=postauthor${index}], .author`)]; const author = authorLinks.find(a => a.textContent.trim()) || authorLinks[0]; const authorURL = author && safeURL(author.getAttribute('href'), base); const uid = authorURL?.searchParams.get('uid') || author?.getAttribute('data-uid') || author?.getAttribute('data-user-id') || row.querySelector('[name=uid]')?.textContent.trim() || (doc === document ? ngaPageContext(window).commonui?.postArg?.data?.[index]?.pAid : null) || author?.getAttribute('onclick')?.match(/userClick\([^,]+,\s*['"](\d+)['"]/)?.[1] || null; const userID = uid == null ? null : String(uid); const profile = userID && userProfiles.normalize(userData.users[userID] || {uid:userID,username:author?.textContent.trim() || `UID ${uid}`}, userData.groups); const nativeAvatar = row.querySelector(`[id=posteravatar${index}], img.avatar`); if (profile && !profile.avatar) profile.avatar = userProfiles.avatarURL(nativeAvatar?.getAttribute('src')); const date = doc.getElementById(`postdate${index}`) || row.querySelector('.postdatec'); const original = new URL(base); original.hash = pid ? `pid${pid}Anchor` : `l${floor ?? index}`; const quotes = [...content.querySelectorAll('.quote, blockquote, .reply-reference')].filter(q => !q.parentElement?.closest('.quote, blockquote, .reply-reference')); const refs = quotes.map(q => { const a = [...q.querySelectorAll('a[href]')].find(a => { const u = safeURL(a.getAttribute('href'), base); return u?.origin === pageURL.origin && u.searchParams.get('tid') === tid && u.searchParams.has('topid'); }); const u = a && safeURL(a.getAttribute('href'), base); return u ? { pid: u.searchParams.get('topid'), url: u.href, author: q.querySelector('.userlink')?.textContent.trim() || '' } : null; }).filter(Boolean); output.push({ key, pid, floor, replyURL: postURL(doc, base, {row,pid,index}).href, quoteURL: postURL(doc, base, {row,pid,index}, 'quote').href, author: profile?.username || author?.textContent.trim() || '匿名用户', uid:userID, profile, time: date?.textContent.trim() || '', content, refs, original: original.href, base, native: doc === document ? container : null }); } return output; } function detectNextPage(doc, page, base) { const metadata = [...doc.scripts].map(s => s.textContent).join('\n').match(/var\s+__PAGE\s*=\s*\{0:[^,]+,1:(\d+),2:(\d+),3:(\d+)/); if (metadata && Number(metadata[3]) > 0) return page < Math.ceil((Number(metadata[1]) + 1) / Number(metadata[3])); const pagers = [...doc.querySelectorAll('#pagebtop, #pagebbtm')]; // 没有分页信息的响应仍可手动加载;空分页栏是 NGA 单页帖的结构。 if (!pagers.length) return null; return pagers.some(pager => [...pager.querySelectorAll('a[href]')].some(a => { const u = safeURL(a.getAttribute('href'), base); return u?.origin === pageURL.origin && u.pathname === pageURL.pathname && u.searchParams.get('tid') === tid && (Number(u.searchParams.get('page')) > page || u.searchParams.get('page') === 'e'); })); } function updateNative() { autoContinue(); if (typeof isDeletedDoc === 'function' && isDeletedDoc(document)) { navigation.finish(); return; } const posts = parsePosts(document, location.href); if (!posts.length) return; const sig = posts.map(p => `${p.key}|${p.author}|${JSON.stringify(p.profile)}|${p.replyURL}|${p.quoteURL}|${p.time}|${p.content.outerHTML}`).join('\n') + [...document.querySelectorAll('#pagebtop, #pagebbtm')].map(el => el.outerHTML).join(''); if (sig === initialSignature) return; initialSignature = sig; const detectedPage = [...document.querySelectorAll('#pagebtop .invert, #pagebbtm .invert')].map(x => Number(x.textContent.replace(/\p{M}/gu, '').trim())).find(x => x > 0); const p = detectedPage || (pageURL.searchParams.get('page') === 'e' && posts[0].floor != null ? Math.floor(posts[0].floor / 20) + 1 : nativePage); const firstRender = !pages.size; if (firstRender) cursor = p; nextPages.set(p, detectNextPage(document, p, location.href)); pages.set(p, posts); rebuildRecords(); rtitle.textContent = document.querySelector('#postsubject0')?.textContent.trim() || document.title.replace(/\s*NGA玩家社区.*$/, ''); cachePage(p,posts,nextPages.get(p)); if (!rbar.querySelector('a')) { const board = [...document.querySelectorAll('#m_nav a[href*="thread.php"]')].at(-1); const u = board && safeURL(board.getAttribute('href')); if (u) rbar.insertBefore(link(u.href, '返回板块', 'pill'), rbar.lastChild); } renderReader(); setMode(!!prefs.enabled); const targetPid = pageURL.hash.match(/pid(\d+)/)?.[1]; if (firstRender && targetPid) setTimeout(() => focusRecord(`pid:${targetPid}`), 50); } function rebuildRecords() { records.clear(); for (const list of pages.values()) for (const p of list) records.set(p.key, p); } function cachePage(number, posts, next) { if (!postCache) return; const generation = postCache.generation; const replies = posts.map(p => { const {content,native,...record} = p; return {...record,html:content.outerHTML}; }); postCache.saveReplyPage(tid,number,replies,{next,title:rtitle.textContent,url:canonical(number).href}).then(async stored => { if (!stored || generation !== postCache.generation || !await postCache.getReplyPage(tid,number)) return; }); } function restorePage(cached) { if (!cached || cached.tid !== tid || !Array.isArray(cached.replies)) return []; return cached.replies.map(p => { const base = safeURL(p.base); if (!base || base.origin !== pageURL.origin || base.searchParams.get('tid') !== tid || typeof p.html !== 'string' || typeof p.author !== 'string' || typeof p.key !== 'string') return null; if (!['replyURL','quoteURL','original'].every(key => { const u=safeURL(p[key]); return u?.origin===pageURL.origin && u.searchParams.get('tid')===tid && ['/post.php','/read.php'].includes(u.pathname); })) return null; const content = new DOMParser().parseFromString(p.html,'text/html').body.firstElementChild; if (!content || !content.matches('.postcontent,[id^=postcontent]')) return null; const refs=(Array.isArray(p.refs)?p.refs:[]).filter(ref=>{const u=safeURL(ref.url);return u?.origin===pageURL.origin&&u.searchParams.get('tid')===tid;}); return {...p,refs,content,native:null}; }).filter(Boolean); } // 图片保留原图入口;加载、失败与查看器都在 Shadow DOM 内处理。 let viewerReturnFocus, viewerItems = [], viewerIndex = 0; const viewer = node('dialog', 'image-viewer'); viewer.setAttribute('aria-label', '图片查看器'); const viewerBar = node('div', 'viewer-bar'), viewerCount = node('span', 'viewer-count'); const viewerOriginal = link('', '打开原图 ↗', 'viewer-original'); viewerOriginal.target = '_blank'; viewerOriginal.rel = 'noopener noreferrer'; const viewerClose = button('关闭 ×', 'viewer-close', closeViewer); viewerBar.append(viewerCount, viewerOriginal, viewerClose); const viewerStage = node('div', 'viewer-stage'); const viewerPrev = button('‹', 'viewer-prev', () => showViewerImage(viewerIndex - 1)); const viewerNext = button('›', 'viewer-next', () => showViewerImage(viewerIndex + 1)); viewerPrev.setAttribute('aria-label', '上一张图片'); viewerNext.setAttribute('aria-label', '下一张图片'); viewer.append(viewerBar, viewerPrev, viewerStage, viewerNext); shadow.append(viewer); viewer.addEventListener('click', event => { if (event.target === viewer || event.target === viewerStage) closeViewer(); }); viewer.addEventListener('close', () => viewerReturnFocus?.isConnected && viewerReturnFocus.focus({ preventScroll: true })); viewer.addEventListener('keydown', event => { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); showViewerImage(viewerIndex + (event.key === 'ArrowLeft' ? -1 : 1)); } else if (event.key === 'Escape') { event.preventDefault(); closeViewer(); } }); function closeViewer() { if (!viewer.open) return; if (typeof viewer.close === 'function') viewer.close(); else viewer.removeAttribute('open'); viewerStage.replaceChildren(); if (viewerReturnFocus?.isConnected) viewerReturnFocus.focus({ preventScroll: true }); } function showViewerImage(index) { viewerIndex = Math.max(0, Math.min(index, viewerItems.length - 1)); const item = viewerItems[viewerIndex]; if (!item) return; viewerStage.replaceChildren(makeImage(item.href, item.querySelector('img')?.alt || '', null, false)); viewerCount.textContent = `${viewerIndex + 1} / ${viewerItems.length}`; viewerOriginal.href = item.href; viewerPrev.disabled = viewerIndex === 0; viewerNext.disabled = viewerIndex === viewerItems.length - 1; } function openViewer(attachment) { viewerReturnFocus = attachment; viewerItems = [...(attachment.closest('.note-gallery') || attachment.closest('.comment') || app).querySelectorAll('.attachment')]; showViewerImage(viewerItems.indexOf(attachment)); if (typeof viewer.showModal === 'function') viewer.showModal(); else viewer.setAttribute('open', ''); viewerClose.focus(); } function makeImage(url, alt, source, interactive = true) { const frame = node('span', 'reader-image is-loading'), attachment = link(url, '', 'attachment'); attachment.setAttribute('aria-label', alt || '查看完整图片'); const img = node('img'); img.alt = alt; img.decoding = 'async'; img.loading = interactive ? 'lazy' : 'eager'; img.referrerPolicy = 'same-origin'; const width = Number(source?.getAttribute('width')) || Number(source?.getAttribute('data-nw')) || source?.naturalWidth; const height = Number(source?.getAttribute('height')) || Number(source?.getAttribute('data-nh')) || source?.naturalHeight; if (width > 0 && height > 0) { img.width = width; img.height = height; frame.style.setProperty('--image-ratio', `${width} / ${height}`); frame.dataset.ratio = `${width} / ${height}`; } const state = node('span', 'image-state', '图片加载中…'); state.setAttribute('role', 'status'); const retry = button('重新加载', 'image-retry', () => { frame.className = 'reader-image is-loading'; state.textContent = '图片加载中…'; retry.hidden = true; img.src = url; }); retry.hidden = true; const loaded = () => { if (!img.naturalWidth) return; frame.className = 'reader-image is-loaded'; state.textContent = ''; retry.hidden = true; frame.style.setProperty('--image-ratio', `${img.naturalWidth} / ${img.naturalHeight}`); frame.dataset.ratio = `${img.naturalWidth} / ${img.naturalHeight}`; }; img.addEventListener('load', loaded); img.addEventListener('error', () => { frame.className = 'reader-image is-error'; state.textContent = '图片加载失败'; retry.hidden = false; }); attachment.append(img, state); frame.append(attachment, retry); attachment.addEventListener('click', event => { if (interactive && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey && event.button === 0) { event.preventDefault(); openViewer(attachment); } else if (!interactive && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey) { event.preventDefault(); closeViewer(); } }); img.src = url; if (img.complete && img.naturalWidth) loaded(); return frame; } function makeGallery(images) { const gallery = node('section', 'note-gallery'), track = node('div', 'gallery-track'); gallery.setAttribute('aria-label', '首楼图集'); track.tabIndex = 0; track.setAttribute('aria-label', '左右滑动浏览图片'); const count = node('span', 'gallery-count', `1 / ${images.length}`), dots = node('div', 'gallery-dots'); let current = 0; const select = index => { current = Math.max(0, Math.min(index, images.length - 1)); track.scrollTo({ left: current * track.clientWidth, behavior: 'smooth' }); }; const prev = button('‹', 'gallery-prev', () => select(current - 1)); const next = button('›', 'gallery-next', () => select(current + 1)); prev.setAttribute('aria-label', '上一张'); next.setAttribute('aria-label', '下一张'); const update = () => { if (track.clientWidth) current = Math.round(track.scrollLeft / track.clientWidth); count.textContent = `${current + 1} / ${images.length}`; [...dots.children].forEach((dot, i) => dot.setAttribute('aria-pressed', String(i === current))); prev.disabled = current === 0; next.disabled = current === images.length - 1; }; images.forEach((image, i) => { const slide = node('div', 'gallery-slide'); slide.append(image); track.append(slide); const img = image.querySelector('img'); img.loading = i === 0 ? 'eager' : 'lazy'; if (i === 0) img.setAttribute('fetchpriority', 'high'); const dot = button('', 'gallery-dot', () => select(i)); dot.setAttribute('aria-label', `查看第 ${i + 1} 张`); dots.append(dot); }); track.addEventListener('scroll', update, { passive: true }); track.addEventListener('keydown', event => { if (event.target === track && ['ArrowLeft', 'ArrowRight'].includes(event.key)) { event.preventDefault(); select(current + (event.key === 'ArrowLeft' ? -1 : 1)); } }); count.hidden = dots.hidden = prev.hidden = next.hidden = images.length < 2; gallery.append(track, count, prev, next, dots); update(); return gallery; } function sanitized(source, base) { const fragment = document.createDocumentFragment(); const allowed = new Set(['P','DIV','SPAN','BR','B','STRONG','EM','I','U','S','DEL','PRE','CODE','UL','OL','LI','TABLE','THEAD','TBODY','TR','TD','TH','H1','H2','H3','H4','HR','DETAILS','SUMMARY','FONT']); const copy = (child, dest) => { if (child.nodeType === 3) { dest.append(document.createTextNode(child.textContent)); return; } if (child.nodeType !== 1 || ['SCRIPT','STYLE','IFRAME','OBJECT','EMBED','FORM','INPUT','BUTTON','SVG'].includes(child.tagName)) return; // NGA 注入的外链提示与装饰括号不是帖子正文;移除后保留实际链接。 if (child.matches('.urltip, .apd')) return; if (child.matches('.reply-reference')) { const target = [...child.querySelectorAll('a[href]')].map(a => safeURL(a.getAttribute('href'),base)).find(u => u?.origin === pageURL.origin && u.searchParams.get('tid') === tid && u.searchParams.has('topid')); if (target) { dest.append(button(child.textContent.trim(), 'context reply-rel', () => showContext(target))); return; } } if (child.matches('.quote, blockquote')) { const detail = node('details', 'quoted'), title = node('summary'); const body = node('div', 'quoted-body'); for (const c of child.childNodes) copy(c, body); const raw = body.textContent.replace(/\s+/g, ' ').replace(/\(undefined\)/g, '').trim(); const excerpt = raw.replace(/^[+R\s]*by\s*/i, ''); title.textContent = `引用 · ${excerpt.slice(0, 85)}${excerpt.length > 85 ? '…' : ''}`; const target = [...child.querySelectorAll('a[href]')].map(a => safeURL(a.getAttribute('href'), base)).find(u => u?.origin === pageURL.origin && u.searchParams.get('tid') === tid && u.searchParams.has('topid')); detail.append(title, body); if (target) detail.append(button('查看原楼 ↗', 'context', () => showContext(target))); dest.append(detail); return; } if (child.tagName === 'IMG') { if (child.getAttribute('style')?.includes('display:none') || child.getAttribute('style')?.includes('display: none')) return; const raw = child.getAttribute('data-src') || child.getAttribute('data-original') || child.getAttribute('src'); if (!raw) return; const u = safeURL(raw, base); if (!u) return; const alt = child.getAttribute('alt') || ''; if (/smile|emotion/i.test(u.href) || /smile/.test(child.className)) { const img = node('img', 'emoji'); img.src = u.href; img.alt = alt; img.loading = 'lazy'; img.referrerPolicy = 'same-origin'; dest.append(img); } else dest.append(makeImage(u.href, alt, child)); return; } if (child.tagName === 'A') { if (child.querySelector('img')) { for (const c of child.childNodes) copy(c, dest); return; } const href = child.getAttribute('href'); const u = href?.trim() ? safeURL(href, base) : null; if (!u) { if (!/^[+R]$/.test(child.textContent.trim())) for (const c of child.childNodes) copy(c, dest); return; } if (child.classList.contains('pid-reference') && !child.closest('.quote,blockquote,.reply-reference') && u.origin === pageURL.origin && u.searchParams.get('tid') === tid && u.searchParams.has('topid')) { dest.append(button('查看原楼 ↗', 'context', () => showContext(u))); return; } const a = link(u.href, '', 'content-link'); a.rel = 'noopener noreferrer'; a.title = u.href; if (u.origin !== pageURL.origin) { a.classList.add('external-link'); a.target = '_blank'; } else if (child.getAttribute('target') === '_blank') a.target = '_blank'; for (const c of child.childNodes) copy(c, a); if (!a.textContent.trim()) a.textContent = u.href; dest.append(a); return; } const tag = allowed.has(child.tagName) ? (child.tagName === 'FONT' ? 'span' : child.tagName.toLowerCase()) : 'span'; const el = node(tag); if (child.tagName === 'DETAILS' && child.hasAttribute('open')) el.open = true; for (const c of child.childNodes) copy(c, el); dest.append(el); }; for (const child of source.childNodes) copy(child, fragment); return fragment; } function syncCommentActions(card, p, authenticated) { const actions = card.querySelector('.comment-actions'); if (!authenticated) { actions.querySelectorAll('a').forEach(a => a.remove()); return; } if (!actions.querySelector('.floor-reply')) actions.append(postLink(p.replyURL, '回复', 'floor-reply'), postLink(p.quoteURL, '引用', 'floor-quote')); } function makeComment(p, parent) { const op = p.floor === 0; const signature = [p.author, p.uid, JSON.stringify(p.profile), p.replyURL, p.quoteURL, p.time, p.content.outerHTML, p.original, op ? rtitle.textContent : '', parent?.key || '', parent?.author || ''].join('|'); const existing = previousCards.get(p.key); if (existing?.signature === signature) { cards.set(p.key, existing.card); return existing.card; } const card = node('article', `comment${op ? ' op' : ''}`); card.dataset.key = p.key; card.style.setProperty('--bg', color(p.pid || String(p.floor || 0))); const head = node('header', 'comment-head'), identity = node('div', 'reader-identity'); const name = button(p.author, 'reader-author user-trigger', () => userProfiles.show(p, name)); if (op) name.append(node('span', 'op-label', '楼主')); identity.append(name, node('div', 'comment-date', p.time)); const floor = link(p.original, op ? '首楼' : `#${p.floor ?? '?'}`, 'floor'); floor.title = '在原站查看此楼'; floor.setAttribute('aria-label', `${op ? '首楼' : `第 ${p.floor ?? '?'} 楼`},在原站查看`); const avatar = button(p.author.replace(/^UID:/, '').slice(0, 1) || 'N', 'reader-avatar user-trigger', () => userProfiles.show(p, avatar)); userProfiles.decorate(avatar, p, true); head.append(avatar, identity, floor); const body = node('div', 'comment-content'); body.append(sanitized(p.content, p.base)); const actions = node('div', 'comment-actions'); actions.append(node('span', 'action-date', p.time)); card.append(head); if (parent) card.append(node('div', 'reply-rel', `回复 #${parent.floor ?? '?'} · ${parent.author}`)); if (op) { const images = [...body.querySelectorAll('.reader-image')].filter(image => !image.closest('.quoted, table, details')); const firstFrame = [...body.querySelectorAll('.reader-image')].find(image => !image.closest('.quoted')); const firstImage = firstFrame?.querySelector('.attachment'); if (firstImage) { // 记下封面比例,列表可在图片加载前预占位,滚动时卡片高度不跳、点击命中稳定。 const sent = firstFrame.dataset.ratio || ''; const [coverW, coverH] = sent.split(' / ').map(Number); postCache?.saveCover(tid, firstImage.href, { title: rtitle.textContent, url: pageURL.href, ...(coverW > 0 && coverH > 0 ? { coverW, coverH } : {}) }); firstFrame.querySelector('img')?.addEventListener('load', () => { const img = firstFrame.querySelector('img'), ratio = `${img.naturalWidth} / ${img.naturalHeight}`; if (img.naturalWidth > 0 && ratio !== sent) postCache?.saveCover(tid, firstImage.href, { coverW: img.naturalWidth, coverH: img.naturalHeight }); }, { once: true }); } if (images.length) { card.classList.add('has-gallery'); card.append(makeGallery(images)); } const copy = node('div', 'note-copy'); copy.append(node('h1', 'note-title', rtitle.textContent), body); card.append(copy, actions); } else card.append(body, actions); card.readerPost = p; syncCommentActions(card, p, !!currentAccount()); card.readerSignature = signature; cards.set(p.key, card); return card; } // Keep unchanged nodes attached: clearing the stream can clamp the browser's // scroll position and interrupt mobile scrolling even if it is restored later. function reconcileChildren(parent, children) { let current = parent.firstChild; for (const child of children) { if (child !== current) parent.insertBefore(child, current); current = child.nextSibling; } while (current) { const next = current.nextSibling; current.remove(); current = next; } } function renderReader() { const scroll = getReadingScroll(); const galleryScrolls = [...stream.querySelectorAll('.gallery-track')].map(track => [track, track.scrollLeft]); previousCards = new Map([...cards].map(([key, card]) => [key, { card, signature: card.readerSignature }])); const sections = new Map([...stream.children].map(section => [Number(section.dataset.page), section])); const nextSections = []; cards.clear(); const hasOp = [...records.values()].some(p => p.floor === 0); rhead.hidden = hasOp; // 同页关系图仅接受一个明确引用,且只能指向较早的非首楼,避免误合并和循环。 for (const [number, posts] of [...pages].sort((a, b) => a[0] - b[0])) { const section = sections.get(number) || node('section', 'reader-page'); section.dataset.page = number; const sectionChildren = [], threads = new Map([...section.children].filter(child => child.classList.contains('thread')).map(thread => [thread.dataset.parent, thread])); if (!posts.some(p => p.floor === 0)) { const heading = [...section.children].find(child => child.classList.contains('page-heading')) || node('div', 'page-heading'); const label = `第 ${number} 页 · ${posts.length} 个楼层`; if (heading.textContent !== label) heading.textContent = label; sectionChildren.push(heading); } const local = new Map(posts.map(p => [p.key, p])), children = new Map(), parents = new Map(); if (prefs.groupReplies) for (const p of posts) if (p.refs.length === 1) { const parent = local.get(`pid:${p.refs[0].pid}`); if (parent && parent.floor > 0 && p.floor > parent.floor) { parents.set(p.key, parent); if (!children.has(parent.key)) children.set(parent.key, []); children.get(parent.key).push(p); } } const descendants = root => { const out = []; for (const p of children.get(root.key) || []) { out.push(p, ...descendants(p)); } return out; }; for (const p of posts) { if (parents.has(p.key)) continue; sectionChildren.push(makeComment(p)); const replies = descendants(p).sort((a, b) => a.floor - b.floor); if (replies.length) { const thread = threads.get(p.key) || node('details', 'thread'); thread.dataset.parent = p.key; const summary = [...thread.children].find(child => child.tagName === 'SUMMARY') || node('summary'); const label = `展开 ${replies.length} 条关联回复`; if (summary.textContent !== label) summary.textContent = label; reconcileChildren(thread, [summary, ...replies.map(reply => makeComment(reply, parents.get(reply.key)))]); sectionChildren.push(thread); } } reconcileChildren(section, sectionChildren); nextSections.push(section); } reconcileChildren(stream, nextSections); rsub.textContent = `已加载 ${pages.size} 页 · ${records.size} 个楼层 · 引用默认折叠`; dockCount.textContent = `评论 ${[...records.values()].filter(p => p.floor !== 0).length}`; let last = cursor; while (pages.has(last + 1)) last++; const atEnd = ended || nextPages.get(last) === false; load.hidden = atEnd; endMarker.hidden = !atEnd; load.disabled = busy || atEnd; load.textContent = busy ? '正在加载…' : '加载下一页'; // 浏览器会在节点脱离文档时重置横向滚动,接回后恢复当前图片。 for (const [track, left] of galleryScrolls) if (track.isConnected) track.scrollLeft = left; if (getReadingScroll() !== scroll) setReadingScroll(scroll); } function focusRecord(key) { const card = cards.get(key); if (!card) return false; let parent = card.parentElement; while (parent && parent !== app) { if (parent.tagName === 'DETAILS') parent.open = true; parent = parent.parentElement; } card.scrollIntoView({ block: 'center', behavior: 'smooth' }); card.classList.add('flash'); setTimeout(() => card.classList.remove('flash'), 1700); return true; } async function showContext(u) { const key = `pid:${u.searchParams.get('topid')}`; if (focusRecord(key)) return; if (busy) { status.textContent = '正在加载页面,请稍后再查看原楼。'; return; } const page = Number(u.searchParams.get('page')); if (Number.isInteger(page) && page > 0) { await loadPage(page); if (focusRecord(key)) return; } // NGA's older references may omit their page. Search at most the first 500 replies. if (!Number.isInteger(page) || page < 1 || page <= 26) { for (let number = 1; number <= 26; number++) { await loadPage(number); if (focusRecord(key)) return; if (nextPages.get(number) === false || !pages.has(number)) break; } } status.replaceChildren(node('span', '', '原楼暂未解析到,可在原站查看: '), link(u.href, '打开原楼')); } async function loadPage(number, advance = false) { if (pages.has(number) || busy || !Number.isInteger(number) || number < 1) return; busy = true; renderReader(); status.textContent = `正在读取第 ${number} 页…`; const u = canonical(number), controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 15000); const generation = postCache?.generation; try { const cached = await postCache?.getReplyPage(tid,number), restored = restorePage(cached).filter(p=>!records.has(p.key)); if (restored.length && generation === postCache?.generation) { nextPages.set(number,cached.next); pages.set(number,restored); rebuildRecords(); postCache.visit({tid}); status.textContent = `第 ${number} 页来自本地缓存`; return; } const response = await fetch(u.href, { credentials: 'same-origin', signal: controller.signal }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const finalURL = safeURL(response.url || u.href); if (!finalURL || finalURL.origin !== pageURL.origin || finalURL.searchParams.get('tid') !== tid) throw new Error('原站返回了其他页面'); const bytes = new Uint8Array(await response.arrayBuffer()), initial = new TextDecoder('utf-8').decode(bytes); const encoding = response.headers.get('content-type')?.match(/charset\s*=\s*([\w-]+)/i)?.[1] || initial.slice(0,4096).match(/charset\s*=\s*["']?([\w-]+)/i)?.[1] || 'utf-8'; const doc = new DOMParser().parseFromString(new TextDecoder(encoding).decode(bytes), 'text/html'); const all = parsePosts(doc, u.href), fresh = all.filter(p => !records.has(p.key)); if (!all.length) throw new Error('需要原站跳转、登录,或页面结构不支持'); if (!fresh.length) { if (advance) ended = true; status.textContent = '原站未返回新的楼层,已停止重复加载。'; return; } nextPages.set(number, detectNextPage(doc, number, u.href)); pages.set(number, fresh); rebuildRecords(); status.textContent = `已加载第 ${number} 页`; if (generation === postCache?.generation) cachePage(number,all,nextPages.get(number)); } catch (error) { status.replaceChildren(node('span', '', `${error.name === 'AbortError' ? '加载超时' : error.message}。 `), link(u.href, `直接打开第 ${number} 页`)); } finally { clearTimeout(timeout); busy = false; renderReader(); } } updateNative(); const restoreGeneration = postCache?.generation; postCache?.getReplyPage(tid,nativePage).then(cached => { if (pages.size || coverDisposed || restoreGeneration !== postCache.generation) return; const restored = restorePage(cached); if (!restored.length) return; cursor = nativePage; pages.set(nativePage,restored); nextPages.set(nativePage,cached.next); rtitle.textContent = cached.title || rtitle.textContent; rebuildRecords(); renderReader(); setMode(!!prefs.enabled); status.textContent = '正在阅读本地缓存'; }); const nativeObserver = new MutationObserver(() => { clearTimeout(scanTimer); scanTimer = setTimeout(updateNative, 250); }); nativeObserver.observe(document.body, { childList: true, subtree: true, characterData: true }); const userSignature = () => { const table = readNGAUserData(document, window); return JSON.stringify([Object.entries(table.users).map(([uid,u]) => [uid,u.username,u.avatar,u.regdate,u.memberid,u.groupid,u.rvrc,u.money,u.postnum]),table.groups]); }; let lastUsers = userSignature(); const userRefresh = window.setInterval(() => { const signature = userSignature(); if (signature !== lastUsers) { lastUsers = signature; updateNative(); } }, 1200); window.addEventListener('pagehide', () => { window.clearInterval(userRefresh); nativeObserver.disconnect(); clearTimeout(scanTimer); clearTimeout(gateTimer); }); } if (/\/read\.php$/.test(pageURL.pathname)) { startReader(); return; } startList(); configureListRefresh(); // 列表由 NGA 后续脚本生成时再扫描,不覆盖登录/错误页。 const observer = new MutationObserver(() => { clearTimeout(timer); timer = setTimeout(scan, 220); }); observer.observe(document.body, { childList: true, subtree: true, characterData: true }); observeListEnd(); window.addEventListener('pagehide', () => { observer.disconnect(); cardObserver?.disconnect(); listObserver?.disconnect(); listController?.abort(); refreshController?.abort(); stopListRefresh(); clearTimeout(timer); clearTimeout(gateTimer); }); } const config = {"storageKey":"nga-cards-v1","hosts":["bbs.nga.cn","nga.178.com","ngabbs.com","bbs.ngacn.cc"],"paths":["/thread.php","/read.php","/forum.php","/index.php","/"]}; config.accepts = u => config.hosts.includes(u.hostname) && config.paths.includes(u.pathname); const postCache = createPostCache({ context: window, key: config.storageKey }); config.mountReader = context => { const navigation = createNavigation(config, context); try { runAdapter({ navigation, context, postCache }); } catch (error) { navigation.destroy(); throw error; } return () => { context.dispatchEvent(new context.PageTransitionEvent('pagehide')); navigation.destroy(); }; }; const navigation = createNavigation(config); const start = () => { if (!config.accepts(new URL(location.href))) { navigation.finish(); return; } try { runAdapter({ navigation, postCache }); } catch (error) { navigation.finish(); console.error('[readscape]', error); } }; if (document.body && document.head) start(); else document.addEventListener('DOMContentLoaded', start, {once:true}); })();