// ==UserScript== // @name 骨碌碌手机沉浸阅读套件 V1.95 // @namespace Violentmonkey Scripts // @match *://gululu.world/book/* // @match *://www.gululu.world/book/* // @match *://gululu.world/chat/* // @match *://www.gululu.world/chat/* // @match *://gululu.world/ // @match *://www.gululu.world/ // @grant GM_xmlhttpRequest // @grant unsafeWindow // @run-at document-start // @version 1.95 // @author 无限悾涧 // @description 手机阅读完美适配:沉浸式阅读 / 楼层&段落评论 / 氛围背景 / 音乐控制 / 骰子解析 / 快捷菜单 / 流量节省。需配合主脚本使用。 // ==/UserScript== (function() { 'use strict'; // ========================================== // 0. 启动配置检测 // ========================================== let config = {}; try { config = JSON.parse(localStorage.getItem('gululu_global_config_v6') || '{}'); } catch (e) {} // 容错:防止缩放系数异常导致界面崩溃 if (!config.mobileScale || isNaN(config.mobileScale) || config.mobileScale < 0.1) { config.mobileScale = 1.0; } // 🌙 全局夜间模式初始化 (统一管理) if (config.nightMode) { document.body.classList.add('g-night-mode'); } if (!config.mobileModeEnabled) return; // ⚡ 全屏自动缩放逻辑 (全局生效 + 跨页面继承) let preFullscreenScale = null; const updateScaleUI = () => { document.documentElement.style.setProperty('--gm-scale', config.mobileScale); const scaleText = `UI缩放: ${Math.round(config.mobileScale * 100)}%`; document.querySelectorAll('#gm-act-scale span, #gm-menu-scale').forEach(el => { if(el.id === 'gm-menu-scale') el.innerText = scaleText; else el.innerText = scaleText; }); }; const handleFullscreenChange = () => { const isFull = !!document.fullscreenElement; const scales = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5]; if (isFull) { // 进入全屏:如果之前没保存过比例,则保存当前比例 if (preFullscreenScale === null) { preFullscreenScale = config.mobileScale; } // 计算目标比例 (降2级,例如 1.0 -> 0.5) let idx = scales.indexOf(preFullscreenScale); if (idx === -1) idx = 3; let newIdx = Math.max(0, idx - 2); // 应用新比例 config.mobileScale = scales[newIdx]; // 更新按钮文本 document.querySelectorAll('#gm-menu-fullscreen, #gm-act-fullscreen span').forEach(el => { if(el.tagName === 'SPAN') el.innerText = '退出全屏'; else el.innerText = '退出全屏 ✕'; }); } else { // 退出全屏:恢复比例 if (preFullscreenScale !== null) { config.mobileScale = preFullscreenScale; preFullscreenScale = null; } // 恢复按钮文本 document.querySelectorAll('#gm-menu-fullscreen, #gm-act-fullscreen span').forEach(el => { if(el.tagName === 'SPAN') el.innerText = '全屏模式'; else el.innerText = '全屏模式 ⛶'; }); } updateScaleUI(); }; document.addEventListener('fullscreenchange', handleFullscreenChange); // 🚀 启动时恢复逻辑 (实现全屏继承) // 如果 sessionStorage 标记了全屏,但当前未全屏,则监听第一次交互来恢复 if (sessionStorage.getItem('gm_is_fullscreen') === '1') { if (document.fullscreenElement) { handleFullscreenChange(); // 已经是全屏 (如刷新),直接应用缩放 } else { // 等待用户第一次点击/触摸时恢复全屏 const restoreFs = () => { document.documentElement.requestFullscreen().catch(()=>{}); document.removeEventListener('click', restoreFs); document.removeEventListener('touchend', restoreFs); // 改为 touchend 兼容性更好 }; document.addEventListener('click', restoreFs); document.addEventListener('touchend', restoreFs); } } console.log("📱 [Mobile Reforged] v4.5 启动..."); // ========================================== // 0.5 流量节省拦截器 // ========================================== const originalFetch = window.fetch; window.fetch = function(url, options) { if (typeof url === 'string' && url.includes('paragraph-comment-count')) { return Promise.resolve(new Response(JSON.stringify({ code: 200, data: {} }), { status: 200, headers: { 'Content-Type': 'application/json' } })); } return originalFetch.apply(this, arguments); }; // ========================================== // 1. 样式系统 // ========================================== const appStyle = ` /* 核心:隐藏原网页 */ #__next { opacity: 0 !important; pointer-events: none !important; position: fixed !important; z-index: -9999 !important; } html, body { background: #f5f6fa !important; overflow: hidden !important; overscroll-behavior: none !important; touch-action: none; } /* 手机版主容器 */ #g-mobile-app { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: transparent; z-index: 999999; display: flex; flex-direction: column; font-family: -apple-system, "Microsoft YaHei", sans-serif; overscroll-behavior: none; } /* 背景图层 */ #gm-bg-layer { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 999000; background-size: cover; background-position: center; background-repeat: no-repeat; opacity: 0; pointer-events: none; transition: opacity 0.5s ease-in-out; filter: brightness(0.9); } #gm-bg-backdrop { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 998000; background: rgba(255, 255, 255, 0.3); pointer-events: none; } /* 顶部导航 */ .gm-navbar { height: 140px; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid rgba(0,0,0,0.05); display: flex; align-items: center; justify-content: space-between; padding: 0 30px; flex-shrink: 0; } .gm-nav-title { font-weight: 800; font-size: 45px; color: #333; max-width: 80%; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; } .gm-nav-btn { font-size: 50px; padding: 20px; color: #666; cursor: pointer; min-width: 60px; text-align: center; } /* 内容区域 */ .gm-content { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 20px; scroll-behavior: smooth; -webkit-overflow-scrolling: touch; overscroll-behavior: none; } /* 楼层卡片 */ .gm-floor { background: rgba(255, 255, 255, 0.85); backdrop-filter: blur(5px); border-radius: 24px; padding: 35px; margin-bottom: 25px; box-shadow: 0 4px 16px rgba(0,0,0,0.05); border: 1px solid rgba(255,255,255,0.4); transition: background 0.3s; } .gm-floor-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 2px solid rgba(0,0,0,0.05); } .gm-user-info { display: flex; align-items: center; gap: 25px; } .gm-avatar { width: 100px; height: 100px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.5); object-fit: cover; } .gm-username { font-size: 36px; font-weight: 700; color: #2d3436; } .gm-floor-meta { font-size: 28px; color: #636e72; margin-top: 8px; } .gm-floor-right { display: flex; flex-direction: column; align-items: flex-end; gap: 10px; } .gm-floor-id { font-size: 30px; color: #b2bec3; font-weight: bold; } .gm-floor-cmt-btn { font-size: 34px; color: #6c5ce7; background: rgba(108, 92, 231, 0.1); padding: 15px 35px; border-radius: 40px; cursor: pointer; font-weight: 700; } .gm-floor-cmt-btn:active { background: rgba(108, 92, 231, 0.2); transform: scale(0.95); } .gm-floor-body { font-size: 42px; line-height: 1.6; color: #2d3436; word-break: break-word; } .gm-floor-body img { width: 100%; height: auto; border-radius: 16px; margin: 20px 0; display: block; } .gm-floor-body p { margin-bottom: 25px; min-height: 40px; } .gm-floor-body h1, .gm-floor-body h2, .gm-floor-body h3 { margin: 30px 0; font-weight: 800; line-height: 1.4; color: #2d3436; } .gm-floor-body h1 { font-size: 1.4em; } .gm-floor-body h2 { font-size: 1.25em; } .gm-floor-body h3 { font-size: 1.1em; } /* 插件适配 */ .g-dice-mask, .g-dice-revealed { font-size: 38px !important; height: auto !important; line-height: 1.5 !important; padding: 4px 12px !important; border-radius: 12px !important; min-width: 80px !important; margin: 0 6px !important; border-width: 2px !important; } .g-dice-mask::after { font-size: 32px !important; } .g-music-key { font-size: 36px !important; padding: 16px 40px !important; border-radius: 60px !important; margin: 15px 0 !important; height: auto !important; max-width: 90% !important; box-shadow: 0 6px 20px rgba(108, 92, 231, 0.3) !important; } .g-music-key::before { font-size: 30px !important; margin-right: 16px !important; } .g-fold-controller { font-size: 36px !important; padding: 20px 30px !important; margin: 20px 0 !important; border-radius: 16px !important; border-width: 2px !important; } .g-folded-content { padding-left: 30px !important; border-left-width: 6px !important; margin-left: 15px !important; } .g-secret-chest, .g-clue-key { font-size: 34px !important; padding: 16px 24px !important; border-radius: 16px !important; border-width: 3px !important; margin: 10px 0 !important; } .g-quote-box { padding: 20px 30px !important; border-left-width: 8px !important; margin: 20px 0 !important; } .g-quote-content { font-size: 36px !important; } .g-quote-icon { font-size: 60px !important; top: -25px !important; } .g-quote-info { font-size: 28px !important; margin-top: 10px !important; } .gm-loader { padding: 40px; text-align: center; color: #999; font-size: 30px; display: none; justify-content: center; align-items: center; } .gm-loader.active { display: flex; } .gm-loader::after { content:""; width:40px; height:40px; border:4px solid #ddd; border-top-color:#888; border-radius:50%; margin-left:20px; animation:gm-spin 0.8s linear infinite; } @keyframes gm-spin { to { transform: rotate(360deg); } } /* --- 🌙 手机版夜间模式 (深度适配) --- */ body.g-night-mode, body.g-night-mode html { background-color: #121212 !important; } body.g-night-mode #g-mobile-app { background: transparent; } body.g-night-mode .gm-navbar { background: rgba(30, 30, 30, 0.95); border-color: rgba(255,255,255,0.1); } body.g-night-mode .gm-nav-title { color: #dfe6e9; } body.g-night-mode .gm-nav-btn { color: #b2bec3; } /* 修复闲聊背景夜间模式 */ body.g-night-mode #gm-bg-backdrop { background: rgba(0, 0, 0, 0.6); } body.g-night-mode .gm-floor { background: rgba(35, 35, 35, 0.85); box-shadow: none; border: 1px solid rgba(255,255,255,0.08); } body.g-night-mode .gm-floor-header { border-color: rgba(255,255,255,0.1); } body.g-night-mode .gm-username { color: #dfe6e9; } body.g-night-mode .gm-floor-meta { color: #636e72; } /* 强制覆盖正文中所有文本元素的颜色 */ body.g-night-mode .gm-floor-body, body.g-night-mode .gm-floor-body p, body.g-night-mode .gm-floor-body span, body.g-night-mode .gm-floor-body h1, body.g-night-mode .gm-floor-body h2, body.g-night-mode .gm-floor-body h3, body.g-night-mode .gm-floor-body h4, body.g-night-mode .gm-floor-body h5, body.g-night-mode .gm-floor-body h6, body.g-night-mode .gm-floor-body strong, body.g-night-mode .gm-floor-body b { color: #b2bec3 !important; } /* 降低图片亮度,保护眼睛 */ body.g-night-mode .gm-floor-body img { filter: brightness(0.7); transition: filter 0.3s; } /* 组件适配 */ body.g-night-mode .g-dice-mask { background-color: #2d3436 !important; border-color: #555 !important; } body.g-night-mode .g-fold-controller { background: #2d2d2d !important; border-color: #555 !important; color: #ddd !important; } body.g-night-mode .g-quote-box { background: #2d3436 !important; } body.g-night-mode .g-quote-content { color: #b2bec3 !important; } /* 选中文字颜色 */ body.g-night-mode ::selection { background: #fdcb6e !important; color: #000 !important; } /* === 评论系统 === */ .gm-comment-btn { display: none; /* 默认隐藏 */ position: relative; width: 54px; height: 40px; margin-left: 2px; cursor: pointer; vertical-align: -5px; transition: all 0.2s; -webkit-tap-highlight-color: transparent; } .gm-comment-btn svg { width: 100%; height: 100%; filter: drop-shadow(0 2px 2px rgba(0,0,0,0.1)); } .gm-comment-btn svg path { stroke: #999; stroke-width: 1.2; } .gm-comment-num { position: absolute; top: 70%; left: 50%; transform: translate(-50%, -50%); font-size: 20px; color: #999; font-weight: bold; font-family: sans-serif; } .gm-comment-btn:active { transform: scale(1.2); } .gm-comment-btn.has-comment { display: inline-block; opacity: 1; } .g-show-comments .gm-comment-btn { display: inline-block !important; } .g-show-comments .gm-comment-btn:not(.has-comment) { opacity: 0.4; filter: grayscale(1); } #gm-comment-toggle { position: fixed; bottom: 170px; right: 20px; width: 80px; height: 80px; background: rgba(255,255,255,0.95); border-radius: 50%; box-shadow: 0 4px 16px rgba(0,0,0,0.2); border: 2px solid #dcdde1; text-align: center; line-height: 80px; cursor: pointer; z-index: 1000001; font-size: 36px; transition: all 0.2s; backdrop-filter: blur(5px); } #gm-comment-toggle:active { transform: scale(0.9); background: #eee; } #gm-comment-toggle.active { background: #6c5ce7; color: white; border-color: #6c5ce7; box-shadow: 0 4px 20px rgba(108, 92, 231, 0.4); } #gm-home-btn { position: fixed; bottom: 70px; right: 20px; width: 80px; height: 80px; background: rgba(255,255,255,0.95); border-radius: 50%; box-shadow: 0 4px 16px rgba(0,0,0,0.2); border: 2px solid #dcdde1; text-align: center; line-height: 80px; cursor: pointer; z-index: 1000001; font-size: 36px; transition: all 0.2s; backdrop-filter: blur(5px); } #gm-home-btn:active { transform: scale(0.9); background: #eee; } body.g-night-mode #gm-comment-toggle, body.g-night-mode #gm-home-btn { background: rgba(50,50,50,0.9); color: #ccc; border-color: #555; } body.g-night-mode #gm-comment-toggle.active { background: #6c5ce7; color: white; } /* === 快捷菜单 === */ #gm-menu-toggle { position: fixed; bottom: 270px; right: 20px; width: 80px; height: 80px; background: rgba(255,255,255,0.95); border-radius: 50%; box-shadow: 0 4px 16px rgba(0,0,0,0.2); border: 2px solid #dcdde1; text-align: center; line-height: 80px; cursor: pointer; z-index: 1000001; font-size: 36px; transition: all 0.2s; backdrop-filter: blur(5px); } #gm-menu-toggle:active { transform: scale(0.9); background: #eee; } #gm-menu-toggle.active { transform: rotate(45deg); background: #636e72; color: white; border-color: #636e72; } .gm-menu-list { position: fixed; bottom: 370px; right: 20px; display: flex; flex-direction: column; gap: 20px; z-index: 1000001; pointer-events: none; opacity: 0; transform: translateY(20px) scale(0.9); transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); transform-origin: bottom right; } .gm-menu-list.active { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; } .gm-menu-item { background: rgba(255,255,255,0.95); padding: 15px 30px; border-radius: 40px; box-shadow: 0 4px 16px rgba(0,0,0,0.15); font-size: 28px; font-weight: bold; color: #333; text-align: right; cursor: pointer; border: 1px solid #eee; display: flex; align-items: center; justify-content: flex-end; gap: 15px; backdrop-filter: blur(5px); } .gm-menu-item:active { transform: scale(0.95); background: #f0f0f0; } body.g-night-mode #gm-menu-toggle { background: rgba(50,50,50,0.9); color: #ccc; border-color: #555; } body.g-night-mode #gm-menu-toggle.active { background: #636e72; color: white; } body.g-night-mode .gm-menu-item { background: rgba(50,50,50,0.95); color: #ccc; border-color: #555; } /* === 评论抽屉修复 === */ #gm-cmt-drawer { position: fixed; bottom: 0; left: 0; width: 100%; height: 70vh; background: #fff; border-radius: 40px 40px 0 0; /* 提升层级,高于悬浮按钮(1000001) */ z-index: 1000010; transform: translateY(110%); transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1); display: flex; flex-direction: column; box-shadow: 0 -10px 40px rgba(0,0,0,0.1); } #gm-cmt-drawer.active { transform: translateY(0); } #gm-cmt-mask { position: fixed; top: 0; left: 0; width: 100%; height: 100%; /* 提升遮罩层级,防止误触按钮 */ background: rgba(0,0,0,0.5); z-index: 1000005; opacity: 0; pointer-events: none; transition: opacity 0.3s; } #gm-cmt-mask.active { opacity: 1; pointer-events: auto; } /* 布局保护:防止 flex 子元素溢出或被挤压 */ .gm-cmt-header { padding: 25px; font-size: 34px; font-weight: bold; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; /* 防止被挤压 */ } .gm-cmt-close-btn { color: #999; font-weight: normal; cursor: pointer; padding: 10px; margin: -10px; /* 增加点击区域 */ font-size: 40px; line-height: 1; } .gm-cmt-list { flex: 1; overflow-y: auto; padding: 25px; -webkit-overflow-scrolling: touch; min-height: 0; /* ⚡ 关键:允许 flex 子元素收缩,产生滚动条 */ } .gm-cmt-item { display: flex; gap: 20px; padding: 25px 0; border-bottom: 1px solid #f5f5f5; } .gm-cmt-avatar { width: 90px; height: 90px; border-radius: 50%; object-fit: cover; } .gm-cmt-content { flex: 1; font-size: 34px; line-height: 1.5; color: #333; } .gm-cmt-meta { font-size: 26px; color: #999; margin-top: 12px; display: flex; justify-content: space-between; align-items: center; } .gm-cmt-like-btn { padding: 10px 0 10px 30px; /* 左侧留空,防止误触 */ font-size: 56px; /* 进一步增大图标 */ cursor: pointer; transition: transform 0.2s, color 0.2s; user-select: none; color: #b2bec3; display: flex; align-items: center; justify-content: center; align-self: flex-start; /* 顶部对齐 */ flex-shrink: 0; /* 防止被挤压 */ } .gm-cmt-like-btn:active { transform: scale(1.2); } .gm-cmt-like-btn.liked { color: #ff4757; } body.g-night-mode #gm-cmt-drawer { background: #1e1e1e; border-top: 1px solid #333; } body.g-night-mode .gm-cmt-header { border-color: #333; color: #eee; } body.g-night-mode .gm-cmt-content { color: #ccc; } body.g-night-mode .gm-cmt-item { border-color: #333; } /* 跳转按钮 - 金色版 */ .gm-jump-btn { display: block; width: 100%; padding: 30px; margin: 40px 0; border-radius: 24px; background: linear-gradient(135deg, #f1c40f 0%, #f39c12 100%); border: 4px solid #e67e22; color: white; font-size: 40px; font-weight: 800; text-align: center; box-shadow: 0 10px 25px rgba(243, 156, 18, 0.4), inset 0 2px 0 rgba(255,255,255,0.2); transition: all 0.2s cubic-bezier(0.25, 0.8, 0.25, 1); text-shadow: 0 2px 4px rgba(0,0,0,0.15); letter-spacing: 2px; } .gm-jump-btn:active { transform: scale(0.96) translateY(2px); box-shadow: 0 4px 12px rgba(243, 156, 18, 0.3); filter: brightness(0.95); border-color: #d35400; } body.g-night-mode .gm-jump-btn { background: linear-gradient(135deg, #b7791f 0%, #d35400 100%); border-color: #a0522d; color: #f1f2f6; box-shadow: 0 10px 25px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.1); } .gm-cmt-footer { padding: 20px; border-top: 1px solid #eee; display: flex; flex-direction: column; background: #fff; padding-bottom: calc(20px + env(safe-area-inset-bottom)); flex-shrink: 0; } .gm-cmt-input-area { display: flex; gap: 20px; align-items: flex-end; } .gm-reply-status { font-size: 26px; color: #666; background: #f0f2f5; padding: 10px 20px; margin-bottom: 15px; border-radius: 10px; display: none; justify-content: space-between; align-items: center; } .gm-reply-status.active { display: flex; } .gm-reply-cancel { color: #ff4757; font-weight: bold; cursor: pointer; padding: 5px 15px; } .gm-cmt-actions { display: flex; align-items: center; gap: 20px; } .gm-cmt-reply-btn { font-size: 28px; color: #666; font-weight: bold; cursor: pointer; padding: 10px 20px; background: #f5f6fa; border-radius: 30px; } .gm-cmt-reply-btn:active { background: #e1e1e1; } .gm-sub-cmt-list { margin-left: 110px; margin-top: 20px; background: #f9f9f9; border-radius: 16px; padding: 0 20px; display: none; } .gm-sub-cmt-list.active { display: block; } .gm-sub-cmt-item { padding: 20px 0; border-bottom: 1px solid #eee; font-size: 30px; color: #333; } .gm-sub-cmt-item:last-child { border-bottom: none; } .gm-sub-expand-btn { margin-left: 110px; margin-top: 15px; font-size: 26px; color: #6c5ce7; font-weight: bold; cursor: pointer; } /* 子评论通用样式 */ .gm-sub-user { color: #666; font-size: 24px; margin-bottom: 5px; } .gm-sub-content { color: #333; font-size: 28px; line-height: 1.4; margin-bottom: 10px; } .gm-sub-meta { color: #999; font-size: 22px; display: flex; justify-content: space-between; align-items: center; } /* 楼中楼中楼 (Level 3) 字体微调 */ .gm-sub-item-l3 .gm-sub-user { font-size: 22px; margin-bottom: 3px; } .gm-sub-item-l3 .gm-sub-content { font-size: 26px; margin-bottom: 5px; } .gm-sub-item-l3 .gm-sub-meta { font-size: 20px; } /* 夜间模式子评论适配 */ body.g-night-mode .gm-sub-cmt-list { background: #252525; border: 1px solid #333; } body.g-night-mode .gm-sub-cmt-item { border-color: #333; } body.g-night-mode .gm-sub-user { color: #aaa; } body.g-night-mode .gm-sub-user b { color: #eee; } body.g-night-mode .gm-sub-content { color: #b2bec3; } body.g-night-mode .gm-sub-meta { color: #666; } body.g-night-mode .gm-reply-status { background: #333; color: #ccc; } body.g-night-mode .gm-cmt-reply-btn { background: #333; color: #aaa; } .gm-cmt-input { flex: 1; background: #f5f6fa; border: none; padding: 20px 30px; border-radius: 40px; font-size: 32px; outline: none; color: #333; resize: none; overflow-y: hidden; min-height: 80px; max-height: 300px; font-family: inherit; line-height: 1.4; } .gm-cmt-send { background: #6c5ce7; color: white; border: none; padding: 0 40px; border-radius: 40px; height: 80px; font-size: 32px; font-weight: bold; cursor: pointer; } .gm-cmt-send:active { transform: scale(0.95); } body.g-night-mode .gm-cmt-footer { background: #1e1e1e; border-color: #333; } body.g-night-mode .gm-cmt-input { background: #333; color: #eee; } /* 修复闲聊底部输入框背景 */ #gm-chat-footer { background: #fff; } body.g-night-mode #gm-chat-footer { background: #1e1e1e; border-color: #333; } /* === 底部标签栏 (App TabBar) === */ .gm-tab-bar { position: fixed; bottom: 0; left: 0; width: 100%; height: 120px; background: rgba(255,255,255,0.98); border-top: 1px solid rgba(0,0,0,0.1); display: flex; justify-content: space-around; align-items: center; padding-bottom: env(safe-area-inset-bottom); z-index: 1000000; backdrop-filter: blur(10px); box-shadow: 0 -4px 20px rgba(0,0,0,0.05); } .gm-tab-item { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; height: 100%; color: #999; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: transform 0.1s; } .gm-tab-item:active { transform: scale(0.95); } .gm-tab-item.active { color: #6c5ce7; } .gm-tab-icon { width: 48px; height: 48px; margin-bottom: 8px; fill: currentColor; } .gm-tab-label { font-size: 22px; font-weight: 600; } /* Tab 内容容器 */ .gm-tab-content { display: none; width: 100%; height: 100%; padding: 40px; box-sizing: border-box; font-size: 32px; color: #666; animation: gm-fade-in 0.3s ease; } .gm-tab-content.active { display: block; } @keyframes gm-fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } /* 夜间模式适配 */ body.g-night-mode .gm-tab-bar { background: rgba(30,30,30,0.98); border-color: rgba(255,255,255,0.1); } body.g-night-mode .gm-tab-item { color: #636e72; } body.g-night-mode .gm-tab-item.active { color: #a29bfe; } body.g-night-mode .gm-tab-content { color: #ccc; } /* === 自定义模态框 === */ .gm-modal-mask { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000005; display: flex; justify-content: center; align-items: center; opacity: 0; pointer-events: none; transition: opacity 0.2s; backdrop-filter: blur(5px); } .gm-modal-mask.active { opacity: 1; pointer-events: auto; } .gm-modal { background: #fff; width: 80%; max-width: 600px; border-radius: 32px; padding: 50px; text-align: center; box-shadow: 0 10px 40px rgba(0,0,0,0.2); transform: scale(0.9); transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); } .gm-modal-mask.active .gm-modal { transform: scale(1); } .gm-modal-title { font-size: 40px; font-weight: 800; margin-bottom: 20px; color: #333; } .gm-modal-desc { font-size: 32px; color: #666; margin-bottom: 50px; line-height: 1.5; } .gm-modal-btns { display: flex; gap: 30px; justify-content: center; } .gm-modal-btn { flex: 1; padding: 25px 0; border-radius: 50px; font-size: 32px; font-weight: bold; cursor: pointer; transition: transform 0.1s; } .gm-modal-btn:active { transform: scale(0.95); } .gm-btn-cancel { background: #f1f2f6; color: #666; } .gm-btn-confirm { background: #6c5ce7; color: white; box-shadow: 0 5px 15px rgba(108, 92, 231, 0.3); } body.g-night-mode .gm-modal { background: #2d2d2d; } body.g-night-mode .gm-modal-title { color: #eee; } body.g-night-mode .gm-modal-desc { color: #aaa; } body.g-night-mode .gm-btn-cancel { background: #444; color: #ccc; } /* === 消息提示 (Toast) === */ .gm-toast { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.8); color: #fff; padding: 25px 50px; border-radius: 16px; font-size: 32px; z-index: 1000020; opacity: 0; transition: opacity 0.3s; pointer-events: none; text-align: center; font-weight: bold; } .gm-toast.active { opacity: 1; } /* === 登录弹窗 === */ .gm-login-tabs { display: flex; border-bottom: 1px solid #eee; margin-bottom: 30px; } .gm-login-tab { flex: 1; text-align: center; padding: 20px; font-size: 32px; color: #999; font-weight: bold; cursor: pointer; } .gm-login-tab.active { color: #6c5ce7; border-bottom: 4px solid #6c5ce7; } .gm-input-group { margin-bottom: 30px; position: relative; } .gm-login-input { width: 100%; padding: 25px 30px; background: #f5f6fa; border: 1px solid #eee; border-radius: 16px; font-size: 30px; outline: none; box-sizing: border-box; } .gm-login-input:focus { border-color: #6c5ce7; background: #fff; } .gm-code-btn { position: absolute; right: 10px; top: 10px; height: calc(100% - 20px); padding: 0 30px; background: #6c5ce7; color: white; border-radius: 12px; border: none; font-size: 26px; font-weight: bold; } .gm-code-btn:disabled { background: #ccc; } body.g-night-mode .gm-login-tab { border-color: #333; } body.g-night-mode .gm-login-input { background: #333; border-color: #444; color: #eee; } /* === 屏蔽主脚本的桌面端按钮 === */ #g-setting-btn, #g-magic-btn, #g-mobile-btn, #g-mobile-search-btn, #g-stop-bgm-btn, #g-chat-btn { display: none !important; } /* === 闲聊点赞按钮 (集成缩放) === */ .gm-chat-like-wrapper { position: fixed; bottom: 340px; right: 20px; width: 80px; display: flex; flex-direction: column; align-items: center; z-index: 1000000; pointer-events: none; /* 低于菜单(1000001) */ } #gm-chat-like-btn { width: 80px; height: 80px; background: rgba(255,255,255,0.95); border-radius: 50%; box-shadow: 0 4px 16px rgba(0,0,0,0.2); border: 2px solid #dcdde1; display: flex; align-items: center; justify-content: center; font-size: 36px; transition: all 0.2s; backdrop-filter: blur(5px); cursor: pointer; pointer-events: auto; } #gm-chat-like-btn:active { transform: scale(0.95); } .gm-chat-like-num { margin-top: 5px; font-size: 24px; font-weight: bold; color: #666; text-shadow: 0 1px 2px rgba(255,255,255,0.8); } body.g-night-mode #gm-chat-like-btn { background: rgba(50,50,50,0.9); color: #ccc; border-color: #555; } body.g-night-mode .gm-chat-like-num { color: #aaa; text-shadow: 0 1px 2px rgba(0,0,0,0.8); } /* === 闲聊专用样式修正 === */ /* 楼层子评论容器 */ .gm-chat-sub-list { margin-top: 20px; background: #f9f9f9; border-radius: 16px; padding: 0 20px; display: none; } body.g-night-mode .gm-chat-sub-list { background: #252525; border: 1px solid #333; } /* 楼中楼中楼 (Level 3) 容器 */ .gm-chat-sub-wrapper { background: #f9f9f9; border-radius: 12px; padding: 10px; margin-top: 10px; } body.g-night-mode .gm-chat-sub-wrapper { background: #333; border: 1px solid #444; } /* 点赞按钮样式 (移入CSS以支持缩放) */ #gm-chat-like-btn { width: 80px; height: 80px; background: rgba(255,255,255,0.95); border-radius: 50%; box-shadow: 0 4px 16px rgba(0,0,0,0.2); border: 2px solid #dcdde1; display: flex; align-items: center; justify-content: center; font-size: 36px; transition: all 0.2s; backdrop-filter: blur(5px); cursor: pointer; pointer-events: auto; } #gm-chat-like-btn:active { transform: scale(0.95); } body.g-night-mode #gm-chat-like-btn { background: rgba(50,50,50,0.9); color: #ccc; border-color: #555; } .gm-chat-like-num { margin-top: 5px; font-size: 24px; font-weight: bold; color: #666; text-shadow: 0 1px 2px rgba(255,255,255,0.8); } body.g-night-mode .gm-chat-like-num { color: #aaa; text-shadow: 0 1px 2px rgba(0,0,0,0.8); } `; // 立即注入样式 (处理缩放系数) // 1. 初始化缩放系数 if (!config.mobileScale) config.mobileScale = 1.0; // 2. 将所有 px 单位转换为 calc(px * var(--gm-scale)) // 排除 1px 边框 (可选,但为了保持精致感,一般边框不缩放,或者也缩放) -> 这里选择全量缩放以保持比例一致 const scaledStyle = appStyle.replace(/(\d+)px/g, 'calc($1px * var(--gm-scale))'); // 3. 注入变量定义和处理后的样式 const style = document.createElement('style'); style.textContent = ` :root { --gm-scale: ${config.mobileScale}; } ${scaledStyle} `; (document.head || document.documentElement).appendChild(style); // ========================================== // 2. API 客户端 // ========================================== class GululuApiClient { constructor() { this.token = null; this.directory = []; this.floorMap = new Map(); this.loadedFloors = new Set(); this.minLoaded = Infinity; this.maxLoaded = -Infinity; this.bookId = null; this.bookName = "骨碌碌阅读器"; this.bookAuthor = null; this.totalFloors = 0; this.readProgress = 0; this.isCollected = false; this.isLoading = false; try { this.currentUser = JSON.parse(localStorage.getItem('gululu_user_cache') || '{"nickName":"我","headPic":""}'); } catch (e) { this.currentUser = { nickName: '我', headPic: '' }; } } initData() { // 1. 优先从 Cookie 获取 Token (最准确) const cookieMatch = document.cookie.match(/(?:^|; )token=([^;]*)/); if (cookieMatch && cookieMatch[1]) { this.token = decodeURIComponent(cookieMatch[1]); } const script = document.getElementById('__NEXT_DATA__'); if (!script) return null; try { const data = JSON.parse(script.textContent); const props = data.props?.pageProps; if (!props) return null; // 2. 如果 Cookie 没拿到,尝试从 NEXT_DATA 获取 if (!this.token && props.tokenStore?.token) { this.token = props.tokenStore.token; } // 3. 获取用户信息缓存 (如果有) if (props.userStore?.userInfo) { this.currentUser = props.userStore.userInfo; localStorage.setItem('gululu_user_cache', JSON.stringify(this.currentUser)); } // 4. 阅读页特有数据 (仅在有书籍数据时解析) if (props.bookFloorsStore && props.bookDetailStore) { this.directory = props.bookFloorsStore.directory || []; this.bookId = props.bookDetailStore.opusId; this.bookName = props.bookDetailStore.bookDetail?.name || "骨碌碌阅读器"; this.bookAuthor = props.bookDetailStore.bookDetail?.author; // 修复:优先使用目录最后一项的楼层号,解决"总楼层虚高"问题 if (this.directory && this.directory.length > 0) { this.totalFloors = this.directory[this.directory.length - 1].floorNum; } else { this.totalFloors = props.bookDetailStore.bookDetail?.floorNum || 0; } this.readProgress = props.bookFloorsStore.readProgress || 0; this.isCollected = props.bookDetailStore.bookDetail?.collect || false; this.directory.forEach(d => this.floorMap.set(d.floorNum, d.floorId)); return props.bookFloorsStore.bookFloors; } // 5. 闲聊页数据解析 if (props.chatFloorsStore && props.chatDetailStore) { const detail = props.chatDetailStore.firstFloor; const floors = props.chatFloorsStore.floors || []; // 简单的数据清洗,适配 UI if (detail) { detail.content = detail.chatText; // 映射内容字段 detail.isHtml = true; // 修正点赞数据:优先使用 store 中的数据 (chatDetailStore 包含实时的帖子统计) if (props.chatDetailStore) { detail.isGood = props.chatDetailStore.isLike; detail.goodNum = props.chatDetailStore.likeNum; } } floors.forEach(f => { f.content = f.chatText; f.isHtml = true; f.id = f.chatFloorId; f.childrenNum = f.commentNum; // 映射评论数 }); // 获取最大楼层 (优先使用 floorSortList 的最后一个,因为它更准确) let totalFloors = props.chatDetailStore.maxFloor || 0; if (props.chatDetailStore.floorSortList && props.chatDetailStore.floorSortList.length > 0) { totalFloors = props.chatDetailStore.floorSortList[props.chatDetailStore.floorSortList.length - 1].floorSort; } return { isChat: true, detail, floors, totalFloors }; } return null; } catch (e) { console.error("解析数据失败", e); return null; } } fetchComments(floorId, paragraphId, page = 1) { return new Promise((resolve) => { if (!this.token) return resolve(null); let url = `https://backend.gululu.world/reader/opus/comment/page?opusId=${this.bookId}¤t=${page}&size=20&floorId=${floorId}`; if (paragraphId) url += `¶graphId=${paragraphId}`; GM_xmlhttpRequest({ method: "GET", url: url, headers: { "Authorization": `Bearer ${this.token}`, "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/" }, onload: (response) => { try { const json = JSON.parse(response.responseText); resolve(json.data ? json.data.records : []); } catch (e) { resolve([]); } }, onerror: () => resolve([]) }); }); } fetchCommentCounts(paragraphIds) { return new Promise((resolve) => { if (!this.token || !paragraphIds || paragraphIds.length === 0) return resolve({}); GM_xmlhttpRequest({ method: "POST", url: "https://backend.gululu.world/reader/opus/comment/paragraph-comment-count", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json", "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/" }, data: JSON.stringify(paragraphIds), onload: (response) => { try { const json = JSON.parse(response.responseText); resolve(json.data || {}); } catch (e) { resolve({}); } }, onerror: () => resolve({}) }); }); } sendComment(floorId, pid, content) { return new Promise((resolve) => { if (!this.token) return resolve(null); const payload = { opusId: this.bookId, floorId: floorId, content: content }; if (pid) payload.paragraphId = pid; GM_xmlhttpRequest({ method: "POST", url: "https://backend.gululu.world/reader/opus/comment/create", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json", "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/" }, data: JSON.stringify(payload), onload: (response) => { try { const json = JSON.parse(response.responseText); resolve(json); } catch (e) { resolve(null); } }, onerror: () => resolve(null) }); }); } likeComment(commentId) { return new Promise((resolve) => { if (!this.token) return resolve(false); GM_xmlhttpRequest({ method: "POST", url: "https://backend.gululu.world/reader/opus/comment/like", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json", "Origin": "https://www.gululu.world", "platform": "1" }, data: JSON.stringify({ opusId: this.bookId, id: commentId }), onload: (response) => { try { const json = JSON.parse(response.responseText); // 201 Created 或 200 OK 都视为成功 resolve(json.code === 200 || json.code === 201); } catch (e) { resolve(false); } }, onerror: () => resolve(false) }); }); } saveHistory(floorNum) { if (!this.token || !this.bookId) return; // 简单的防抖:如果进度没变,不发送 if (this.readProgress === floorNum) return; this.readProgress = floorNum; GM_xmlhttpRequest({ method: "POST", url: "https://backend.gululu.world/history/save", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json", "Origin": "https://www.gululu.world", "platform": "1" }, data: JSON.stringify({ opusId: this.bookId, type: 1, floorSort: floorNum, historyId: this.bookId, // 根据抓包,直接传 opusId save: 1 }), onload: () => {}, // 默默保存,不打扰用户 onerror: () => {} }); } toggleCollect(isCollect) { return new Promise((resolve) => { if (!this.token) return resolve(false); GM_xmlhttpRequest({ method: "POST", url: "https://backend.gululu.world/opus/collect", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json", "Origin": "https://www.gululu.world", "platform": "1" }, data: JSON.stringify({ opusId: this.bookId, isCollect: isCollect ? 1 : 0 }), onload: (response) => { try { const json = JSON.parse(response.responseText); if (json.code === 200) { this.isCollected = isCollect; resolve(true); } else { resolve(false); } } catch (e) { resolve(false); } }, onerror: () => resolve(false) }); }); } replyComment(content, replyCommentId) { return new Promise((resolve) => { if (!this.token) return resolve(null); GM_xmlhttpRequest({ method: "POST", url: "https://backend.gululu.world/reader/opus/comment/reply-comment", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json", "Origin": "https://www.gululu.world", "platform": "1" }, data: JSON.stringify({ opusId: this.bookId, content: content, replyCommentId: replyCommentId }), onload: (response) => { try { const json = JSON.parse(response.responseText); resolve(json); } catch (e) { resolve(null); } }, onerror: () => resolve(null) }); }); } fetchChildComments(parentId) { return new Promise((resolve) => { if (!this.token) return resolve([]); GM_xmlhttpRequest({ method: "GET", url: `https://backend.gululu.world/reader/opus/comment/page-children?opusId=${this.bookId}&parentId=${parentId}¤t=1&size=1000`, headers: { "Authorization": `Bearer ${this.token}`, "Origin": "https://www.gululu.world", "platform": "1" }, onload: (response) => { try { const json = JSON.parse(response.responseText); resolve(json.data ? json.data.records : []); } catch (e) { resolve([]); } }, onerror: () => resolve([]) }); }); } fetchFloors(floorIds) { return new Promise((resolve) => { if (!this.token) return resolve(null); GM_xmlhttpRequest({ method: "POST", url: "https://backend.gululu.world/reader/floor/content-by-ids", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json", "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/" }, data: JSON.stringify(floorIds), onload: function(response) { try { const json = JSON.parse(response.responseText); resolve(json.data); } catch (e) { resolve(null); } }, onerror: function(e) { resolve(null); } }); }); } fetchHotSearch() { return new Promise((resolve) => { GM_xmlhttpRequest({ method: "GET", url: "https://backend.gululu.world/search/hot-week", headers: { "Authorization": `Bearer ${this.token}`, "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/" }, onload: (response) => { try { const json = JSON.parse(response.responseText); if (Array.isArray(json)) resolve(json); else if (json.data && Array.isArray(json.data)) resolve(json .data); else resolve([]); } catch (e) { resolve([]); } }, onerror: () => resolve([]) }); }); } fetchSearchHistory() { return new Promise((resolve) => { if (!this.token) return resolve([]); GM_xmlhttpRequest({ method: "GET", url: "https://backend.gululu.world/search/history", headers: { "Authorization": `Bearer ${this.token}`, "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/" }, onload: (response) => { try { const json = JSON.parse(response.responseText); // 兼容直接返回数组的情况 if (Array.isArray(json)) resolve(json); else if (json.data && Array.isArray(json.data)) resolve(json.data); else resolve([]); } catch(e) { resolve([]); } }, onerror: () => resolve([]) }); }); } searchGeneral(keyword, type, page = 1) { return new Promise((resolve) => { if (!this.token) return resolve(null); const encodedKey = encodeURIComponent(keyword); GM_xmlhttpRequest({ method: "GET", url: `https://backend.gululu.world/search/generalPageV2?type=${type}&key=${encodedKey}&page=${page}`, headers: { "Authorization": `Bearer ${this.token}`, "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/" }, onload: (response) => { try { const json = JSON.parse(response.responseText); resolve(json.data); } catch (e) { resolve(null); } }, onerror: () => resolve(null) }); }); } // 用户搜索 searchUser(keyword) { return new Promise((resolve) => { if (!this.token) return resolve([]); GM_xmlhttpRequest({ method: "POST", url: "https://backend.gululu.world/search/opus-author", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json", "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/" }, data: JSON.stringify({ text: keyword }), onload: (response) => { try { const json = JSON.parse(response.responseText); resolve(json.searchAuthorRespList || []); } catch(e) { resolve([]); } }, onerror: () => resolve([]) }); }); } // 获取新人榜 (无分页) fetchNewcomerRank() { return new Promise((resolve) => { GM_xmlhttpRequest({ method: "GET", url: "https://backend.gululu.world/opus/hot-new", headers: { "Authorization": `Bearer ${this.token}`, "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/" }, onload: (response) => { try { const json = JSON.parse(response.responseText); resolve(json.data || []); } catch(e) { resolve([]); } }, onerror: () => resolve([]) }); }); } // 获取通用榜单 (sort: 0=浏览最高, 1=最新更新, 2=最新发布) fetchRankList(sort, page = 1) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: "GET", url: `https://backend.gululu.world/opus/list-sort?pageNum=${page}&pageSize=20&isAsc=asc&sort=${sort}`, headers: { "Authorization": `Bearer ${this.token}`, "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/" }, onload: (response) => { try { const json = JSON.parse(response.responseText); resolve(json.rows || []); } catch(e) { resolve([]); } }, onerror: () => resolve([]) }); }); } clearSearchHistory() { return new Promise((resolve) => { if (!this.token) return resolve(false); GM_xmlhttpRequest({ method: "POST", url: "https://backend.gululu.world/search/delete", headers: { "Authorization": `Bearer ${this.token}`, "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/" }, onload: (response) => { try { const json = JSON.parse(response.responseText); resolve(json.code === 200); } catch(e) { resolve(false); } }, onerror: () => resolve(false) }); }); } // 获取书架 (分页) fetchBookshelf(page = 1) { return new Promise((resolve) => { if (!this.token) return resolve([]); GM_xmlhttpRequest({ method: "GET", url: `https://backend.gululu.world/bookshelf/get-book?pageNum=${page}&pageSize=20&sort=1`, headers: { "Authorization": `Bearer ${this.token}`, "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/", "platform": "1" }, onload: (response) => { try { const json = JSON.parse(response.responseText); resolve(json.code === 200 ? json.data : []); } catch(e) { resolve([]); } }, onerror: () => resolve([]) }); }); } // 获取我的作品 fetchMyWorks() { return new Promise((resolve) => { if (!this.token) return resolve([]); GM_xmlhttpRequest({ method: "GET", url: "https://backend.gululu.world/app-user/select-user", headers: { "Authorization": `Bearer ${this.token}`, "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/", "platform": "1" }, onload: (response) => { try { const json = JSON.parse(response.responseText); resolve(Array.isArray(json) ? json : []); } catch(e) { resolve([]); } }, onerror: () => resolve([]) }); }); } // 获取消息未读数 fetchMessageCount() { return new Promise((resolve) => { if (!this.token) return resolve({}); GM_xmlhttpRequest({ method: "GET", url: "https://backend.gululu.world/message/push-get-all", headers: { "Authorization": `Bearer ${this.token}`, "Origin": "https://www.gululu.world" }, onload: (res) => { try { resolve(JSON.parse(res.responseText).data || {}); } catch(e) { resolve({}); } }, onerror: () => resolve({}) }); }); } // 获取消息列表 (type: 1=赞, 2=回复, 0=系统) fetchMessages(type) { return new Promise((resolve) => { if (!this.token) return resolve([]); GM_xmlhttpRequest({ method: "GET", url: `https://backend.gululu.world/message/push-get-one?type=${type}`, headers: { "Authorization": `Bearer ${this.token}`, "Origin": "https://www.gululu.world" }, onload: (res) => { try { resolve(JSON.parse(res.responseText).data || []); } catch(e) { resolve([]); } }, onerror: () => resolve([]) }); }); } // 获取用户信息 fetchUserInfo() { return new Promise((resolve) => { if (!this.token) return resolve(null); GM_xmlhttpRequest({ method: "GET", url: "https://backend.gululu.world/app-user/list-userinfo", headers: { "Authorization": `Bearer ${this.token}`, "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/", "platform": "1" }, onload: (response) => { try { const json = JSON.parse(response.responseText); if (json.code === 200 && json.data) { this.currentUser = json.data; localStorage.setItem('gululu_user_cache', JSON.stringify(this.currentUser)); resolve(this.currentUser); } else { console.error("获取用户信息失败:", json); resolve(null); } } catch(e) { console.error("解析用户信息失败:", e); resolve(null); } }, onerror: (e) => { console.error("请求用户信息网络错误:", e); resolve(null); } }); }); } // 密码登录 loginByPass(phone, pass) { return new Promise(resolve => { GM_xmlhttpRequest({ method: "POST", url: "https://backend.gululu.world/login/phone-passLogin", headers: { "Content-Type": "application/json", "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/", "platform": "1" // 补全平台标识 }, data: JSON.stringify({ phone, pass }), onload: (res) => resolve(JSON.parse(res.responseText)), onerror: () => resolve({ code: 500, msg: "网络错误" }) }); }); } // 发送验证码 sendSmsCode(phone) { return new Promise(resolve => { GM_xmlhttpRequest({ method: "POST", url: "https://backend.gululu.world/readlogin/send-app-code", headers: { "Content-Type": "application/json", "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/", "platform": "1" }, data: JSON.stringify({ phone }), onload: (res) => resolve(JSON.parse(res.responseText)), onerror: () => resolve({ code: 500, msg: "网络错误" }) }); }); } // 验证码登录 loginBySms(phone, smsCode) { return new Promise(resolve => { GM_xmlhttpRequest({ method: "POST", url: "https://backend.gululu.world/readlogin/phone", headers: { "Content-Type": "application/json", "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/", "platform": "1" }, data: JSON.stringify({ phone, smsCode }), onload: (res) => resolve(JSON.parse(res.responseText)), onerror: () => resolve({ code: 500, msg: "网络错误" }) }); }); } // 获取闲聊列表 fetchChatList(topicId, page = 1) { return new Promise((resolve) => { // 即使未登录也可以查看闲聊,所以不强制校验 token,但如果有 token 最好带上 const headers = { "Origin": "https://www.gululu.world", "Referer": "https://www.gululu.world/", "platform": "1" }; if (this.token) headers["Authorization"] = `Bearer ${this.token}`; GM_xmlhttpRequest({ method: "GET", url: `https://backend.gululu.world/chat/home-new?pageNum=${page}&pageSize=20&type=3&chatTopicId=${topicId}`, headers: headers, onload: (response) => { try { const json = JSON.parse(response.responseText); // 接口直接返回数组,或者返回 {data: []},做个兼容 if (Array.isArray(json)) resolve(json); else if (json.data && Array.isArray(json.data)) resolve(json.data); else resolve([]); } catch (e) { resolve([]); } }, onerror: () => resolve([]) }); }); } // === 闲聊相关 API === // 获取闲聊详情 (楼主) fetchChatDetail(chatId) { return new Promise((resolve) => { const headers = { "Origin": "https://www.gululu.world", "platform": "1" }; if (this.token) headers["Authorization"] = `Bearer ${this.token}`; GM_xmlhttpRequest({ method: "GET", url: `https://backend.gululu.world/chat/detail?chatId=${chatId}`, headers: headers, onload: (res) => { try { resolve(JSON.parse(res.responseText).data); } catch (e) { resolve(null); } }, onerror: () => resolve(null) }); }); } // 获取闲聊一级评论 (作为楼层) - 旧版分页接口 (保留备用) fetchChatFloors(chatId, page = 1) { return new Promise((resolve) => { const headers = { "Origin": "https://www.gululu.world", "platform": "1" }; if (this.token) headers["Authorization"] = `Bearer ${this.token}`; GM_xmlhttpRequest({ method: "GET", url: `https://backend.gululu.world/chat/comment/list?chatId=${chatId}&pageNum=${page}&pageSize=20`, headers: headers, onload: (res) => { try { resolve(JSON.parse(res.responseText).data || []); } catch (e) { resolve([]); } }, onerror: () => resolve([]) }); }); } // 获取闲聊楼层 (增量加载) - 新版接口 fetchChatFloorsChunk(chatId, startFloor, size = 20) { return new Promise((resolve) => { const headers = { "Origin": "https://www.gululu.world", "platform": "1" }; if (this.token) headers["Authorization"] = `Bearer ${this.token}`; // sort=1 表示升序,从 startFloor 开始往后取 size 条 GM_xmlhttpRequest({ method: "GET", url: `https://backend.gululu.world/chat/list-floor?chatId=${chatId}&size=${size}&floorSort=${startFloor}&sort=1`, headers: headers, onload: (res) => { try { const json = JSON.parse(res.responseText); resolve(json.chatFloorRespList || []); } catch (e) { resolve([]); } }, onerror: () => resolve([]) }); }); } // 获取闲聊二级评论 (楼中楼) fetchChatSubComments(parentId) { return new Promise((resolve) => { const headers = { "Origin": "https://www.gululu.world", "platform": "1" }; if (this.token) headers["Authorization"] = `Bearer ${this.token}`; GM_xmlhttpRequest({ method: "GET", url: `https://backend.gululu.world/chat/comment/children/list?parentId=${parentId}`, headers: headers, onload: (res) => { try { resolve(JSON.parse(res.responseText).data || []); } catch (e) { resolve([]); } }, onerror: () => resolve([]) }); }); } // 获取闲聊子评论 (楼中楼) fetchChatSubComments(chatId, floorId) { return new Promise((resolve) => { const headers = { "Origin": "https://www.gululu.world", "platform": "1" }; if (this.token) headers["Authorization"] = `Bearer ${this.token}`; GM_xmlhttpRequest({ method: "GET", url: `https://backend.gululu.world/comment-chat/list?chatId=${chatId}&floor=${floorId}&type=1`, headers: headers, onload: (res) => { try { const json = JSON.parse(res.responseText); resolve(json.readCommentList || []); } catch (e) { resolve([]); } }, onerror: () => resolve([]) }); }); } // 发送闲聊评论 (回复层主 或 回复评论) sendChatComment(chatId, content, floorId, parentId = null, replyUserId = null) { return new Promise((resolve) => { if (!this.token) return resolve({code: 401, msg: "请先登录"}); const data = { chatId, content, floor: floorId }; if (parentId) { data.parentId = parentId; data.paragraphId = 0; // 似乎是固定参数 } if (replyUserId) { data.replyUserId = replyUserId; } GM_xmlhttpRequest({ method: "POST", url: "https://backend.gululu.world/comment-chat/reply", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json", "Origin": "https://www.gululu.world", "platform": "1" }, data: JSON.stringify(data), onload: (res) => { try { resolve(JSON.parse(res.responseText)); } catch (e) { resolve({code: 500, msg: "解析错误"}); } }, onerror: () => resolve({code: 500, msg: "网络错误"}) }); }); } // 发送闲聊回帖 (盖楼) sendChatFloor(chatId, content) { return new Promise((resolve) => { if (!this.token) return resolve({code: 401, msg: "请先登录"}); // 构造 HTML 内容 (简单包裹 p 标签) const htmlContent = `

${content.replace(/\n/g, '
')}

`; GM_xmlhttpRequest({ method: "POST", url: "https://backend.gululu.world/chat/add-floor", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json", "Origin": "https://www.gululu.world", "platform": "1" }, data: JSON.stringify({ chatId: chatId, chatTitle: "", chatText: htmlContent }), onload: (res) => { try { resolve(JSON.parse(res.responseText)); } catch (e) { resolve({code: 500, msg: "解析错误"}); } }, onerror: () => resolve({code: 500, msg: "网络错误"}) }); }); } // 闲聊收藏 toggleChatCollect(chatId, isCollect) { return new Promise((resolve) => { if (!this.token) return resolve(false); GM_xmlhttpRequest({ method: "POST", url: "https://backend.gululu.world/chat/collect", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json", "Origin": "https://www.gululu.world", "platform": "1" }, data: JSON.stringify({ chatId, isCollect: isCollect ? 1 : 0 }), onload: (res) => { try { resolve(JSON.parse(res.responseText).code === 200); } catch (e) { resolve(false); } }, onerror: () => resolve(false) }); }); } // 闲聊点赞 toggleChatLike(chatId, isGood) { return new Promise((resolve) => { if (!this.token) return resolve(false); GM_xmlhttpRequest({ method: "POST", url: "https://backend.gululu.world/chat/great", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json", "Origin": "https://www.gululu.world", "platform": "1" }, data: JSON.stringify({ chatId, isGood: isGood ? 1 : 0 }), onload: (res) => { try { resolve(JSON.parse(res.responseText).code === 200); } catch (e) { resolve(false); } }, onerror: () => resolve(false) }); }); } } // ========================================== // 3. UI (阅读页) // ========================================== class MobileUI { constructor(client) { this.client = client; this.root = null; this.container = null; this.parser = new DOMParser(); this.currentBgUrl = null; this.bgTimer = null; this.ticking = false; this.isJumping = false; // 跳转锁 this.renderGeneration = 0; // 渲染代数,用于废弃过期的请求 } init(initialFloors) { this.initBgLayer(); this.initCommentDrawer(); this.initFloatingBtn(); this.client.fetchUserInfo(); // 替换原有的 scrapeUserInfo this.root = document.createElement('div'); this.root.id = 'g-mobile-app'; this.root.innerHTML = `
${this.client.bookName}
正在加载上一页...
正在加载下一页...
`; document.body.appendChild(this.root); this.container = this.root.querySelector('#gm-list-container'); this.renderFloors(initialFloors, 'append'); this.bindEvents(); this.initImageObserver(); this.checkReadProgress(); } checkReadProgress() { // 如果有阅读进度,且进度不在当前加载的范围内(比如刚打开是在第一页,但进度在第100页) if (this.client.readProgress > 20 && !this.client.floorMap.has(this.client.readProgress)) { // 简单的 Confirm 提示,或者可以用自定义 Modal 优化体验 if(confirm(`检测到上次阅读到 ${this.client.readProgress} 层,是否跳转?`)) { this.jumpToFloor(this.client.readProgress); } } } initBgLayer() { if (!document.getElementById('gm-bg-layer')) { const layer = document.createElement('div'); layer.id = 'gm-bg-layer'; document.body.appendChild(layer); const backdrop = document.createElement('div'); backdrop.id = 'gm-bg-backdrop'; document.body.appendChild(backdrop); } } renderFloors(floorsData, direction) { if (!floorsData || floorsData.length === 0) return; floorsData.sort((a, b) => a.floorNum - b.floorNum); const fragment = document.createDocumentFragment(); const pidsToFetch = []; floorsData.forEach(floor => { if (this.client.loadedFloors.has(floor.floorNum)) return; this.client.loadedFloors.add(floor.floorNum); this.client.minLoaded = Math.min(this.client.minLoaded, floor.floorNum); this.client.maxLoaded = Math.max(this.client.maxLoaded, floor.floorNum); const el = document.createElement('div'); el.className = 'gm-floor'; el.dataset.floorNum = floor.floorNum; let rawHtml = ''; if (floor.paragraphContents) { floor.paragraphContents.forEach(p => { if (p.id) pidsToFetch.push(p.id); }); rawHtml = floor.paragraphContents.map(p => this.jsonToHtml(p)).join(''); } const processedHtml = this.processContent(rawHtml); let author = floor.author; if (!author && floor.authorId === this.client.bookAuthor?.userId) { author = this.client.bookAuthor; } const avatarUrl = author?.headPic || ''; const authorName = author?.nickName || 'Unknown'; const time = floor.createTime || ''; const realFloorId = this.client.floorMap.get(floor.floorNum) || floor.floorId || floor .id; el.innerHTML = `
${avatarUrl ? `` : ''}
${authorName}
${time}
#${floor.floorNum}
${processedHtml}
`; fragment.appendChild(el); }); if (direction === 'append') { this.container.appendChild(fragment); } else { const scrollArea = this.root.querySelector('#gm-scroll-area'); // 1. 临时禁用平滑滚动,确保瞬间修正位置,防止视觉抖动 const originalBehavior = scrollArea.style.scrollBehavior; scrollArea.style.scrollBehavior = 'auto'; // 2. 关键:在插入前记录旧的高度和位置 const oldHeight = scrollArea.scrollHeight; const oldTop = scrollArea.scrollTop; // 3. 插入新内容 this.container.insertBefore(fragment, this.container.firstChild); // 4. 修正滚动条位置:新位置 = 旧位置 + (新高度 - 旧高度) // 这样可以保持视口相对于旧内容的相对位置不变,视觉上就像"静止"一样 const newHeight = scrollArea.scrollHeight; scrollArea.scrollTop = oldTop + (newHeight - oldHeight); // 5. 恢复平滑滚动 scrollArea.style.scrollBehavior = originalBehavior; } this.updateBackground(); if (pidsToFetch.length > 0) { this.client.fetchCommentCounts(pidsToFetch).then(counts => { Object.entries(counts).forEach(([pid, count]) => { if (parseInt(count) > 0) { const btn = this.root.querySelector( `.gm-comment-btn[data-pid="${pid}"]`); if (btn) { const numEl = btn.querySelector('.gm-comment-num'); if (numEl) numEl.textContent = count; btn.classList.add('has-comment'); } } }); }); } } jsonToHtml(p) { if (p.type === 'image') return ``; if (p.type === 'paragraph' || p.type === 'heading') { const tag = p.type === 'heading' ? `h${p.attrs.level || 1}` : 'p'; let contentHtml = ''; let fullText = ''; if (p.content && p.content.length > 0) { p.content.forEach(span => { if (span.type === 'text') { let style = ''; let text = span.text; let isTransparent = false; if (span.marks) { span.marks.forEach(m => { if (m.type === 'bold') style += 'font-weight:bold;'; if (m.type === 'textStyle' && m.attrs.color) { // 检测透明色 if (m.attrs.color === 'transparent' || m.attrs.color.replace(/\s/g,'') === 'rgba(0,0,0,0)') { isTransparent = true; } // 过滤纯黑色,使其跟随主题色 const c = m.attrs.color.replace(/\s/g, ''); if (c !== 'rgb(0,0,0)' && c !== '#000000' && c !== '#000') { style += `color:${m.attrs.color};`; } } }); } // 只有非透明文本才计入有效内容 (用于判断是否显示评论气泡) if (!isTransparent) { fullText += text; } text = text.replace(/&/g, "&").replace(//g, ">"); contentHtml += style ? `${text}` : text; } else if (span.type === 'hardBreak') contentHtml += '
'; }); } // 隐藏空行:如果内容为空(且不是超级空行),则不渲染 // 原生界面行为:空段落高度为0或不显示,我们之前强制加了
导致空行显现,现在去掉 if (!contentHtml) return ''; let pHtml = `<${tag} data-pid="${p.id || ''}">${contentHtml}`; const hasMeaningfulContent = /[a-zA-Z0-9\u4e00-\u9fa5]/.test(fullText); if (p.id && hasMeaningfulContent) { pHtml += ` `; } pHtml += ``; return pHtml; } if (p.type === 'jumpFloorComponent') { const text = p.attrs.description || '跳转到指定楼层'; const targetFloor = p.attrs.floorNumber; return ``; } return ''; } processContent(htmlStr) { const doc = this.parser.parseFromString(htmlStr, 'text/html'); const selectors = ['div[class*="RichTextParagraph_menuBtn"]', 'span[class*="RichTextParagraph_inlineCommentNumber"]' ]; selectors.forEach(s => doc.querySelectorAll(s).forEach(el => el.remove())); doc.querySelectorAll('img').forEach(img => { img.removeAttribute('resize'); img.style = ""; if (!img.src && img.dataset.src) img.src = img.dataset.src; }); // 净化硬编码的黑色字体 & 智能提亮暗色字体 doc.querySelectorAll('[style*="color"]').forEach(el => { const color = el.style.color; // 1. 移除纯黑 if (color === 'rgb(0, 0, 0)' || color === '#000000' || color === 'black') { el.style.color = ''; return; } // 2. 提亮暗色 (RGB检测) const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (match) { let r = parseInt(match[1]), g = parseInt(match[2]), b = parseInt(match[3]); const maxVal = Math.max(r, g, b); // 如果最大亮度小于 110 (且不是纯黑),则等比提亮到 120 以上 // 这样在深色背景下可见,在浅色背景下也不会太浅 if (maxVal < 110 && maxVal > 0) { const scale = 120 / maxVal; r = Math.min(255, Math.round(r * scale)); g = Math.min(255, Math.round(g * scale)); b = Math.min(255, Math.round(b * scale)); el.style.color = `rgb(${r}, ${g}, ${b})`; } } }); // --- 引用解析 (移植自主脚本) --- // 1. 块级引用 (跨节点检测) const children = Array.from(doc.body.children); for (let i = 0; i < children.length; i++) { const node = children[i]; if (!node.parentNode) continue; const text = node.textContent || ""; const startMatch = text.match(/(?:<|<)引用\s+id="(\d+)"\s+floor="(\d+)"(?:>|>)/); if (startMatch) { const bid = startMatch[1]; const fid = startMatch[2]; const next1 = node.nextElementSibling; const next2 = next1 ? next1.nextElementSibling : null; if (next1 && next2) { const endText = next2.textContent || ""; if (endText.includes('') || endText.includes('</引用>')) { const contentHtml = next1.innerHTML; const quoteDiv = document.createElement('div'); quoteDiv.className = 'g-quote-box'; quoteDiv.dataset.bid = bid; quoteDiv.dataset.fid = fid; quoteDiv.innerHTML = `
${contentHtml}
跳转至 #${fid}
`; node.replaceWith(quoteDiv); next1.remove(); next2.remove(); continue; } } } } // 2. 内联引用 (正则替换) let html = doc.body.innerHTML; const inlineRegex = /(?:<|<)引用\s+id="(\d+)"\s+floor="(\d+)"(?:>|>)([\s\S]*?)(?:<|<)\/引用(?:>|>)/gi; if (html.match(inlineRegex)) { html = html.replace(inlineRegex, (match, bid, fid, content) => { return `
${content}
跳转至 #${fid}
`; }); doc.body.innerHTML = html; } doc.querySelectorAll('[data-bg-url]').forEach(el => el.classList.add('gm-bg-trigger')); doc.querySelectorAll('[data-bg-clear]').forEach(el => { el.classList.add('gm-bg-trigger'); el.setAttribute('data-bg-url', 'CLEAR'); }); const walker = document.createTreeWalker(doc.body, NodeFilter.SHOW_ALL, null, false); let currentNode, pendingBgUrl = null; while (currentNode = walker.nextNode()) { if (currentNode.nodeType === Node.TEXT_NODE) { const text = currentNode.textContent; const musicMatch = text.match(/(?:<|<)music:(.*?)(?:>|>)/i); if (musicMatch && musicMatch[1]) { const url = musicMatch[1].trim(); const span = document.createElement('span'); span.className = 'g-music-key'; span.dataset.url = url; span.textContent = ' 点击播放'; if (currentNode.parentNode) currentNode.parentNode.replaceChild(span, currentNode); continue; } const inlineMatch = text.match(/(?:<|<)背景(?:>|>)(?:[\s\S]*?src="([^"]+)")?/i); if (inlineMatch && inlineMatch[1]) { const p = currentNode.parentElement; if (p) { p.classList.add('gm-bg-trigger'); p.setAttribute('data-bg-url', inlineMatch[1]); } } else if (/(?:<|<)背景(?:>|>)/.test(text)) pendingBgUrl = 'WAITING_FOR_IMG'; else if (/(?:<|<)(?:移除背景|清除背景)(?:>|>)/.test(text)) { const p = currentNode.parentElement; if (p) { p.classList.add('gm-bg-trigger'); p.setAttribute('data-bg-url', 'CLEAR'); } pendingBgUrl = null; } } else if (currentNode.nodeType === Node.ELEMENT_NODE && currentNode.tagName === 'IMG') { if (pendingBgUrl === 'WAITING_FOR_IMG') { currentNode.classList.add('gm-bg-trigger'); const src = currentNode.getAttribute('src') || currentNode.getAttribute('data-src'); if (src) currentNode.setAttribute('data-bg-url', src); pendingBgUrl = null; } } } return doc.body.innerHTML; } initImageObserver() { const container = this.root.querySelector('#gm-list-container'); const observer = new MutationObserver((mutations) => { let needUpdate = false; mutations.forEach(m => { if (m.type === 'attributes' && m.attributeName === 'src') { const img = m.target; if (img.classList.contains('gm-bg-trigger') && img.src) { img.setAttribute('data-bg-url', img.src); needUpdate = true; } } if (m.type === 'childList') needUpdate = true; }); if (needUpdate) this.updateBackground(); }); observer.observe(container, { attributes: true, childList: true, subtree: true, attributeFilter: ['src'] }); } initFloatingBtn() { const btn = document.createElement('div'); btn.id = 'gm-comment-toggle'; btn.innerText = '💬'; document.body.appendChild(btn); btn.onclick = () => { const isActive = this.root.classList.contains('g-show-comments'); if (isActive) { this.root.classList.remove('g-show-comments'); btn.classList.remove('active'); } else { this.root.classList.add('g-show-comments'); btn.classList.add('active'); } }; // 1.5 返回首页 const homeBtn = document.createElement('div'); homeBtn.id = 'gm-home-btn'; homeBtn.innerText = '🏠'; document.body.appendChild(homeBtn); // 注入模态框 HTML if (!document.getElementById('gm-home-modal')) { const modalHtml = `
返回首页
确定要退出阅读并返回首页吗?
取消
确定
`; const div = document.createElement('div'); div.innerHTML = modalHtml; document.body.appendChild(div.firstElementChild); } const modal = document.getElementById('gm-home-modal'); homeBtn.onclick = () => modal.classList.add('active'); modal.querySelector('.gm-btn-cancel').onclick = () => modal.classList.remove('active'); modal.querySelector('.gm-btn-confirm').onclick = () => { window.location.href = '/'; }; // 2. 快捷菜单 (移植主脚本功能) const menuBtn = document.createElement('div'); menuBtn.id = 'gm-menu-toggle'; menuBtn.innerText = '⚙️'; document.body.appendChild(menuBtn); const menuList = document.createElement('div'); menuList.className = 'gm-menu-list'; const scalePercent = Math.round(config.mobileScale * 100); menuList.innerHTML = `
UI缩放: ${scalePercent}% 📐
全屏模式
停止音乐 ⏹️
十连骰子 🎲
配置面板 🔧
退出手机模式 🚪
`; document.body.appendChild(menuList); menuBtn.onclick = () => { const isActive = menuList.classList.contains('active'); if (isActive) { menuList.classList.remove('active'); menuBtn.classList.remove('active'); menuBtn.innerText = '⚙️'; } else { menuList.classList.add('active'); menuBtn.classList.add('active'); menuBtn.innerText = '✕'; } }; const callHelper = (method) => { const targetWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; // 增加重试机制 (最多等待 3 秒) let attempts = 15; const tryExecute = () => { if (targetWindow.GululuHelper && targetWindow.GululuHelper[method]) { targetWindow.GululuHelper[method](); menuList.classList.remove('active'); menuBtn.classList.remove('active'); menuBtn.innerText = '⚙️'; } else { if (attempts > 0) { attempts--; setTimeout(tryExecute, 200); } else { console.error("GululuHelper not found on", targetWindow); alert("主脚本接口未就绪或加载过慢!\n请确保“Gululu沉浸助手主文件”已启用。"); } } }; tryExecute(); }; menuList.querySelector('#gm-act-stop').onclick = () => callHelper('stopMusic'); menuList.querySelector('#gm-act-dice').onclick = () => callHelper('revealNext10'); menuList.querySelector('#gm-act-setting').onclick = () => callHelper('toggleSettings'); // 全屏切换 menuList.querySelector('#gm-act-fullscreen').onclick = () => { if (!document.fullscreenElement) { sessionStorage.setItem('gm_is_fullscreen', '1'); // 标记意图 document.documentElement.requestFullscreen().catch(e => alert("全屏失败: " + e.message)); } else { sessionStorage.removeItem('gm_is_fullscreen'); // 清除意图 if (document.exitFullscreen) document.exitFullscreen(); } menuList.classList.remove('active'); menuBtn.classList.remove('active'); menuBtn.innerText = '⚙️'; }; // 退出逻辑 // 1. 注入退出确认框 if (!document.getElementById('gm-exit-modal')) { const modalHtml = `
退出手机模式
确定要切换回桌面版网页吗?
取消
退出
`; const div = document.createElement('div'); div.innerHTML = modalHtml; document.body.appendChild(div.firstElementChild); } const exitModal = document.getElementById('gm-exit-modal'); menuList.querySelector('#gm-act-exit').onclick = () => { menuList.classList.remove('active'); menuBtn.classList.remove('active'); menuBtn.innerText = '⚙️'; exitModal.classList.add('active'); }; exitModal.querySelector('.gm-btn-cancel').onclick = () => exitModal.classList.remove('active'); exitModal.querySelector('.gm-btn-confirm').onclick = () => { config.mobileModeEnabled = false; localStorage.setItem('gululu_global_config_v6', JSON.stringify(config)); location.reload(); }; // 缩放逻辑 menuList.querySelector('#gm-act-scale').onclick = (e) => { const scales = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5]; let idx = scales.indexOf(config.mobileScale); idx = (idx + 1) % scales.length; config.mobileScale = scales[idx]; // 应用 document.documentElement.style.setProperty('--gm-scale', config.mobileScale); localStorage.setItem('gululu_global_config_v6', JSON.stringify(config)); // 更新按钮文本 const item = e.currentTarget; item.innerHTML = `UI缩放: ${config.mobileScale * 100}% 📐`; }; } initCommentDrawer() { const drawer = document.createElement('div'); drawer.id = 'gm-cmt-drawer'; drawer.innerHTML = `
评论
加载中...
`; document.body.appendChild(drawer); const mask = document.createElement('div'); mask.id = 'gm-cmt-mask'; document.body.appendChild(mask); // 状态变量 this.replyTarget = null; // { cid, rootId, uname } const close = () => { drawer.classList.remove('active'); mask.classList.remove('active'); document.getElementById('gm-cmt-input').blur(); this.cancelReplyMode(); }; mask.onclick = close; drawer.querySelector('#gm-cmt-close').onclick = close; // 事件委托:点赞、回复、展开子评论 const cmtList = document.getElementById('gm-cmt-list'); cmtList.addEventListener('click', async (e) => { // 1. 点赞 const likeBtn = e.target.closest('.gm-cmt-like-btn'); if (likeBtn) { const cid = likeBtn.dataset.cid; if (!cid) return; if (likeBtn.dataset.liked === "true" || likeBtn.classList.contains('liked')) return; if (likeBtn.dataset.liking) return; likeBtn.dataset.liking = "true"; likeBtn.classList.add('liked'); const success = await this.client.likeComment(cid); if (success) { const match = likeBtn.innerText.match(/\d+/); if (match) likeBtn.innerHTML = `♥ ${parseInt(match[0]) + 1}`; likeBtn.dataset.liked = "true"; } else { likeBtn.classList.remove('liked'); } delete likeBtn.dataset.liking; } // 2. 回复按钮 const replyBtn = e.target.closest('.gm-cmt-reply-btn'); if (replyBtn) { const cid = replyBtn.dataset.cid; const uname = replyBtn.dataset.uname; const rootId = replyBtn.dataset.root; this.enterReplyMode(cid, uname, rootId); } // 3. 展开/收起子评论 const expandBtn = e.target.closest('.gm-sub-expand-btn'); if (expandBtn) { const cid = expandBtn.dataset.cid; const action = expandBtn.dataset.action; // 'collapse' or undefined (expand) const subList = document.getElementById(`gm-sub-list-${cid}`); if (action === 'collapse') { subList.classList.remove('active'); // 既然收起了,我们需要在外面生成一个"展开"按钮,如果之前没有的话 let outerBtn = subList.parentNode.querySelector(`.gm-sub-expand-btn[data-action="expand"]`); if (!outerBtn) { outerBtn = document.createElement('div'); outerBtn.className = 'gm-sub-expand-btn'; outerBtn.dataset.cid = cid; outerBtn.dataset.action = 'expand'; outerBtn.innerText = '查看回复 ▾'; subList.parentNode.appendChild(outerBtn); } outerBtn.style.display = 'block'; } else { // 展开逻辑 if (subList.children.length > 0 && !subList.dataset.autoload) { // 已经加载过,直接显示 subList.classList.add('active'); expandBtn.style.display = 'none'; } else { // 需要加载 expandBtn.innerText = "加载中..."; const children = await this.client.fetchChildComments(cid); if (children && children.length > 0) { subList.innerHTML = children.map(c => this.renderSubCommentItem(c, cid)).join(''); subList.insertAdjacentHTML('beforeend', `
收起回复
`); subList.classList.add('active'); expandBtn.style.display = 'none'; } else { expandBtn.innerText = "暂无更多回复"; } } } } }); // 取消回复模式 document.getElementById('gm-reply-cancel-btn').onclick = () => this.cancelReplyMode(); const sendBtn = document.getElementById('gm-cmt-send'); const input = document.getElementById('gm-cmt-input'); // 自适应高度 input.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = (this.scrollHeight) + 'px'; }); sendBtn.onclick = async () => { const content = input.value.trim(); if (!content) return; sendBtn.innerText = '...'; // 分支:回复评论 vs 发送新评论 if (this.replyTarget) { // === 回复模式 === const res = await this.client.replyComment(content, this.replyTarget.cid); sendBtn.innerText = '发送'; if (res && res.code === 200) { // 构造假数据插入 const fakeSub = { id: Date.now(), // 临时ID content: content, createTime: "刚刚", fromUser: this.client.currentUser, replyUser: { nickName: this.replyTarget.uname } }; const rootId = this.replyTarget.rootId; const subList = document.getElementById(`gm-sub-list-${rootId}`); // 确保子评论容器可见 subList.classList.add('active'); subList.insertAdjacentHTML('beforeend', this.renderSubCommentItem(fakeSub, rootId)); input.value = ''; input.style.height = 'auto'; // 重置高度 this.cancelReplyMode(); } else { alert('回复失败: ' + (res ? res.msg : '网络错误')); } } else { // === 普通评论模式 === if (!this.activeCommentTarget) return; const { floorId, pid } = this.activeCommentTarget; const res = await this.client.sendComment(floorId, pid, content); sendBtn.innerText = '发送'; if (res && res.code === 200) { const fakeComment = { content: content, createTime: "刚刚", likeCount: 0, fromUser: this.client.currentUser }; const list = document.getElementById('gm-cmt-list'); if (list.querySelector('div[style*="text-align:center"]')) list.innerHTML = ''; list.insertAdjacentHTML('beforeend', this.renderCommentItem(fakeComment)); list.scrollTop = list.scrollHeight; input.value = ''; input.style.height = 'auto'; // 重置高度 // 更新外部计数 if (pid) { const btn = this.root.querySelector(`.gm-comment-btn[data-pid="${pid}"]`); if (btn) { const numEl = btn.querySelector('.gm-comment-num'); if (numEl) numEl.textContent = (parseInt(numEl.textContent) || 0) + 1; btn.classList.add('has-comment'); } } else { const btn = this.root.querySelector(`.gm-floor-cmt-btn[data-fid="${floorId}"]`); if (btn) { const match = btn.textContent.match(/(\d+)/); const count = match ? parseInt(match[1]) : 0; btn.textContent = `💬 ${count + 1}`; } } } else { alert('发送失败: ' + (res ? res.msg : '网络错误')); } } }; } enterReplyMode(cid, uname, rootId) { this.replyTarget = { cid, uname, rootId }; const cancelBtn = document.getElementById('gm-reply-cancel-btn'); const input = document.getElementById('gm-cmt-input'); if(cancelBtn) cancelBtn.style.display = 'flex'; input.placeholder = `回复 @${uname}...`; input.focus(); } cancelReplyMode() { this.replyTarget = null; const cancelBtn = document.getElementById('gm-reply-cancel-btn'); const input = document.getElementById('gm-cmt-input'); if(cancelBtn) cancelBtn.style.display = 'none'; if(input) input.placeholder = "说点什么..."; } renderCommentItem(c) { const user = c.fromUser || c.userInfo || c.user || {}; const headPic = user.headPic || user.avatar || c.headPic || 'https://www.gululu.world/favicon.ico'; const nickName = user.nickName || user.nickname || c.nickName || c.nickname || '未知用户'; const likeCount = c.likeNum || c.likeCount || 0; const cid = c.id; const isLiked = c.liked === true; const childrenNum = c.childrenNum || 0; // 默认展开逻辑:如果有子评论,直接显示加载占位符,不显示展开按钮 const subHtml = childrenNum > 0 ? `
↻ 正在加载 ${childrenNum} 条回复...
` : `
`; return `
${nickName}
${c.content}
♥ ${likeCount}
${c.createTime}
回复
${subHtml}
`; } renderSubCommentItem(c, rootId) { const user = c.fromUser || {}; const nickName = user.nickName || '未知用户'; const replyUser = c.replyUser ? `回复 @${c.replyUser.nickName}` : ''; return `
${nickName} ${replyUser}
${c.content}
${c.createTime}
回复
`; } async jumpToFloor(targetNum) { targetNum = parseInt(targetNum); if (!this.client.floorMap.has(targetNum)) { alert("目标楼层不存在或未加载目录"); return; } // 1. 上锁并升级代数 (废弃所有正在进行的 loadMore 请求) this.isJumping = true; this.renderGeneration++; this.client.isLoading = true; // 抢占加载锁 // 2. 立即重置状态 (防止滚动事件读取旧数据) this.client.loadedFloors.clear(); this.client.minLoaded = Infinity; this.client.maxLoaded = -Infinity; // 3. 视觉反馈 & 滚动归零 this.container.innerHTML = '
正在穿越... 🚀
'; const scrollArea = this.root.querySelector('#gm-scroll-area'); scrollArea.scrollTop = 0; const idsToLoad = []; // 加载目标层及后续9层(填满屏幕) for (let i = 0; i < 10; i++) { if (this.client.floorMap.has(targetNum + i)) idsToLoad.push(this.client.floorMap.get(targetNum + i)); } const data = await this.client.fetchFloors(idsToLoad); // 4. 渲染新数据 if (data) { this.container.innerHTML = ''; this.renderFloors(data, 'append'); scrollArea.scrollTop = 0; } else { this.container.innerHTML = '
加载失败,请重试
'; } // 5. 解锁 this.isJumping = false; this.client.isLoading = false; } async showComments(floorId, pid) { this.activeCommentTarget = { floorId, pid }; const drawer = document.getElementById('gm-cmt-drawer'); const mask = document.getElementById('gm-cmt-mask'); const list = document.getElementById('gm-cmt-list'); drawer.classList.add('active'); mask.classList.add('active'); list.innerHTML = '
加载中...
'; const comments = await this.client.fetchComments(floorId, pid); if (!comments || comments.length === 0) { list.innerHTML = '
暂无评论
'; return; } list.innerHTML = comments.map(c => this.renderCommentItem(c)).join(''); // 自动加载子评论 list.querySelectorAll('.gm-sub-cmt-list[data-autoload="true"]').forEach(async (el) => { const cid = el.dataset.cid; const children = await this.client.fetchChildComments(cid); if (children && children.length > 0) { el.innerHTML = children.map(c => this.renderSubCommentItem(c, cid)).join(''); // 加载完成后添加收起按钮,并移除 autoload 标记防止重复 el.insertAdjacentHTML('beforeend', `
收起回复 ▴
`); delete el.dataset.autoload; } else { el.innerHTML = ''; el.classList.remove('active'); } }); } applyBackground(url) { if (this.currentBgUrl === url) return; if (!url) return; if (this.bgTimer) clearTimeout(this.bgTimer); this.currentBgUrl = url; const layer = document.getElementById('gm-bg-layer'); if (!layer) return; layer.style.opacity = '0'; this.bgTimer = setTimeout(() => { if (url && url !== 'CLEAR') { const img = new Image(); img.src = url; img.onload = () => { if (this.currentBgUrl === url) { layer.style.backgroundImage = `url("${url}")`; layer.style.opacity = '1'; } }; } else { layer.style.backgroundImage = 'none'; } }, 500); } showToast(msg) { let toast = document.getElementById('gm-toast-msg'); if (!toast) { toast = document.createElement('div'); toast.id = 'gm-toast-msg'; toast.className = 'gm-toast'; document.body.appendChild(toast); } toast.innerText = msg; toast.classList.add('active'); if (this.toastTimer) clearTimeout(this.toastTimer); this.toastTimer = setTimeout(() => { toast.classList.remove('active'); }, 2000); } updateBackground() { const triggers = this.root.querySelectorAll('.gm-bg-trigger, .gm-floor[data-bg]'); if (triggers.length === 0) return; const checkLine = window.innerHeight / 2; let activeBg = null; for (let i = 0; i < triggers.length; i++) { const el = triggers[i]; // ⚡ 修复:跳过被隐藏的元素(例如迷雾锁定的图片),防止它们抢占背景 if (el.offsetParent === null) continue; const rect = el.getBoundingClientRect(); if (rect.top < checkLine) { let url = el.getAttribute('data-bg-url') || el.getAttribute('data-bg'); if (url && url.length > 4) activeBg = url; } else { break; } } this.applyBackground(activeBg); } // 检测当前阅读楼层并保存 checkActiveFloor() { // 我们取屏幕上方 1/3 处作为阅读焦点线 const focusLine = window.innerHeight / 3; const floors = this.root.querySelectorAll('.gm-floor'); // 倒序查找,找到第一个在该线之上的楼层(或者正好覆盖该线的) // 由于是顺序排列,只要找到 rect.bottom > focusLine 的第一个元素即可 for (let i = 0; i < floors.length; i++) { const floor = floors[i]; const rect = floor.getBoundingClientRect(); // 如果楼层底部超过了焦点线,说明它正在被阅读(或者它刚开始) if (rect.bottom > focusLine) { const num = parseInt(floor.dataset.floorNum); if (num && !isNaN(num)) { this.client.saveHistory(num); } break; // 找到即止 } } } bindEvents() { const scrollArea = this.root.querySelector('#gm-scroll-area'); const topLoader = this.root.querySelector('#gm-loader-top'); const bottomLoader = this.root.querySelector('#gm-loader-bottom'); // 收藏按钮 const collectBtn = this.root.querySelector('#gm-collect'); collectBtn.onclick = () => { if (!this.client.token) return this.showToast("请先登录"); // 1. 注入收藏确认框 (如果不存在) if (!document.getElementById('gm-collect-modal')) { const modalHtml = `
收藏提示
取消
确定
`; const div = document.createElement('div'); div.innerHTML = modalHtml; document.body.appendChild(div.firstElementChild); } const modal = document.getElementById('gm-collect-modal'); const title = modal.querySelector('.gm-modal-title'); const desc = modal.querySelector('.gm-modal-desc'); const confirmBtn = modal.querySelector('.gm-btn-confirm'); const cancelBtn = modal.querySelector('.gm-btn-cancel'); const isCollected = this.client.isCollected; title.innerText = isCollected ? "取消收藏" : "加入书架"; desc.innerText = isCollected ? "确定要将本书移出书架吗?" : "确定将本书加入书架吗?"; modal.classList.add('active'); // 绑定事件 (先解绑防止重复) const close = () => modal.classList.remove('active'); cancelBtn.onclick = close; // 重新克隆按钮以去除旧监听器 const newConfirm = confirmBtn.cloneNode(true); confirmBtn.parentNode.replaceChild(newConfirm, confirmBtn); newConfirm.onclick = async () => { close(); const newState = !isCollected; const success = await this.client.toggleCollect(newState); if (success) { collectBtn.style.color = newState ? '#0984e3' : '#b2bec3'; this.showToast(newState ? "收藏成功" : "已取消收藏"); } else { this.showToast("操作失败"); } }; }; // === 跳页功能 === // 1. 注入模态框 if (!document.getElementById('gm-jump-modal')) { const modalHtml = `
跳转楼层
请输入目标楼层号 (1 - ${this.client.totalFloors || '?'})
取消
跳转
`; const div = document.createElement('div'); div.innerHTML = modalHtml; document.body.appendChild(div.firstElementChild); } const jumpBtn = this.root.querySelector('#gm-jump-toggle'); const jumpModal = document.getElementById('gm-jump-modal'); const jumpInput = document.getElementById('gm-jump-input'); // 打开模态框 jumpBtn.onclick = () => { jumpModal.querySelector('.gm-modal-desc').innerText = `请输入目标楼层号 (1 - ${this.client.totalFloors || '未知'})`; jumpModal.classList.add('active'); jumpInput.value = ''; setTimeout(() => jumpInput.focus(), 100); }; // 取消 jumpModal.querySelector('.gm-btn-cancel').onclick = () => jumpModal.classList.remove('active'); // 确认跳转 const doJump = () => { const val = parseInt(jumpInput.value); if (!val || val < 1) return; jumpModal.classList.remove('active'); this.jumpToFloor(val); }; jumpModal.querySelector('.gm-btn-confirm').onclick = doJump; jumpInput.onkeydown = (e) => { if(e.key === 'Enter') doJump(); }; let loadTimer = null; const handleScroll = () => { // ⚡ 如果正在跳转中,忽略一切滚动事件,防止触发旧楼层的加载逻辑 if (this.isJumping) return; if (!this.ticking) { window.requestAnimationFrame(() => { this.updateBackground(); this.ticking = false; }); this.ticking = true; } if (loadTimer) clearTimeout(loadTimer); loadTimer = setTimeout(() => { // 1. 记录阅读进度 (500ms 延迟保存,即停止滚动后半秒保存) this.checkActiveFloor(); if (this.client.isLoading) return; const scrollTop = scrollArea.scrollTop; const scrollHeight = scrollArea.scrollHeight; const clientHeight = scrollArea.clientHeight; if (scrollTop < 200) this.loadMore('prev', topLoader); else if (scrollTop + clientHeight >= scrollHeight - 800) this.loadMore('next', bottomLoader); }, 500); // 将防抖时间从 100ms 改为 500ms,对性能更好,也符合阅读习惯 }; scrollArea.addEventListener('scroll', handleScroll, { passive: true }); this.root.addEventListener('click', (e) => { const btn = e.target.closest('.gm-comment-btn'); if (btn) { e.stopPropagation(); const pid = btn.dataset.pid; const floorEl = btn.closest('.gm-floor'); if (floorEl && pid) { const floorNum = parseInt(floorEl.dataset.floorNum); const floorId = this.client.floorMap.get(floorNum); if (floorId) this.showComments(floorId, pid); } } if (e.target.classList.contains('gm-jump-btn')) { const target = e.target.dataset.target; if (target) this.jumpToFloor(target); } if (e.target.classList.contains('gm-floor-cmt-btn')) { const fid = e.target.dataset.fid; if (fid) this.showComments(fid, null); } // 引用跳转 const quoteBox = e.target.closest('.g-quote-box'); if (quoteBox) { const bid = quoteBox.dataset.bid; const fid = quoteBox.dataset.fid; if (bid && fid) this.showQuoteModal(bid, fid); } }); } showQuoteModal(bid, fid) { if (!document.getElementById('gm-quote-modal')) { const modalHtml = `
跳转确认
取消
跳转
`; const div = document.createElement('div'); div.innerHTML = modalHtml; document.body.appendChild(div.firstElementChild); const m = document.getElementById('gm-quote-modal'); m.querySelector('.gm-btn-cancel').onclick = () => m.classList.remove('active'); } const modal = document.getElementById('gm-quote-modal'); const desc = modal.querySelector('.gm-modal-desc'); const confirmBtn = modal.querySelector('.gm-btn-confirm'); // 检查是否为本书 const isCurrentBook = String(bid) === String(this.client.bookId); const bookText = isCurrentBook ? "当前书籍" : `Book: ${bid}`; desc.innerHTML = `即将跳转到
${bookText}
${fid} 层`; // 重新克隆按钮以去除旧监听器 const newConfirm = confirmBtn.cloneNode(true); confirmBtn.parentNode.replaceChild(newConfirm, confirmBtn); newConfirm.onclick = () => { modal.classList.remove('active'); if (isCurrentBook) { this.jumpToFloor(fid); } else { window.location.href = `/book/${bid}?floorSort=${fid}`; } }; modal.classList.add('active'); } async loadMore(direction, loaderEl) { // ⚡ 记录发起请求时的代数 const currentGen = this.renderGeneration; let targetFloorNum; if (direction === 'prev') { targetFloorNum = this.client.minLoaded - 1; if (targetFloorNum < 1) return; } else { targetFloorNum = this.client.maxLoaded + 1; if (!this.client.floorMap.has(targetFloorNum)) return; } this.client.isLoading = true; loaderEl.style.display = 'flex'; const idsToLoad = []; for (let i = 0; i < 10; i++) { let num = direction === 'prev' ? targetFloorNum - i : targetFloorNum + i; if (this.client.floorMap.has(num)) idsToLoad.push(this.client.floorMap.get(num)); } if (idsToLoad.length > 0) { console.log(`📡 [API] 请求 ${direction} 加载`); const data = await this.client.fetchFloors(idsToLoad); // ⚡ 核心检查:如果数据返回时,代数已经变了(说明中间发生了跳转),则丢弃数据 if (this.renderGeneration === currentGen) { if (data) this.renderFloors(data, direction === 'prev' ? 'prepend' : 'append'); } else { console.log("⚠️ [LoadMore] 检测到跳转发生,丢弃过期数据"); } } loaderEl.style.display = 'none'; this.client.isLoading = false; } } // ========================================== // 3.2 闲聊阅读页 UI (ChatUI) // ========================================== class ChatUI { constructor(client, chatId) { this.client = client; this.chatId = chatId; this.root = null; this.container = null; // 状态管理 this.isLoading = false; this.detail = null; // 楼主信息 this.loadedFloors = new Set(); // 已加载的楼层号 this.minLoaded = Infinity; this.maxLoaded = -Infinity; this.isJumping = false; this.renderGeneration = 0; // 方向锁:当API返回空时锁定,防止反复请求 this.hasMorePrev = true; this.hasMoreNext = true; this.totalFloors = 0; } async init(initialData = null) { // 1. 数据获取 if (initialData) { this.detail = initialData.detail; this.totalFloors = initialData.totalFloors || this.detail.maxFloor || 0; } else { this.detail = await this.client.fetchChatDetail(this.chatId); if(this.detail) this.totalFloors = this.detail.maxFloor || 0; } if (!this.detail) { alert("帖子不存在或已被删除"); window.location.href = '/'; return; } // 2. 初始化框架 this.initBgLayer(); this.renderFrame(); // 先渲染框架 this.initCommentDrawer(); // 初始化底部输入框 this.initFloatingBtn(); // 再绑定按钮事件 // 3. 渲染楼主层 (作为第1层) // 楼主层总是存在的,且固定为1楼 this.renderHostFloor(); this.loadedFloors.add(1); this.minLoaded = 1; this.maxLoaded = 1; // 4. 加载初始评论层 if (initialData && initialData.floors && initialData.floors.length > 0) { // 过滤掉楼主层(1楼),避免重复 const comments = initialData.floors.filter(f => f.floorSort > 1); if (comments.length > 0) { this.renderCommentsAsFloors(comments, 'append'); } } else { // 如果没有初始数据,尝试加载第一页 (从2楼开始) this.loadMore('next'); } } initBgLayer() { if (!document.getElementById('gm-bg-layer')) { const layer = document.createElement('div'); layer.id = 'gm-bg-layer'; document.body.appendChild(layer); const backdrop = document.createElement('div'); backdrop.id = 'gm-bg-backdrop'; document.body.appendChild(backdrop); } } renderFrame() { this.root = document.createElement('div'); this.root.id = 'g-mobile-app'; this.root.innerHTML = `
${this.detail.chatTitle}
#
正在加载上一页...
正在加载更多回复...
`; document.body.appendChild(this.root); this.container = this.root.querySelector('#gm-list-container'); // 滚动监听 (复用 MobileUI 的防抖逻辑) const scrollArea = this.root.querySelector('#gm-scroll-area'); let loadTimer = null; scrollArea.addEventListener('scroll', () => { if (this.isJumping) return; if (loadTimer) clearTimeout(loadTimer); loadTimer = setTimeout(() => { if (this.isLoading) return; const { scrollTop, scrollHeight, clientHeight } = scrollArea; // 增加方向锁检查:只有当还有更多数据时才请求 if (scrollTop < 200 && this.hasMorePrev) this.loadMore('prev'); else if (scrollTop + clientHeight >= scrollHeight - 800 && this.hasMoreNext) this.loadMore('next'); }, 200); }, { passive: true }); // 点击底部加载条手动刷新 (用于网络错误或强行刷新) const bottomLoader = this.root.querySelector('#gm-loader-bottom'); if (bottomLoader) { bottomLoader.onclick = () => { if (this.isLoading) return; // 解锁并尝试加载 this.hasMoreNext = true; bottomLoader.innerHTML = "正在加载更多回复..."; bottomLoader.classList.remove('no-spin'); this.loadMore('next'); }; } } processContent(html) { // 简单处理:提亮暗色字体 (复用逻辑) const div = document.createElement('div'); div.innerHTML = html; div.querySelectorAll('[style*="color"]').forEach(el => { const color = el.style.color; // 1. 移除纯黑 if (color === 'rgb(0, 0, 0)' || color === '#000000' || color === 'black') { el.style.color = ''; return; } // 2. 提亮暗色 (RGB检测) // 浏览器会将 hex 自动转为 rgb,所以正则只匹配 rgb 即可覆盖大部分情况 const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (match) { let r = parseInt(match[1]), g = parseInt(match[2]), b = parseInt(match[3]); const maxVal = Math.max(r, g, b); // 阈值设为 110,如果最大亮度低于此,则提亮到 120+ if (maxVal < 110 && maxVal > 0) { const scale = 120 / maxVal; r = Math.min(255, Math.round(r * scale)); g = Math.min(255, Math.round(g * scale)); b = Math.min(255, Math.round(b * scale)); el.style.color = `rgb(${r}, ${g}, ${b})`; } } }); return div.innerHTML; } renderHostFloor() { const d = this.detail; const el = document.createElement('div'); el.className = 'gm-floor'; el.dataset.floorNum = 1; // 楼主算1楼 // 内容处理 let contentHtml = d.chatText || d.content || (d.text ? d.text.replace(/\n/g, '
') : ''); contentHtml = this.processContent(contentHtml); // 如果有额外的图片数组 if (d.images && d.images.length > 0) { contentHtml += d.images.map(url => ``).join(''); } el.innerHTML = `
${d.nickName}
${d.createTime} · 楼主
#1
${contentHtml}
`; this.container.appendChild(el); } async loadMore(direction) { // 预检查:如果该方向已锁定,直接返回 if (direction === 'prev' && !this.hasMorePrev) return; if (direction === 'next' && !this.hasMoreNext) return; const currentGen = this.renderGeneration; let startFloor; const size = 20; if (direction === 'prev') { if (this.minLoaded <= 2) { this.hasMorePrev = false; // 锁定顶部 return; } startFloor = Math.max(2, this.minLoaded - size); } else { startFloor = this.maxLoaded + 1; } this.isLoading = true; const loader = this.root.querySelector(direction === 'prev' ? '#gm-loader-top' : '#gm-loader-bottom'); loader.innerHTML = direction === 'prev' ? "正在加载上一页..." : "正在加载更多回复..."; loader.classList.remove('no-spin'); loader.style.display = 'flex'; const list = await this.client.fetchChatFloorsChunk(this.chatId, startFloor, size); if (this.renderGeneration !== currentGen) return; if (list && list.length > 0) { this.renderCommentsAsFloors(list, direction === 'prev' ? 'prepend' : 'append'); // 如果返回的数据少于请求的 size,说明已经到头了,锁定方向 // (注意:这里用 < size 判断可能不准,因为可能有过滤,但作为一种优化是可以的) // 暂时不锁定,依靠下一次空请求来锁定,更稳健 loader.style.display = 'none'; } else { // 数据为空,锁定方向 if (direction === 'next') { this.hasMoreNext = false; loader.innerHTML = "— 到底了 —"; loader.classList.add('no-spin'); if(!document.getElementById('gm-nospin-css')) { const s = document.createElement('style'); s.id = 'gm-nospin-css'; s.textContent = '.gm-loader.no-spin::after { display: none !important; }'; document.head.appendChild(s); } } else { this.hasMorePrev = false; loader.style.display = 'none'; } } this.isLoading = false; } renderCommentsAsFloors(list, direction) { const fragment = document.createDocumentFragment(); // 过滤掉已加载的楼层 const newItems = list.filter(item => { if (this.loadedFloors.has(item.floorSort)) return false; this.loadedFloors.add(item.floorSort); this.minLoaded = Math.min(this.minLoaded, item.floorSort); this.maxLoaded = Math.max(this.maxLoaded, item.floorSort); return true; }); if (newItems.length === 0) return; // 确保按楼层排序 newItems.sort((a, b) => a.floorSort - b.floorSort); newItems.forEach(item => { const el = document.createElement('div'); el.className = 'gm-floor'; el.dataset.floorNum = item.floorSort; // 内容处理:优先使用 chatText (HTML) let contentHtml = item.chatText || item.content || ''; // 如果不是 HTML 格式(旧数据兼容),则转换换行 if (!item.chatText && !/<[a-z][\s\S]*>/i.test(contentHtml)) { contentHtml = contentHtml.replace(/\n/g, '
'); } // 颜色处理 contentHtml = this.processContent(contentHtml); // 额外的图片字段 (API 返回的 images 数组) if (item.images && item.images.length > 0) { contentHtml += item.images.map(url => ``).join(''); } else if (item.image) { contentHtml += ``; } el.innerHTML = `
${item.nickName}
${item.createTime}
#${item.floorSort}
${contentHtml}
`; fragment.appendChild(el); }); if (direction === 'append') { this.container.appendChild(fragment); } else { // 向上加载时的滚动锚定修复 const scrollArea = this.root.querySelector('#gm-scroll-area'); const originalBehavior = scrollArea.style.scrollBehavior; scrollArea.style.scrollBehavior = 'auto'; const oldHeight = scrollArea.scrollHeight; const oldTop = scrollArea.scrollTop; // 插入到楼主层之后 (container 的第一个子元素是楼主) // 注意:container.firstChild 是楼主,我们应该插在楼主后面吗? // 不,MobileUI 的逻辑是 prepend 到 container 最前面。 // 但 ChatUI 有个特殊的楼主层(1楼)始终在最上面。 // 所以我们应该把新加载的楼层插入到楼主层之后,或者如果加载的是 2-10 楼,它们应该在 11 楼之前。 // 简单处理:找到第一个非楼主的楼层,插在它前面 const firstCommentFloor = Array.from(this.container.children).find(c => c.dataset.floorNum > 1); if (firstCommentFloor) { this.container.insertBefore(fragment, firstCommentFloor); } else { this.container.appendChild(fragment); } const newHeight = scrollArea.scrollHeight; scrollArea.scrollTop = oldTop + (newHeight - oldHeight); scrollArea.style.scrollBehavior = originalBehavior; } } initFloatingBtn() { // === 1. 浮动菜单按钮 (右下角) === const menuBtn = document.createElement('div'); menuBtn.id = 'gm-menu-toggle'; menuBtn.innerText = '⚙️'; // 调整位置,使用 calc 适配缩放 menuBtn.style.bottom = 'calc(240px * var(--gm-scale))'; document.body.appendChild(menuBtn); const menuList = document.createElement('div'); menuList.className = 'gm-menu-list'; menuList.style.bottom = 'calc(340px * var(--gm-scale))'; // 菜单列表也要相应上移 const scalePercent = Math.round(config.mobileScale * 100); menuList.innerHTML = `
夜间模式 🌗
UI缩放: ${scalePercent}% 📐
全屏模式
退出手机模式 🚪
`; document.body.appendChild(menuList); menuBtn.onclick = () => { if (menuList.classList.contains('active')) { menuList.classList.remove('active'); menuBtn.classList.remove('active'); menuBtn.innerText = '⚙️'; } else { menuList.classList.add('active'); menuBtn.classList.add('active'); menuBtn.innerText = '✕'; } }; // 绑定菜单事件 menuList.querySelector('#gm-act-night').onclick = () => { document.body.classList.toggle('g-night-mode'); config.nightMode = document.body.classList.contains('g-night-mode'); localStorage.setItem('gululu_global_config_v6', JSON.stringify(config)); menuList.classList.remove('active'); menuBtn.classList.remove('active'); menuBtn.innerText = '⚙️'; }; menuList.querySelector('#gm-act-scale').onclick = (e) => { const scales = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5]; let idx = scales.indexOf(config.mobileScale); idx = (idx + 1) % scales.length; config.mobileScale = scales[idx]; document.documentElement.style.setProperty('--gm-scale', config.mobileScale); localStorage.setItem('gululu_global_config_v6', JSON.stringify(config)); e.currentTarget.innerHTML = `UI缩放: ${config.mobileScale * 100}% 📐`; }; menuList.querySelector('#gm-act-fullscreen').onclick = () => { if (!document.fullscreenElement) { sessionStorage.setItem('gm_is_fullscreen', '1'); // 标记意图 document.documentElement.requestFullscreen().catch(e => alert("全屏失败: " + e.message)); } else { sessionStorage.removeItem('gm_is_fullscreen'); // 清除意图 if (document.exitFullscreen) document.exitFullscreen(); } menuList.classList.remove('active'); menuBtn.classList.remove('active'); menuBtn.innerText = '⚙️'; }; // 退出确认框 (自定义 Modal) menuList.querySelector('#gm-act-exit').onclick = () => { menuList.classList.remove('active'); menuBtn.classList.remove('active'); menuBtn.innerText = '⚙️'; if (!document.getElementById('gm-exit-modal')) { const modalHtml = `
退出手机模式
确定要切换回桌面版网页吗?
取消
退出
`; const div = document.createElement('div'); div.innerHTML = modalHtml; document.body.appendChild(div.firstElementChild); document.getElementById('gm-exit-modal').querySelector('.gm-btn-cancel').onclick = () => document.getElementById('gm-exit-modal').classList.remove('active'); } const modal = document.getElementById('gm-exit-modal'); // 绑定确认事件 (防止多次绑定) const confirmBtn = modal.querySelector('.gm-btn-confirm'); const newConfirm = confirmBtn.cloneNode(true); confirmBtn.parentNode.replaceChild(newConfirm, confirmBtn); newConfirm.onclick = () => { config.mobileModeEnabled = false; localStorage.setItem('gululu_global_config_v6', JSON.stringify(config)); location.reload(); }; modal.classList.add('active'); }; // === 2. 返回首页按钮 (右下角) === const homeBtn = document.createElement('div'); homeBtn.id = 'gm-home-btn'; homeBtn.innerText = '🏠'; homeBtn.style.bottom = 'calc(140px * var(--gm-scale))'; document.body.appendChild(homeBtn); homeBtn.onclick = () => { if (!document.getElementById('gm-home-modal')) { const modalHtml = `
返回首页
确定要退出阅读并返回首页吗?
取消
确定
`; const div = document.createElement('div'); div.innerHTML = modalHtml; document.body.appendChild(div.firstElementChild); document.getElementById('gm-home-modal').querySelector('.gm-btn-cancel').onclick = () => document.getElementById('gm-home-modal').classList.remove('active'); } const modal = document.getElementById('gm-home-modal'); const confirmBtn = modal.querySelector('.gm-btn-confirm'); const newConfirm = confirmBtn.cloneNode(true); confirmBtn.parentNode.replaceChild(newConfirm, confirmBtn); newConfirm.onclick = () => { window.location.href = '/'; }; modal.classList.add('active'); }; // === 3. 点赞按钮 (右下角) === const likeWrapper = document.createElement('div'); likeWrapper.className = 'gm-chat-like-wrapper'; const likeIcon = document.createElement('div'); likeIcon.id = 'gm-chat-like-btn'; // 移除内联样式,全部由 CSS 类控制,以支持缩放 const likeNum = document.createElement('div'); likeNum.className = 'gm-chat-like-num'; likeWrapper.appendChild(likeIcon); likeWrapper.appendChild(likeNum); document.body.appendChild(likeWrapper); // 渲染函数 const renderLikeBtn = () => { const isLiked = this.detail.isGood === 1; likeIcon.innerHTML = isLiked ? '❤️' : '🤍'; likeNum.innerText = this.detail.goodNum; if (isLiked) { likeIcon.style.cursor = 'default'; likeIcon.title = "已点赞"; } else { likeIcon.style.cursor = 'pointer'; } }; renderLikeBtn(); // 绑定事件:只允许点赞,不可取消 likeIcon.onclick = async () => { if (this.detail.isGood === 1) return; if (!this.client.token) return this.showToast("请先登录"); this.detail.isGood = 1; this.detail.goodNum += 1; renderLikeBtn(); const success = await this.client.toggleChatLike(this.chatId, true); if (!success) { this.detail.isGood = 0; this.detail.goodNum -= 1; renderLikeBtn(); this.showToast("点赞失败"); } }; // === 4. 顶部导航按钮事件 (使用自定义 Modal) === // 收藏 const collectBtn = document.getElementById('gm-chat-collect'); if (collectBtn) { if (this.detail.isCollect) collectBtn.style.color = '#0984e3'; collectBtn.onclick = () => { if (!this.client.token) return this.showToast("请先登录"); if (!document.getElementById('gm-collect-modal')) { const modalHtml = `
收藏提示
取消
确定
`; const div = document.createElement('div'); div.innerHTML = modalHtml; document.body.appendChild(div.firstElementChild); document.getElementById('gm-collect-modal').querySelector('.gm-btn-cancel').onclick = () => document.getElementById('gm-collect-modal').classList.remove('active'); } const modal = document.getElementById('gm-collect-modal'); const title = modal.querySelector('.gm-modal-title'); const desc = modal.querySelector('.gm-modal-desc'); const confirmBtn = modal.querySelector('.gm-btn-confirm'); const isCollected = this.detail.isCollect === 1; title.innerText = isCollected ? "取消收藏" : "加入收藏"; desc.innerText = isCollected ? "确定要取消收藏本帖吗?" : "确定收藏本帖吗?"; const newConfirm = confirmBtn.cloneNode(true); confirmBtn.parentNode.replaceChild(newConfirm, confirmBtn); newConfirm.onclick = async () => { modal.classList.remove('active'); const newState = !isCollected; const success = await this.client.toggleChatCollect(this.chatId, newState); if (success) { this.detail.isCollect = newState ? 1 : 0; collectBtn.style.color = newState ? '#0984e3' : 'inherit'; this.showToast(newState ? "收藏成功" : "已取消收藏"); } else { this.showToast("操作失败"); } }; modal.classList.add('active'); }; } // 跳页 const jumpBtn = document.getElementById('gm-chat-jump'); if (jumpBtn) { jumpBtn.onclick = () => { if (!document.getElementById('gm-jump-modal')) { const modalHtml = `
跳转楼层
取消
跳转
`; const div = document.createElement('div'); div.innerHTML = modalHtml; document.body.appendChild(div.firstElementChild); document.getElementById('gm-jump-modal').querySelector('.gm-btn-cancel').onclick = () => document.getElementById('gm-jump-modal').classList.remove('active'); } const modal = document.getElementById('gm-jump-modal'); const desc = modal.querySelector('.gm-modal-desc'); const input = document.getElementById('gm-jump-input'); const confirmBtn = modal.querySelector('.gm-btn-confirm'); desc.innerText = `请输入目标楼层号 (1 - ${this.totalFloors || '未知'})`; input.value = ''; const doJump = () => { const val = parseInt(input.value); if (val && val > 0) { modal.classList.remove('active'); this.jumpToFloor(val); } }; const newConfirm = confirmBtn.cloneNode(true); confirmBtn.parentNode.replaceChild(newConfirm, confirmBtn); newConfirm.onclick = doJump; modal.classList.add('active'); setTimeout(() => input.focus(), 100); }; } // === 5. 评论点击委托 === this.root.addEventListener('click', (e) => { // 楼层回复按钮 (展开/收起子评论) const floorBtn = e.target.closest('.gm-floor-cmt-btn[data-chat-reply]'); if (floorBtn) { const floorId = floorBtn.dataset.id; const uname = floorBtn.dataset.uname; // 1. 切换到底部输入框的回复模式 (回复层主) this.setReplyTarget(floorId, uname); // 2. 展开/加载子评论 const subList = document.getElementById(`gm-sub-list-${floorId}`); if (subList) { if (subList.style.display === 'block') { subList.style.display = 'none'; } else { subList.style.display = 'block'; if (!subList.dataset.loaded) { this.loadSubComments(floorId); } } } } // 子评论回复按钮 (回复某条评论) const subReplyBtn = e.target.closest('.gm-sub-reply-btn'); if (subReplyBtn) { const floorId = subReplyBtn.dataset.floor; const parentId = subReplyBtn.dataset.id; const uname = subReplyBtn.dataset.uname; const replyUid = subReplyBtn.dataset.replyUid; // 获取回复目标用户ID this.setReplyTarget(floorId, uname, parentId, replyUid); } }); } initCommentDrawer() { // 实际上是初始化底部输入框逻辑 const sendBtn = document.getElementById('gm-cmt-send'); const input = document.getElementById('gm-cmt-input'); const status = document.getElementById('gm-reply-status'); const statusText = document.getElementById('gm-reply-target-text'); const cancelBtn = document.getElementById('gm-reply-cancel'); // 状态变量:null 表示回复帖子(盖楼),有值表示回复层主(楼中楼) this.replyTarget = null; // 自适应高度 input.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = (this.scrollHeight) + 'px'; }); // 取消回复模式 cancelBtn.onclick = () => { this.replyTarget = null; status.classList.remove('active'); input.placeholder = "回复帖子..."; }; sendBtn.onclick = async () => { const content = input.value.trim(); if (!content) return; sendBtn.innerText = '...'; let res; if (this.replyTarget) { // 回复层主/评论 (楼中楼) // replyTarget.id 是 floorId (层主ID) // replyTarget.parentId 是被回复的评论ID // replyTarget.replyUserId 是被回复的用户ID (用于显示 回复 @某人) res = await this.client.sendChatComment(this.chatId, content, this.replyTarget.id, this.replyTarget.parentId, this.replyTarget.replyUserId); } else { // 回复帖子 (盖楼) res = await this.client.sendChatFloor(this.chatId, content); } sendBtn.innerText = '发送'; if (res && res.code === 200) { input.value = ''; input.style.height = 'auto'; if (this.replyTarget) { // 刷新子评论列表 this.loadSubComments(this.replyTarget.id); // 恢复默认状态 this.replyTarget = null; status.classList.remove('active'); input.placeholder = "回复帖子..."; } else { // 盖楼成功,解锁并加载新楼层 this.hasMoreNext = true; const loader = document.getElementById('gm-loader-bottom'); if (loader) { loader.innerHTML = "正在加载更多回复..."; loader.classList.remove('no-spin'); loader.style.display = 'flex'; } this.loadMore('next'); } } else { alert(res.msg || "发送失败"); } }; } // 切换回复目标 // id: floorId (层主ID) // uname: 目标昵称 // parentId: 被回复的评论ID (可选) // replyUserId: 目标用户ID (可选,用于楼中楼中楼) setReplyTarget(id, uname, parentId = null, replyUserId = null) { this.replyTarget = { id, uname, parentId, replyUserId }; const status = document.getElementById('gm-reply-status'); const statusText = document.getElementById('gm-reply-target-text'); const input = document.getElementById('gm-cmt-input'); status.classList.add('active'); statusText.innerText = `正在回复 @${uname}`; input.placeholder = `回复 @${uname}...`; input.focus(); } async loadSubComments(floorId) { const list = document.getElementById(`gm-sub-list-${floorId}`); if (!list) return; list.innerHTML = '
加载中...
'; const subs = await this.client.fetchChatSubComments(this.chatId, floorId); list.dataset.loaded = "true"; if (!subs || subs.length === 0) { list.innerHTML = '
暂无回复
'; return; } list.innerHTML = subs.map(sub => { // 渲染子评论 (楼中楼中楼 - Level 3) let childrenHtml = ''; if (sub.readCommentSonList && sub.readCommentSonList.length > 0) { childrenHtml = sub.readCommentSonList.map(son => { const targetName = son.replyNickName || son.parentName || (son.replyUser ? son.replyUser.nickName : null); const replyTag = targetName ? `▶ ${targetName}` : ''; return `
${son.nickName} ${replyTag}
${son.content}
${son.createTime}
回复
`}).join(''); childrenHtml = `
${childrenHtml}
`; } // 渲染层主评论 (Level 2) return `
${sub.nickName} ${sub.parentName ? `▶ ${sub.parentName}` : ''}
${sub.content}
${sub.createTime}
回复
${childrenHtml}
`}).join(''); } async jumpToFloor(targetNum) { // 1. 上锁并升级代数 this.isJumping = true; this.renderGeneration++; this.isLoading = true; // 2. 重置状态 this.container.innerHTML = '
正在穿越... 🚀
'; this.root.querySelector('#gm-scroll-area').scrollTop = 0; this.loadedFloors.clear(); this.minLoaded = Infinity; this.maxLoaded = -Infinity; this.hasMorePrev = true; this.hasMoreNext = true; // 3. 加载数据 (加载目标层及后续9层) // 注意:闲聊API是 startFloor, size。 // 如果目标是 1,则加载 1-10。如果目标是 20,则加载 20-29。 const list = await this.client.fetchChatFloorsChunk(this.chatId, targetNum, 10); // 4. 渲染 this.container.innerHTML = ''; // 如果跳转的不是1楼,我们需要手动补一个楼主层(1楼)在最上面吗? // 通常跳转是为了看后面的,楼主层可以不显示,或者始终显示。 // 为了体验一致性,如果 targetNum > 1,我们就不显示楼主层了,或者用户向上滚动时再加载出来。 // 但 ChatUI 的设计是楼主层作为 header 存在... // 暂时策略:如果 targetNum > 1,不渲染楼主层。如果用户向上滚到 1,再渲染。 // 但是 renderHostFloor 是独立的... // 修正:ChatUI 的 renderHostFloor 是在 init 里调用的,它把楼主层 append 到了 container。 // jumpToFloor 清空了 container,所以楼主层也没了。 // 如果 targetNum == 1,我们应该重新渲染楼主层。 if (targetNum === 1) { this.renderHostFloor(); // 过滤掉 list 中的 1 楼 (如果有) const comments = list.filter(f => f.floorSort > 1); this.renderCommentsAsFloors(comments, 'append'); } else { this.renderCommentsAsFloors(list, 'append'); } // 5. 解锁 this.isJumping = false; this.isLoading = false; } showToast(msg) { let toast = document.getElementById('gm-toast-msg'); if (!toast) { toast = document.createElement('div'); toast.id = 'gm-toast-msg'; toast.className = 'gm-toast'; document.body.appendChild(toast); } toast.innerText = msg; toast.classList.add('active'); setTimeout(() => toast.classList.remove('active'), 2000); } } // ========================================== // 3.5 首页 App 模式 UI // ========================================== class MobileHomeUI { constructor(client) { this.client = client; this.root = null; this.tabs = [ { id: 'search', label: '搜索', icon: 'M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z' }, { id: 'chat', label: '闲聊', icon: 'M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z' }, { id: 'rank', label: '排行榜', icon: 'M16 6l2.29 2.29-4.88 4.88-4-4L2 16.59 3.41 18l6-6 4 4 6.3-6.29L22 12V6z' }, { id: 'shelf', label: '书架', icon: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H8V4h12v12zM10 9h8v2h-8zm0 3h4v2h-4zm0-6h8v2h-8z' }, { id: 'works', label: '作品', icon: 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z' }, { id: 'msg', label: '消息', icon: 'M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z' } ]; this.activeTab = 'rank'; // 搜索相关状态 this.searchOptions = { sort: 'default', minWords: 0 }; // 排行榜相关状态 this.rankState = { type: 'NEW', // NEW, UPDATE, RELEASE, VIEW page: 1, loading: false, hasMore: true }; // 书架相关状态 this.shelfState = { page: 1, loading: false, hasMore: true, mode: 'list', // 'list' or 'grid' data: [] // 缓存已加载的书籍 }; // 闲聊相关状态 this.chatState = { topicId: 0, // 0=推荐, 1=灌水, 2=创作, 3=小镇 page: 1, loading: false, hasMore: true }; // 消息轮询定时器 this.msgPollTimer = null; // 图片懒加载观察器 this.imageObserver = null; } init() { this.initImageObserver(); this.injectHomeStyles(); this.root = document.createElement('div'); this.root.id = 'g-mobile-app'; const user = this.client.currentUser || {}; const isLogin = !!this.client.token; // 统一逻辑:无论登录与否都显示头像框,未登录显示默认头像和"请登录" const headPic = (isLogin && user.headPic) ? user.headPic : 'https://www.gululu.world/favicon.ico'; const displayName = isLogin ? (user.nickName || '我') : '请登录'; this.root.innerHTML = `
退出
骨碌碌
🌗
UI缩放: ${Math.round((config.mobileScale||1)*100)}%
全屏模式 ⛶
${isLogin ? '退出登录' : '立即登录'}
${this.tabs.map(tab => `
${this.getTabInitialHtml(tab)}
`).join('')}
${this.tabs.map(tab => `
${tab.label}
${tab.id === 'msg' ? '' : ''}
`).join('')}
`; document.body.appendChild(this.root); // 绑定 Tab 点击事件 const tabItems = this.root.querySelectorAll('.gm-tab-item'); tabItems.forEach(item => { item.onclick = () => { const tabId = item.dataset.tab; this.switchTab(tabId); }; }); // 左上角退出手机模式 this.root.querySelector('#gm-home-exit').onclick = () => { if(confirm("退出手机模式?")) { config.mobileModeEnabled = false; localStorage.setItem('gululu_global_config_v6', JSON.stringify(config)); location.reload(); } }; // 夜间模式切换 (初始化已在全局完成) this.root.querySelector('#gm-toggle-night').onclick = () => { document.body.classList.toggle('g-night-mode'); config.nightMode = document.body.classList.contains('g-night-mode'); localStorage.setItem('gululu_global_config_v6', JSON.stringify(config)); }; // 右上角用户区域点击事件 (切换菜单) const menu = this.root.querySelector('#gm-user-menu'); this.root.querySelector('#gm-user-area').onclick = (e) => { e.stopPropagation(); menu.classList.toggle('active'); }; // 点击外部关闭菜单 document.addEventListener('click', (e) => { if (!e.target.closest('#gm-user-menu') && !e.target.closest('#gm-user-area')) { menu.classList.remove('active'); } }); // 菜单:UI缩放切换 this.root.querySelector('#gm-menu-scale').onclick = (e) => { const scales = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5]; let current = config.mobileScale || 1.0; let idx = scales.findIndex(s => Math.abs(s - current) < 0.01); if (idx === -1) idx = 3; idx = (idx + 1) % scales.length; config.mobileScale = scales[idx]; document.documentElement.style.setProperty('--gm-scale', config.mobileScale); localStorage.setItem('gululu_global_config_v6', JSON.stringify(config)); e.target.innerText = `UI缩放: ${config.mobileScale * 100}%`; }; // 菜单:全屏切换 const fsBtn = this.root.querySelector('#gm-menu-fullscreen'); fsBtn.onclick = () => { if (!document.fullscreenElement) { sessionStorage.setItem('gm_is_fullscreen', '1'); // 标记意图 document.documentElement.requestFullscreen().catch(e => alert("全屏失败: " + e.message)); } else { sessionStorage.removeItem('gm_is_fullscreen'); // 清除意图 if (document.exitFullscreen) document.exitFullscreen(); } menu.classList.remove('active'); }; document.addEventListener('fullscreenchange', () => { fsBtn.innerHTML = document.fullscreenElement ? '退出全屏 ✕' : '全屏模式 ⛶'; }); // 菜单:登录/退出 this.root.querySelector('#gm-menu-auth').onclick = () => { menu.classList.remove('active'); if (isLogin) { this.initLogoutModal(); } else { this.initLoginModal(); } }; // ⚡ 异步更新用户信息 (修复头像显示为"我"的问题) if (isLogin) { this.client.fetchUserInfo().then(u => { if (u) { const nameEl = this.root.querySelector('.gm-user-name'); const imgEl = this.root.querySelector('#gm-user-area img'); if (nameEl) nameEl.textContent = u.nickName; if (imgEl && u.headPic) imgEl.src = u.headPic; } }); } // 初始化搜索页逻辑 this.initSearchTab(); // 初始化消息页逻辑 this.initMsgTab(); // 初始化排行榜逻辑 this.initRankTab(); // 初始化闲聊逻辑 this.initChatTab(); // 初始化作品页逻辑 this.initWorksTab(); // 初始化书架页逻辑 this.initShelfTab(); // 全局滚动监听 (用于排行榜/书架无限加载) const scrollContent = this.root.querySelector('.gm-content'); scrollContent.addEventListener('scroll', () => { const { scrollTop, scrollHeight, clientHeight } = scrollContent; if (scrollTop + clientHeight < scrollHeight - 200) return; if (this.activeTab === 'rank') { this.loadRankData(true); } else if (this.activeTab === 'shelf') { this.loadShelfData(true); } else if (this.activeTab === 'chat') { this.loadChatData(true); } }, { passive: true }); } initLogoutModal() { if (document.getElementById('gm-logout-modal')) { document.getElementById('gm-logout-modal').classList.add('active'); return; } const modalHtml = `
退出登录
确定要退出当前账号吗?
取消
退出
`; const div = document.createElement('div'); div.innerHTML = modalHtml; document.body.appendChild(div.firstElementChild); const modal = document.getElementById('gm-logout-modal'); modal.querySelector('#gm-logout-cancel').onclick = () => modal.classList.remove('active'); modal.querySelector('#gm-logout-confirm').onclick = () => { // 强力清除 Cookie (覆盖根域名和当前域名) const keys = document.cookie.match(/([^ =;]+)=/g); if (keys) { for (let i = keys.length; i--;) { const key = keys[i].substring(0, keys[i].length - 1); document.cookie = key + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; document.cookie = key + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.gululu.world;'; document.cookie = key + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=gululu.world;'; } } localStorage.removeItem('gululu_user_cache'); location.reload(); }; } initLoginModal() { if (document.getElementById('gm-login-modal')) { document.getElementById('gm-login-modal').classList.add('active'); return; } const modalHtml = `
取消
登录
`; const div = document.createElement('div'); div.innerHTML = modalHtml; document.body.appendChild(div.firstElementChild); const modal = document.getElementById('gm-login-modal'); const tabs = modal.querySelectorAll('.gm-login-tab'); let loginType = 'pass'; // 切换 Tab tabs.forEach(tab => tab.onclick = () => { tabs.forEach(t => t.classList.remove('active')); tab.classList.add('active'); loginType = tab.dataset.type; document.getElementById('gm-login-form-pass').style.display = loginType === 'pass' ? 'block' : 'none'; document.getElementById('gm-login-form-sms').style.display = loginType === 'sms' ? 'block' : 'none'; }); // 取消 modal.querySelector('#gm-login-cancel').onclick = () => modal.classList.remove('active'); // 获取验证码 modal.querySelector('#gm-get-code').onclick = async (e) => { const btn = e.target; const phone = document.getElementById('gm-login-phone-sms').value; if (!phone) return alert("请输入手机号"); btn.disabled = true; const res = await this.client.sendSmsCode(phone); if (res.code === 200) { let sec = 60; const timer = setInterval(() => { btn.innerText = `${sec--}s`; if (sec < 0) { clearInterval(timer); btn.disabled = false; btn.innerText = "获取验证码"; } }, 1000); } else { alert(res.msg); btn.disabled = false; } }; // 提交登录 modal.querySelector('#gm-login-submit').onclick = async () => { let res; if (loginType === 'pass') { const phone = document.getElementById('gm-login-phone').value; const pass = document.getElementById('gm-login-pass').value; if (!phone || !pass) return alert("请填写完整"); res = await this.client.loginByPass(phone, pass); } else { const phone = document.getElementById('gm-login-phone-sms').value; const code = document.getElementById('gm-login-code').value; if (!phone || !code) return alert("请填写完整"); res = await this.client.loginBySms(phone, code); } if (res.code === 200 && res.data && res.data.token) { // 写入 Cookie 并刷新 const token = res.data.token; document.cookie = `token=${token}; path=/; max-age=2592000`; // 30天 document.cookie = `Admin-Token=${token}; path=/; max-age=2592000`; location.reload(); } else { alert(res.msg || "登录失败"); } }; } injectHomeStyles() { const style = document.createElement('style'); const css = ` .gm-search-container { padding: 40px 20px; margin-top: 40px; } .gm-search-container.compact { margin-top: 0; padding-bottom: 20px; } .gm-search-box { position: relative; width: 100%; height: 110px; background: #fff; border-radius: 60px; box-shadow: 0 10px 40px rgba(0,0,0,0.08); display: flex; align-items: center; padding: 0 40px; box-sizing: border-box; border: 3px solid transparent; transition: all 0.3s; } .gm-search-box.focused { border-color: #6c5ce7; box-shadow: 0 10px 50px rgba(108, 92, 231, 0.2); } .gm-search-box.compact { height: 90px; } .gm-search-icon { width: 48px; height: 48px; fill: #999; margin-right: 25px; flex-shrink: 0; } .gm-search-input { flex: 1; border: none; background: transparent; font-size: 36px; color: #333; outline: none; height: 100%; font-weight: 500; } .gm-search-input::placeholder { color: #ccc; } .gm-tags-section { margin-top: 80px; padding: 0 20px; } .gm-tags-title { font-size: 34px; font-weight: 800; color: #333; margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center; } .gm-tags-list { display: flex; flex-wrap: wrap; gap: 20px; } .gm-tag-item { padding: 16px 36px; background: #fff; border-radius: 40px; font-size: 30px; color: #555; cursor: pointer; font-weight: 500; box-shadow: 0 4px 15px rgba(0,0,0,0.05); transition: all 0.2s; border: 1px solid #f0f0f0; } .gm-tag-item:active { transform: scale(0.95); background: #f0f0f0; } /* 夜间模式适配 */ body.g-night-mode .gm-search-box { background: #2d2d2d; box-shadow: none; border-color: #444; } body.g-night-mode .gm-search-box.focused { border-color: #6c5ce7; } body.g-night-mode .gm-search-input { color: #eee; } body.g-night-mode .gm-tags-title { color: #eee; } body.g-night-mode .gm-tag-item { background: #2d2d2d; color: #ccc; border-color: #444; } body.g-night-mode .gm-user-name { color: #ccc !important; } /* === 搜索结果页样式 === */ .gm-sub-tabs { display: flex; border-bottom: 1px solid #eee; background: #fff; position: sticky; top: 0; z-index: 10; } .gm-sub-tab { flex: 1; text-align: center; padding: 25px 0; font-size: 30px; color: #999; font-weight: 600; position: relative; } .gm-sub-tab.active { color: #6c5ce7; } .gm-sub-tab.active::after { content: ""; position: absolute; bottom: 0; left: 25%; width: 50%; height: 6px; background: #6c5ce7; border-radius: 6px; } .gm-result-list { padding: 20px; } /* 通用卡片基础 */ .gm-res-card { background: #fff; border-radius: 20px; padding: 25px; margin-bottom: 25px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); display: flex; gap: 25px; } /* 书籍卡片 (安科) */ .gm-res-book-cover { width: 140px; height: 186px; border-radius: 12px; object-fit: cover; flex-shrink: 0; box-shadow: 0 4px 10px rgba(0,0,0,0.1); } .gm-res-book-info { flex: 1; display: flex; flex-direction: column; justify-content: space-between; overflow: hidden; } .gm-res-title { font-size: 32px; font-weight: bold; color: #333; margin-bottom: 10px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .gm-res-author { font-size: 24px; color: #999; display: flex; align-items: center; gap: 10px; } .gm-res-tags { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; } .gm-res-tag { font-size: 20px; padding: 4px 12px; background: #f0f2f5; color: #666; border-radius: 8px; } /* 综合卡片 (闲聊/楼层) */ .gm-res-general { flex-direction: column; gap: 15px; } .gm-res-ctx { font-size: 28px; color: #555; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } .gm-res-meta { font-size: 22px; color: #aaa; display: flex; justify-content: space-between; border-top: 1px solid #f9f9f9; padding-top: 15px; } /* 用户卡片 */ .gm-res-user { align-items: center; } .gm-res-avatar { width: 100px; height: 100px; border-radius: 50%; object-fit: cover; } .gm-res-uinfo { flex: 1; } .gm-res-uname { font-size: 32px; font-weight: bold; color: #333; margin-bottom: 8px; } .gm-res-ustats { font-size: 24px; color: #999; } /* 夜间模式 */ body.g-night-mode .gm-sub-tabs { background: #1e1e1e; border-color: #333; } body.g-night-mode .gm-res-card { background: #2d2d2d; } body.g-night-mode .gm-res-title, body.g-night-mode .gm-res-uname { color: #eee; } body.g-night-mode .gm-res-ctx { color: #bbb; } body.g-night-mode .gm-res-tag { background: #333; color: #aaa; } /* === 搜索筛选栏 === */ .gm-filter-bar { display: flex; gap: 20px; padding: 0 20px; margin-bottom: 20px; overflow-x: auto; -webkit-overflow-scrolling: touch; } .gm-select-wrapper { position: relative; flex-shrink: 0; } .gm-select { appearance: none; -webkit-appearance: none; background: #f5f6fa; border: 1px solid #eee; padding: 12px 60px 12px 30px; border-radius: 30px; font-size: 26px; font-weight: bold; color: #555; outline: none; transition: all 0.2s; } .gm-select:focus { border-color: #6c5ce7; background: #fff; } .gm-select-arrow { position: absolute; right: 20px; top: 50%; transform: translateY(-50%); width: 0; height: 0; border-left: 8px solid transparent; border-right: 8px solid transparent; border-top: 10px solid #999; pointer-events: none; } body.g-night-mode .gm-select { background: #2d2d2d; border-color: #444; color: #ccc; } body.g-night-mode .gm-select:focus { border-color: #6c5ce7; background: #333; } /* 我的作品卡片 - 大图模式 */ .gm-work-card { flex-direction: column; padding: 30px; } .gm-work-header { display: flex; gap: 30px; margin-bottom: 25px; } .gm-work-cover { width: 180px; height: 240px; border-radius: 16px; object-fit: cover; box-shadow: 0 5px 15px rgba(0,0,0,0.1); flex-shrink: 0; } .gm-work-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; } .gm-work-title { font-size: 36px; font-weight: 800; color: #333; margin-bottom: 15px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .gm-work-intro { font-size: 26px; color: #888; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } .gm-work-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; background: #f8f9fa; padding: 25px; border-radius: 20px; } .gm-stat-item { text-align: center; } .gm-stat-num { font-size: 32px; font-weight: bold; color: #333; margin-bottom: 5px; } .gm-stat-label { font-size: 22px; color: #999; } .gm-work-footer { margin-top: 25px; padding-top: 20px; border-top: 1px solid #f0f0f0; font-size: 24px; color: #aaa; text-align: right; } body.g-night-mode .gm-work-title { color: #eee; } body.g-night-mode .gm-work-intro { color: #aaa; } body.g-night-mode .gm-work-stats { background: #333; } body.g-night-mode .gm-stat-num { color: #ddd; } body.g-night-mode .gm-work-footer { border-color: #333; } /* === 书架样式 === */ .gm-shelf-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 20px 30px; background: #fff; margin-bottom: 20px; box-shadow: 0 4px 10px rgba(0,0,0,0.03); position: sticky; top: 0; z-index: 10; } .gm-view-toggle { font-size: 28px; padding: 10px 24px; background: #f0f2f5; border-radius: 30px; color: #666; cursor: pointer; display: flex; align-items: center; gap: 10px; font-weight: bold; } .gm-view-toggle:active { transform: scale(0.95); } /* 列表模式进度条 */ .gm-shelf-progress { margin-top: 15px; font-size: 24px; color: #6c5ce7; font-weight: bold; display: flex; align-items: center; gap: 15px; } .gm-progress-bar { flex: 1; height: 10px; background: #eee; border-radius: 5px; overflow: hidden; } .gm-progress-fill { height: 100%; background: #6c5ce7; border-radius: 5px; } /* 网格模式 */ .gm-shelf-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; padding: 0 20px 40px 20px; } .gm-grid-item { position: relative; aspect-ratio: 0.7; border-radius: 16px; overflow: hidden; box-shadow: 0 5px 15px rgba(0,0,0,0.1); background: #eee; cursor: pointer; } .gm-grid-cover { width: 100%; height: 100%; object-fit: cover; } .gm-grid-overlay { position: absolute; bottom: 0; left: 0; width: 100%; background: linear-gradient(to top, rgba(0,0,0,0.8), transparent); padding: 20px 10px 10px; color: white; box-sizing: border-box; } .gm-grid-title { font-size: 22px; font-weight: bold; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .gm-grid-info { font-size: 18px; color: #ddd; margin-top: 4px; display: flex; justify-content: space-between; } .gm-grid-badge { position: absolute; top: 10px; right: 10px; background: #ff4757; color: white; font-size: 16px; padding: 4px 8px; border-radius: 8px; font-weight: bold; box-shadow: 0 2px 5px rgba(0,0,0,0.2); } body.g-night-mode .gm-shelf-toolbar { background: #1e1e1e; box-shadow: none; border-bottom: 1px solid #333; } body.g-night-mode .gm-view-toggle { background: #333; color: #aaa; } body.g-night-mode .gm-grid-item { border: 1px solid #333; } /* === 消息页样式 === */ .gm-msg-card { background: #fff; padding: 25px; margin-bottom: 20px; border-radius: 20px; display: flex; gap: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.03); cursor: pointer; } .gm-msg-card:active { background: #f9f9f9; } .gm-msg-avatar { width: 90px; height: 90px; border-radius: 50%; object-fit: cover; flex-shrink: 0; } .gm-msg-body { flex: 1; overflow: hidden; display: flex; flex-direction: column; justify-content: center; } .gm-msg-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } .gm-msg-user { font-size: 30px; font-weight: bold; color: #333; } .gm-msg-time { font-size: 22px; color: #aaa; } .gm-msg-content { font-size: 28px; color: #555; line-height: 1.4; margin-bottom: 12px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .gm-msg-source { font-size: 22px; color: #999; background: #f5f6fa; padding: 8px 16px; border-radius: 10px; align-self: flex-start; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* === 闲聊板块样式 === */ .gm-chat-card { background: #fff; padding: 30px; margin-bottom: 20px; border-radius: 24px; box-shadow: 0 4px 12px rgba(0,0,0,0.03); display: flex; flex-direction: column; gap: 20px; } .gm-chat-card:active { background: #f9f9f9; } .gm-chat-header { display: flex; align-items: center; gap: 20px; } .gm-chat-avatar { width: 80px; height: 80px; border-radius: 50%; object-fit: cover; border: 1px solid #eee; } .gm-chat-user-info { flex: 1; display: flex; flex-direction: column; justify-content: center; } .gm-chat-nickname { font-size: 28px; font-weight: bold; color: #333; } .gm-chat-time { font-size: 22px; color: #999; margin-top: 6px; } .gm-chat-tag { font-size: 22px; color: #6c5ce7; background: rgba(108, 92, 231, 0.1); padding: 6px 16px; border-radius: 10px; font-weight: bold; } .gm-chat-body { display: flex; flex-direction: column; gap: 15px; } .gm-chat-title { font-size: 34px; font-weight: 800; color: #2d3436; line-height: 1.4; } .gm-chat-text { font-size: 30px; color: #636e72; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } .gm-chat-imgs { display: grid; gap: 10px; margin-top: 10px; } .gm-chat-imgs.col-1 { grid-template-columns: 1fr; } .gm-chat-imgs.col-2 { grid-template-columns: 1fr 1fr; } .gm-chat-imgs.col-3 { grid-template-columns: 1fr 1fr 1fr; } .gm-chat-img { width: 100%; height: 200px; object-fit: cover; border-radius: 12px; background: #f0f0f0; } .gm-chat-imgs.col-1 .gm-chat-img { height: 350px; max-width: 100%; } .gm-chat-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 20px; border-top: 1px solid #f5f5f5; color: #999; font-size: 24px; } .gm-chat-stat { display: flex; gap: 30px; } .gm-stat-icon { margin-right: 8px; } body.g-night-mode .gm-chat-card { background: #2d2d2d; } body.g-night-mode .gm-chat-nickname, body.g-night-mode .gm-chat-title { color: #eee; } body.g-night-mode .gm-chat-text { color: #aaa; } body.g-night-mode .gm-chat-footer { border-color: #333; } body.g-night-mode .gm-chat-tag { background: rgba(108, 92, 231, 0.2); color: #a29bfe; } /* 消息红点 */ .gm-tab-badge { position: absolute; top: 15px; right: 20%; background: #ff4757; color: white; font-size: 20px; height: 32px; min-width: 32px; padding: 0 8px; border-radius: 16px; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 5px rgba(255, 71, 87, 0.3); z-index: 20; } /* 底部导航红点 */ .gm-tab-badge-main { position: absolute; top: 5px; left: 50%; margin-left: 10px; background: #ff4757; color: white; font-size: 20px; height: 32px; min-width: 32px; padding: 0 6px; border-radius: 16px; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 5px rgba(255, 71, 87, 0.3); z-index: 25; font-weight: bold; border: 2px solid #fff; } body.g-night-mode .gm-tab-badge-main { border-color: #2d2d2d; } body.g-night-mode .gm-msg-card { background: #2d2d2d; } body.g-night-mode .gm-msg-user { color: #eee; } body.g-night-mode .gm-msg-content { color: #ccc; } body.g-night-mode .gm-msg-source { background: #333; color: #888; } /* 图片懒加载动画 */ img.lazy-load { opacity: 0; transition: opacity 0.3s ease-in; } img.lazy-loaded { opacity: 1; } /* 强制覆盖首页背景 */ body.g-night-mode, body.g-night-mode #g-mobile-app { background-color: #121212 !important; } /* === 用户下拉菜单 === */ .gm-user-menu { position: absolute; top: 120px; right: 20px; background: #fff; border-radius: 24px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); display: none; flex-direction: column; z-index: 10000; overflow: hidden; min-width: 280px; border: 1px solid #eee; } .gm-user-menu.active { display: flex; animation: gm-fade-in 0.2s ease; } .gm-user-menu-item { padding: 30px 40px; font-size: 30px; font-weight: bold; color: #333; text-align: center; border-bottom: 1px solid #f5f5f5; cursor: pointer; transition: background 0.1s; } .gm-user-menu-item:active { background: #f5f6fa; } .gm-user-menu-item:last-child { border-bottom: none; } body.g-night-mode .gm-user-menu { background: #2d2d2d; border-color: #444; } body.g-night-mode .gm-user-menu-item { color: #ccc; border-bottom-color: #444; } body.g-night-mode .gm-user-menu-item:active { background: #333; } /* === 缩放适配补丁 === */ .gm-navbar-exit { font-size: 30px; } .gm-user-name { font-size: 26px; font-weight: bold; color: #666; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .gm-res-meta-bottom { font-size: 22px; color: #aaa; margin-top: 10px; } .gm-res-meta-shelf { font-size: 22px; color: #aaa; margin-top: 10px; display: flex; justify-content: space-between; } .gm-work-id { font-size: 26px; line-height: 1.4; color: #aaa; } .gm-res-title-general { font-size: 30px; } /* 补漏:头像、夜间按钮、阅读进度 */ .gm-navbar-avatar { width: 70px !important; height: 70px !important; border-radius: 50% !important; border: 2px solid rgba(0,0,0,0.1); object-fit: cover; display: block; } .gm-navbar-night { font-size: 36px; margin-right: 15px; } .gm-shelf-progress-num { font-size: 20px; color: #999; } body.g-night-mode .gm-user-name { color: #ccc; } `; style.textContent = css.replace(/(\d+)px/g, 'calc($1px * var(--gm-scale))'); document.head.appendChild(style); } initImageObserver() { if ('IntersectionObserver' in window) { this.imageObserver = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; const src = img.dataset.src; if (src) { img.src = src; img.onload = () => img.classList.add('lazy-loaded'); img.removeAttribute('data-src'); img.classList.remove('lazy-load'); observer.unobserve(img); } } }); }, { rootMargin: '300px 0px' }); // 提前300px加载,保证流畅 } } observeNewImages(container) { if (!this.imageObserver || !container) return; container.querySelectorAll('img.lazy-load').forEach(img => this.imageObserver.observe(img)); } getTabInitialHtml(tab) { if (tab.id === 'rank') { return `
人气新作榜
最新更新
最新发布
浏览最高
加载中...
`; } if (tab.id === 'chat') { return `
推荐
灌水
创作
小镇
加载中...
`; } if (tab.id === 'works') { return `
`; } if (tab.id === 'msg') { return `
回复我的
收到的赞
系统通知
加载中...
`; } if (tab.id === 'search') { return `
${this.renderFilterBar()} `; } if (tab.id === 'works') { return `
`; } if (tab.id === 'shelf') { return `
我的书架
列表
加载中...
`; } return `
${tab.label} (开发中...)
`; } initSearchTab() { const input = this.root.querySelector('.gm-search-input'); const box = this.root.querySelector('.gm-search-box'); const form = this.root.querySelector('.gm-search-form'); if (!input) return; // 聚焦特效 input.onfocus = () => box.classList.add('focused'); input.onblur = () => box.classList.remove('focused'); // 回车搜索 (表单提交) if (form) { form.onsubmit = (e) => { e.preventDefault(); // 阻止页面刷新 this.doSearch(input.value); input.blur(); // 提交后收起键盘 }; } else { // 兜底逻辑 input.onkeydown = (e) => { if(e.key === 'Enter') this.doSearch(input.value); }; } // 绑定筛选/排序变化 this.bindFilterEvents(this.root); // 加载数据 this.loadSearchData(); } async loadSearchData() { const [hot, history] = await Promise.all([ this.client.fetchHotSearch(), this.client.fetchSearchHistory() ]); this.renderTags('history', history); this.renderTags('hot', hot); } renderTags(type, data) { const container = this.root.querySelector(`#gm-tags-${type}`); if(!container) return; const section = container.closest('.gm-tags-section'); if(!data || data.length === 0) { section.style.display = 'none'; return; } section.style.display = 'block'; container.innerHTML = data.map(t => `
${t}
`).join(''); // 绑定点击 container.querySelectorAll('.gm-tag-item').forEach(el => { el.onclick = () => this.doSearch(el.innerText); }); // 绑定清空按钮 (仅针对历史记录) if (type === 'history') { const clearBtn = section.querySelector('#gm-clear-history'); if (clearBtn) { clearBtn.onclick = async () => { if (confirm('确定要清空搜索历史吗?')) { const success = await this.client.clearSearchHistory(); if (success) { section.style.display = 'none'; container.innerHTML = ''; } else { alert('清空失败,请重试'); } } }; } } } renderFilterBar() { return `
`; } bindFilterEvents(container) { const sortSelect = container.querySelector('#gm-filter-sort'); const wordsSelect = container.querySelector('#gm-filter-words'); if(sortSelect) { sortSelect.onchange = (e) => { this.searchOptions.sort = e.target.value; // 如果在结果页且当前是OPUS,刷新结果 if(document.getElementById('gm-res-list') && document.querySelector('.gm-sub-tab[data-type="OPUS"].active')) { const keyword = container.querySelector('.gm-search-input').value; this.loadSubTab('OPUS', keyword); } }; } if(wordsSelect) { wordsSelect.onchange = (e) => { this.searchOptions.minWords = parseInt(e.target.value); if(document.getElementById('gm-res-list') && document.querySelector('.gm-sub-tab[data-type="OPUS"].active')) { const keyword = container.querySelector('.gm-search-input').value; this.loadSubTab('OPUS', keyword); } }; } } doSearch(keyword) { if(!keyword || !keyword.trim()) return; keyword = keyword.trim(); // 1. 隐藏标签页,显示结果容器 const container = this.root.querySelector('#gm-tab-content-search'); container.innerHTML = ''; // 清空旧内容 // 2. 调整搜索框样式 (变紧凑) const searchBox = this.root.querySelector('.gm-search-container'); if(searchBox) searchBox.style.marginTop = '0'; // 3. 渲染结果页框架 this.renderResultPage(container, keyword); } renderResultPage(container, keyword) { // 顶部:紧凑的搜索框 const html = `
${this.renderFilterBar()}
安科
综合
用户
加载中...
`; container.innerHTML = html; // 重新绑定搜索框事件 const form = container.querySelector('.gm-search-form'); const input = container.querySelector('.gm-search-input'); if (form) { form.onsubmit = (e) => { e.preventDefault(); this.doSearch(input.value); input.blur(); }; } // 重新绑定筛选栏事件 (因为 renderResultPage 重新渲染了筛选栏) this.bindFilterEvents(container); // 绑定 Tab 切换 const tabs = container.querySelectorAll('.gm-sub-tab'); tabs.forEach(tab => { tab.onclick = () => { tabs.forEach(t => t.classList.remove('active')); tab.classList.add('active'); this.loadSubTab(tab.dataset.type, keyword); }; }); // 默认加载安科 this.loadSubTab('OPUS', keyword); } async loadSubTab(type, keyword) { const list = document.getElementById('gm-res-list'); list.innerHTML = '
加载中...
'; let html = ''; // 只有在 OPUS 模式下才显示/使用筛选栏 const filterBar = this.root.querySelector('.gm-filter-bar'); if (filterBar) { filterBar.style.display = (type === 'OPUS') ? 'flex' : 'none'; } const PLACEHOLDER = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; if (type === 'USER') { const users = await this.client.searchUser(keyword); if (!users || users.length === 0) { list.innerHTML = '
未找到相关用户
'; return; } html = users.map(u => `
${this.highlight(u.nickName, keyword)}
作品: ${u.opusNum} · 粉丝: ${u.fansNum}
`).join(''); } else { if (type === 'OPUS') { // 2. 聚合搜索 (前10页) const items = await this.fetchOpusAggregated(keyword); if (!items || items.length === 0) { list.innerHTML = '
未找到相关安科
'; return; } html = items.map(item => `
${this.renderHl(item.opusNameHighlight, item.opusName)}
${this.renderHl(item.authorNameHighlight, item.authorName)}
${this.renderTagsHtml(item.tagNamesHighlight, item.tagNames)}
${item.words}字 · ${item.readNum}热度 · ${item.updateTime}
`).join(''); } else { // ALL (综合) const res = await this.client.searchGeneral(keyword, type, 1); const items = res ? res.items : []; if (!items || items.length === 0) { list.innerHTML = '
未找到相关内容
'; return; } html = items.map(item => `
${item.title || '无标题'}
${this.renderHl(item.contextHighlight, item.context)}
${item.createTime} 回复: ${item.commentNum}
`).join(''); } } list.innerHTML = html; this.observeNewImages(list); // 启动懒加载监听 } // === 闲聊逻辑 === initChatTab() { const tabsContainer = this.root.querySelector('#gm-chat-tabs'); if(!tabsContainer) return; const tabs = tabsContainer.querySelectorAll('.gm-sub-tab'); tabs.forEach(tab => { tab.onclick = () => { const topic = parseInt(tab.dataset.topic); if(this.chatState.topicId === topic) return; tabs.forEach(t => t.classList.remove('active')); tab.classList.add('active'); this.chatState.topicId = topic; this.chatState.page = 1; this.chatState.hasMore = true; this.chatState.loading = false; document.getElementById('gm-chat-list').innerHTML = '
加载中...
'; document.getElementById('gm-chat-nomore').style.display = 'none'; this.loadChatData(); }; }); this.loadChatData(); } async loadChatData(isMore = false) { if (this.chatState.loading || (!isMore && !this.chatState.hasMore)) return; if (isMore && !this.chatState.hasMore) return; this.chatState.loading = true; const list = document.getElementById('gm-chat-list'); const items = await this.client.fetchChatList(this.chatState.topicId, this.chatState.page); if (!isMore && list.querySelector('div[style*="text-align:center"]')) { list.innerHTML = ''; } if (items && items.length > 0) { const PLACEHOLDER = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; const html = items.map(item => { // 图片处理 let imgHtml = ''; if (item.images && item.images.length > 0) { const count = Math.min(item.images.length, 3); // 最多显示3张 const colClass = `col-${count}`; const imgs = item.images.slice(0, 3).map(url => `` ).join(''); imgHtml = `
${imgs}
`; } else if (item.firstImg && item.firstImg !== '暂无图片') { imgHtml = `
`; } // 标签处理 const tagName = item.chatTopic ? item.chatTopic.name : ( item.chatTopicId === 1 ? '灌水' : item.chatTopicId === 2 ? '创作' : item.chatTopicId === 3 ? '小镇' : '闲聊' ); return `
${tagName}
${item.chatTitle}
${item.text || ''}
${imgHtml}
`; }).join(''); list.insertAdjacentHTML('beforeend', html); this.observeNewImages(list); this.chatState.page++; } else { this.chatState.hasMore = false; document.getElementById('gm-chat-nomore').style.display = 'block'; if (!isMore && this.chatState.page === 1) { list.innerHTML = '
暂无内容
'; } } this.chatState.loading = false; } // === 消息逻辑 === initMsgTab() { const tabsContainer = this.root.querySelector('#gm-msg-tabs'); if(!tabsContainer) return; // 更新红点 this.updateMsgBadges(); this.startMsgPolling(); // 绑定 Tab 切换 const tabs = tabsContainer.querySelectorAll('.gm-sub-tab'); tabs.forEach(tab => { tab.onclick = () => { tabs.forEach(t => t.classList.remove('active')); tab.classList.add('active'); // 1. 清除当前Tab红点 const badge = tab.querySelector('.gm-tab-badge'); if (badge) { badge.style.display = 'none'; badge.innerText = '0'; // 归零以便后续计算 } // 2. 重新计算底部总未读数 let total = 0; tabsContainer.querySelectorAll('.gm-tab-badge').forEach(b => { if (b.style.display !== 'none') { total += (parseInt(b.innerText) || 0); } }); // 3. 更新底部导航红点 const mainBadge = document.getElementById('gm-tab-badge-msg'); if (mainBadge) { if (total > 0) { mainBadge.style.display = 'flex'; mainBadge.innerText = total > 99 ? '99+' : total; } else { mainBadge.style.display = 'none'; } } this.loadMsgData(tab.dataset.type); }; }); // 默认加载回复 this.loadMsgData(2); } startMsgPolling() { if (this.msgPollTimer) clearInterval(this.msgPollTimer); // 60秒轮询一次,避免过于频繁请求造成服务器压力 this.msgPollTimer = setInterval(() => { this.updateMsgBad ges(); }, 60000); } async updateMsgBadges() { if (!this.client.token) return; const data = await this.client.fetchMessageCount(); let total = 0; // getReplyNum -> type 2 if(data.getReplyNum > 0) { total += data.getReplyNum; const el = document.getElementById('gm-badge-reply'); if(el) { el.style.display = 'flex'; el.innerText = data.getReplyNum; } } // getGoodNum -> type 1 if(data.getGoodNum > 0) { total += data.getGoodNum; const el = document.getElementById('gm-badge-good'); if(el) { el.style.display = 'flex'; el.innerText = data.getGoodNum; } } // getAdviceNum -> type 0 if(data.getAdviceNum > 0) { total += data.getAdviceNum; const el = document.getElementById('gm-badge-sys'); if(el) { el.style.display = 'flex'; el.innerText = data.getAdviceNum; } } // 更新底部导航栏红点 const mainBadge = document.getElementById('gm-tab-badge-msg'); if(mainBadge) { if(total > 0) { mainBadge.style.display = 'flex'; mainBadge.innerText = total > 99 ? '99+' : total; } else { mainBadge.style.display = 'none'; } } } async loadMsgData(type) { const list = document.getElementById('gm-msg-list'); if(!list) return; if (!this.client.token) { list.innerHTML = '
请先登录
'; return; } list.innerHTML = '
加载中...
'; const msgs = await this.client.fetchMessages(type); if (!msgs || msgs.length === 0) { list.innerHTML = '
暂无消息
'; return; } list.innerHTML = msgs.map(msg => { const user = { nickName: msg.nickName || '系统', headPic: msg.headPic || 'https://www.gululu.world/favicon.ico' }; // 时间格式化 const date = new Date(msg.createTime); const timeStr = `${date.getMonth()+1}-${date.getDate()} ${date.getHours()}:${date.getMinutes().toString().padStart(2,'0')}`; // 构建跳转链接 let jumpUrl = ''; if(msg.opusId) { jumpUrl = `/book/${msg.opusId}`; if(msg.floorId) jumpUrl += `?floorId=${msg.floorId}`; } return `
${user.nickName} ${timeStr}
${msg.content || '(无内容)'}
${msg.title ? `
来源: ${msg.title}
` : ''}
`; }).join(''); } // === 书架逻辑 === initShelfTab() { // 绑定视图切换 const toggleBtn = this.root.querySelector('#gm-shelf-toggle'); if (toggleBtn) { toggleBtn.onclick = () => { this.shelfState.mode = this.shelfState.mode === 'list' ? 'grid' : 'list'; toggleBtn.innerHTML = this.shelfState.mode === 'list' ? '列表 ☰' : '网格 ☷'; this.renderShelf(); // 重新渲染,不重新请求 }; } this.loadShelfData(); } async loadShelfData(isMore = false) { const container = document.getElementById('gm-shelf-container'); if (!container) return; if (!this.client.token) { container.innerHTML = `
🔒
请先登录
去登录
`; setTimeout(() => { const btn = container.querySelector('#gm-shelf-login-btn'); if(btn) btn.onclick = () => this.initLoginModal(); }, 0); return; } if (this.shelfState.loading || (!isMore && !this.shelfState.hasMore)) return; if (isMore && !this.shelfState.hasMore) return; this.shelfState.loading = true; const newItems = await this.client.fetchBookshelf(this.shelfState.page); if (newItems && newItems.length > 0) { this.shelfState.data = [...this.shelfState.data, ...newItems]; this.shelfState.page++; this.renderShelf(); } else { this.shelfState.hasMore = false; const noMoreEl = document.getElementById('gm-shelf-nomore'); if(noMoreEl) noMoreEl.style.display = 'block'; if(!isMore && this.shelfState.data.length === 0) { container.innerHTML = '
书架空空如也
'; } } this.shelfState.loading = false; } renderShelf() { const container = document.getElementById('gm-shelf-container'); const data = this.shelfState.data; if(!container || data.length === 0) return; const PLACEHOLDER = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; if (this.shelfState.mode === 'list') { container.className = 'gm-result-list'; // 复用列表容器样式 container.innerHTML = data.map(book => { const progress = book.readInFloorInt || 0; const total = book.floorNum || 1; const percent = Math.min(100, Math.round((progress / total) * 100)); return `
${book.opusName}
${book.nickName}
${progress}/${total}
${book.updateTime.split(' ')[0]} 更新 ${book.enReadFloorInt > book.readInFloorInt ? '● 有新内容' : ''}
`; }).join(''); } else { container.className = 'gm-shelf-grid'; container.innerHTML = data.map(book => { const hasNew = book.enReadFloorInt > book.readInFloorInt; return `
${book.opusName}
${book.readInFloorInt}/${book.floorNum}
${hasNew ? '
NEW
' : ''}
`; }).join(''); } // 更新统计 const statEl = document.getElementById('gm-shelf-stat'); if(statEl) statEl.innerText = `我的书架 (${data.length})`; this.observeNewImages(container); } // === 我的作品逻辑 === initWorksTab() { this.loadWorksData(); } async loadWorksData() { const container = document.getElementById('gm-works-list'); if (!container) return; if (!this.client.token) { container.innerHTML = `
🔒
请先登录
去登录
`; setTimeout(() => { const btn = container.querySelector('#gm-works-login-btn'); if (btn) btn.onclick = () => this.initLoginModal(); }, 0); return; } container.innerHTML = '
加载中...
'; const works = await this.client.fetchMyWorks(); if (!works || works.length === 0) { container.innerHTML = '
暂无作品
'; return; } // 按更新时间降序排序 works.sort((a, b) => { const tA = new Date(a.updateTime.replace(/-/g, '/')).getTime(); const tB = new Date(b.updateTime.replace(/-/g, '/')).getTime(); return tB - tA; }); const PLACEHOLDER = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; container.innerHTML = works.map(w => `
${w.opusName}
${w.oneLineText || '暂无简介'}
${this.formatNum(w.words)}
字数
${this.formatNum(w.readNum)}
热度
${this.formatNum(w.collectNum)}
收藏
${this.formatNum(w.commentNum)}
评论
${w.floorNum}
楼层
ID
${w.opusId}
`).join(''); this.observeNewImages(container); } formatNum(num) { if (!num) return '0'; if (num > 10000) return (num / 10000).toFixed(1) + 'w'; return num; } // === 排行榜逻辑 === initRankTab() { const tabsContainer = this.root.querySelector('#gm-rank-tabs'); if(!tabsContainer) return; // 尚未渲染 // 绑定 Tab 点击 const tabs = tabsContainer.querySelectorAll('.gm-sub-tab'); tabs.forEach(tab => { tab.onclick = () => { const type = tab.dataset.type; if(this.rankState.type === type) return; // 切换高亮 tabs.forEach(t => t.classList.remove('active')); tab.classList.add('active'); // 重置状态 this.rankState.type = type; this.rankState.page = 1; this.rankState.hasMore = true; this.rankState.loading = false; // 清空列表 document.getElementById('gm-rank-list').innerHTML = '
加载中...
'; document.getElementById('gm-rank-nomore').style.display = 'none'; this.loadRankData(); }; }); // 初始加载 this.loadRankData(); } async loadRankData(isMore = false) { if (this.rankState.loading || (!isMore && !this.rankState.hasMore)) return; if (isMore && !this.rankState.hasMore) return; this.rankState.loading = true; const list = document.getElementById('gm-rank-list'); const type = this.rankState.type; // 如果是第一页,清空列表 (防止初始的"加载中"残留) if (!isMore && this.rankState.page === 1) { // list.innerHTML = ''; // 保持"加载中"提示直到数据回来 } let items = []; try { if (type === 'NEW') { // 新人榜无分页,一次性加载 if (this.rankState.page > 1) { this.rankState.hasMore = false; } else { items = await this.client.fetchNewcomerRank(); this.rankState.hasMore = false; // 新人榜只有一页 } } else { // 其他榜单 let sortId = 0; if (type === 'UPDATE') sortId = 1; else if (type === 'RELEASE') sortId = 2; else if (type === 'VIEW') sortId = 0; items = await this.client.fetchRankList(sortId, this.rankState.page); if (!items || items.length === 0) { this.rankState.hasMore = false; } else { this.rankState.page++; } } } catch(e) { console.error(e); this.rankState.hasMore = false; } // 渲染 if (!isMore && list.querySelector('div[style*="text-align:center"]')) { list.innerHTML = ''; } if (items && items.length > 0) { const PLACEHOLDER = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; const html = items.map(item => `
${item.opusName}
${item.nickName || item.authorName}
${item.tagNames ? item.tagNames.split(' ').map(t => `${t}`).join('') : ''}
${item.words}字 · ${item.readNum}热度 · ${item.updateTime}
`).join(''); list.insertAdjacentHTML('beforeend', html); this.observeNewImages(list); // 监听新加入的图片 } else if (!isMore) { list.innerHTML = '
暂无数据
'; } if (!this.rankState.hasMore) { document.getElementById('gm-rank-nomore').style.display = 'block'; } this.rankState.loading = false; } async fetchOpusAggregated(keyword) { const list = document.getElementById('gm-res-list'); list.innerHTML = '
正在聚合全站数据...
请求前10页
'; // 1. 并发请求前10页 const promises = []; for (let i = 1; i <= 10; i++) { promises.push(this.client.searchGeneral(keyword, 'OPUS', i)); } const results = await Promise.all(promises); // 2. 合并结果 let allItems = []; results.forEach(res => { if (res && res.items) allItems.push(...res.items); }); // 3. 客户端筛选 (字数) const minWords = this.searchOptions.minWords; if (minWords > 0) { allItems = allItems.filter(item => (item.words || 0) >= minWords); } // 4. 客户端排序 if (this.searchOptions.sort === 'time') { allItems.sort((a, b) => { const tA = new Date(a.updateTime.replace(/-/g, '/')).getTime(); const tB = new Date(b.updateTime.replace(/-/g, '/')).getTime(); return tB - tA; // 降序 }); } else { // 默认排序 } return allItems; } // 辅助:渲染高亮数组 [{text, color}, ...] renderHl(hlArr, defaultText) { if (!hlArr || hlArr.length === 0) return defaultText; return hlArr.map(h => `${h.text}`).join(''); } // 辅助:简单高亮 (用于用户搜索) highlight(text, key) { if(!key) return text; return text.replace(new RegExp(key, 'gi'), match => `${match}`); } // 辅助:渲染标签 (标签也是高亮数组格式) renderTagsHtml(hlArr, rawStr) { if (!hlArr || hlArr.length === 0) { return rawStr ? rawStr.split(' ').map(t => `${t}`).join('') : ''; } return rawStr.split(' ').map(t => `${t}`).join(''); } switchTab(tabId) { if (this.activeTab === tabId) return; this.activeTab = tabId; // 如果切换到消息页,立即刷新一下未读数 if (tabId === 'msg') { this.updateMsgBadges(); } // 更新底部高亮 this.root.querySelectorAll('.gm-tab-item').forEach(el => { el.classList.toggle('active', el.dataset.tab === tabId); }); // 更新内容显示 this.root.querySelectorAll('.gm-tab-content').forEach(el => { el.classList.toggle('active', el.id === `gm-tab-content-${tabId}`); }); } } // ========================================== // 4. 稳健启动 // ========================================== const client = new GululuApiClient(); const startApp = () => { const path = window.location.pathname; // 🏠 场景 A:首页 App 模式 if (path === '/' || path === '' || path === '/index.html') { // 尝试初始化数据以获取 Token client.initData(); const ui = new MobileHomeUI(client); ui.init(); return; } // 💬 场景 C:闲聊阅读模式 if (path.startsWith('/chat/')) { const chatId = path.split('/')[2]; if (chatId) { const data = client.initData(); // 获取数据 (包含 Token 和 页面数据) const ui = new ChatUI(client, chatId); // 如果 initData 返回了闲聊数据,直接使用;否则 UI 内部会尝试 fetch if (data && data.isChat) { ui.init(data); } else { ui.init(); } return; } } // 📖 场景 B:阅读器模式 if (path.startsWith('/book/')) { const initialFloors = client.initData(); if (initialFloors) { const ui = new MobileUI(client); ui.init(initialFloors); } else { setTimeout(() => { const retryFloors = client.initData(); if (retryFloors) { const ui = new MobileUI(client); ui.init(retryFloors); } else { let attempts = 0; const poller = setInterval(() => { const f = client.initData(); if (f) { clearInterval(poller); const ui = new MobileUI(client); ui.init(f); } if (++attempts > 20) clearInterval(poller); }, 500); } }, 500); } } }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', startApp); } else { startApp(); } })();