// ==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 = `
${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 += `${tag}>`;
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 ``;
});
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 = `
`;
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}
${subHtml}
`;
}
renderSubCommentItem(c, rootId) {
const user = c.fromUser || {};
const nickName = user.nickName || '未知用户';
const replyUser = c.replyUser ? `回复 @${c.replyUser.nickName}` : '';
return `
${nickName} ${replyUser}
${c.content}
`;
}
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 = `
${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 = `
${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 = `
`;
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}
`}).join('');
childrenHtml = `${childrenHtml}
`;
}
// 渲染层主评论 (Level 2)
return `
${sub.nickName} ${sub.parentName ? `▶ ${sub.parentName}` : ''}
${sub.content}
${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 = `
退出
骨碌碌
🌗
${displayName}
${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 `
`;
}
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 `
${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}
${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 => `
${this.formatNum(w.words)}
字数
${this.formatNum(w.readNum)}
热度
${this.formatNum(w.collectNum)}
收藏
${this.formatNum(w.commentNum)}
评论
`).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();
}
})();