1032 lines
37 KiB
JavaScript
Raw Normal View History

/**
*
* 光湖智库 · 前端主脚本 v2.0
* guanghu.online · ZY-PROJ-006
* 守护: 铸渊 · ICE-GL-ZY001
* 版权: 国作登字-2026-A-00037559
*
* v2.0: 邮箱登录 + 真实搜索下载 + Agent对话 + 登录态管理
*
*/
'use strict';
const API_BASE = '/api';
const REQUEST_TIMEOUT_MS = 15000;
let currentToken = localStorage.getItem('zhiku_token') || null;
let currentEmail = localStorage.getItem('zhiku_email') || null;
let codeCooldown = 0;
let codeCooldownTimer = null;
/* ═══ 星空背景 ═══ */
(function initStars() {
const canvas = document.getElementById('starCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let stars = [];
const STAR_COUNT = 200;
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
stars = Array.from({ length: STAR_COUNT }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
r: Math.random() * 1.2 + 0.3,
a: Math.random(),
da: (Math.random() - 0.5) * 0.008
}));
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const s of stars) {
s.a += s.da;
if (s.a > 1 || s.a < 0.1) s.da = -s.da;
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(200,210,255,${s.a * 0.5})`;
ctx.fill();
}
requestAnimationFrame(draw);
}
resize();
window.addEventListener('resize', resize);
draw();
})();
/* ═══ Header 滚动阴影 ═══ */
document.querySelector('.content')?.addEventListener('scroll', function() {
const header = document.getElementById('header');
if (header) header.classList.toggle('scrolled', this.scrollTop > 10);
});
/* ═══ 工具函数 ═══ */
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
function showLoginMsg(text, type) {
const el = document.getElementById('loginMsg');
if (!el) return;
el.textContent = text;
el.className = 'login-msg ' + (type || '');
}
/*
* 邮箱登录系统 · Email Auth
* */
/** 发送验证码 */
async function loginSendCode() {
const emailInput = document.getElementById('loginEmail');
const email = (emailInput?.value || '').trim();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
showLoginMsg('请输入有效的邮箱地址', 'err');
return;
}
const sendBtn = document.getElementById('loginSendBtn');
const sendBtn2 = document.getElementById('loginSendBtn2');
if (sendBtn) sendBtn.disabled = true;
if (sendBtn2) sendBtn2.disabled = true;
showLoginMsg('正在发送验证码...', 'info');
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const res = await fetch(`${API_BASE}/auth/send-code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
signal: controller.signal
});
clearTimeout(timeout);
if (!res.ok && res.status >= 500) {
showLoginMsg('服务器暂时不可用,请稍后重试', 'err');
if (sendBtn) sendBtn.disabled = false;
if (sendBtn2) sendBtn2.disabled = false;
return;
}
let data;
try {
data = await res.json();
} catch (jsonErr) {
console.error('[zhiku] send-code JSON parse failed:', jsonErr);
showLoginMsg('服务器响应异常,请稍后重试', 'err');
if (sendBtn) sendBtn.disabled = false;
if (sendBtn2) sendBtn2.disabled = false;
return;
}
if (data.error) {
showLoginMsg(data.message || '发送失败', 'err');
if (sendBtn) sendBtn.disabled = false;
if (sendBtn2) sendBtn2.disabled = false;
return;
}
showLoginMsg('验证码已发送,请查收邮件', 'ok');
// 显示验证码输入区
const codeRow = document.getElementById('loginCodeRow');
if (codeRow) codeRow.style.display = 'flex';
if (sendBtn) sendBtn.style.display = 'none';
const verifyBtn = document.getElementById('loginVerifyBtn');
if (verifyBtn) verifyBtn.style.display = 'block';
// 禁用邮箱输入
if (emailInput) emailInput.readOnly = true;
// 60秒冷却倒计时
codeCooldown = 60;
if (codeCooldownTimer) clearInterval(codeCooldownTimer);
codeCooldownTimer = setInterval(() => {
codeCooldown--;
if (sendBtn2) {
if (codeCooldown > 0) {
sendBtn2.textContent = `${codeCooldown}s`;
sendBtn2.disabled = true;
} else {
sendBtn2.textContent = '重新发送';
sendBtn2.disabled = false;
clearInterval(codeCooldownTimer);
codeCooldownTimer = null;
}
}
}, 1000);
// 自动聚焦验证码输入
document.getElementById('loginCode')?.focus();
} catch (err) {
console.error('[zhiku] send-code fetch error:', err.name, err.message);
let msg = '网络连接失败,请检查网络后重试';
if (err.name === 'AbortError') {
msg = '请求超时,请检查网络后重试';
} else if (err.name === 'TypeError') {
// Safari/iOS 连接失败时抛出 TypeError错误信息不友好
msg = '服务器连接失败,请稍后重试';
} else if (err.message) {
msg = '连接失败,请稍后重试';
}
showLoginMsg(msg, 'err');
if (sendBtn) sendBtn.disabled = false;
if (sendBtn2) sendBtn2.disabled = false;
}
}
/** 验证码校验 → 登录 */
async function loginVerify() {
const email = (document.getElementById('loginEmail')?.value || '').trim();
const code = (document.getElementById('loginCode')?.value || '').trim();
if (!email || !code) {
showLoginMsg('请输入邮箱和验证码', 'err');
return;
}
if (!/^\d{6}$/.test(code)) {
showLoginMsg('验证码为6位数字', 'err');
return;
}
const verifyBtn = document.getElementById('loginVerifyBtn');
if (verifyBtn) verifyBtn.disabled = true;
showLoginMsg('验证中...', 'info');
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const res = await fetch(`${API_BASE}/auth/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, code }),
signal: controller.signal
});
clearTimeout(timeout);
let data;
try {
data = await res.json();
} catch (jsonErr) {
console.error('[zhiku] verify JSON parse failed:', jsonErr);
showLoginMsg('服务器响应异常,请稍后重试', 'err');
if (verifyBtn) verifyBtn.disabled = false;
return;
}
if (data.error) {
showLoginMsg(data.message || '验证失败', 'err');
if (verifyBtn) verifyBtn.disabled = false;
return;
}
// 登录成功
currentToken = data.token;
currentEmail = data.email || email;
localStorage.setItem('zhiku_token', currentToken);
localStorage.setItem('zhiku_email', currentEmail);
showLoginMsg('登录成功 · 欢迎来到光湖智库', 'ok');
// 切换到主页面
setTimeout(() => {
enterMainPage();
}, 600);
} catch (err) {
console.error('[zhiku] verify fetch error:', err.name, err.message);
let msg = '网络连接失败,请检查网络后重试';
if (err.name === 'AbortError') {
msg = '请求超时,请检查网络后重试';
} else if (err.name === 'TypeError') {
msg = '服务器连接失败,请稍后重试';
} else if (err.message) {
msg = '连接失败,请稍后重试';
}
showLoginMsg(msg, 'err');
if (verifyBtn) verifyBtn.disabled = false;
}
}
/** 退出登录 */
async function doLogout() {
try {
await fetch(`${API_BASE}/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentToken}`
}
});
} catch {}
currentToken = null;
currentEmail = null;
localStorage.removeItem('zhiku_token');
localStorage.removeItem('zhiku_email');
// 重置登录表单
const emailInput = document.getElementById('loginEmail');
if (emailInput) { emailInput.value = ''; emailInput.readOnly = false; }
const codeInput = document.getElementById('loginCode');
if (codeInput) codeInput.value = '';
const codeRow = document.getElementById('loginCodeRow');
if (codeRow) codeRow.style.display = 'none';
const sendBtn = document.getElementById('loginSendBtn');
if (sendBtn) { sendBtn.style.display = 'block'; sendBtn.disabled = false; }
const verifyBtn = document.getElementById('loginVerifyBtn');
if (verifyBtn) { verifyBtn.style.display = 'none'; verifyBtn.disabled = false; }
showLoginMsg('', '');
// 显示登录弹窗,隐藏主页面
document.getElementById('loginOverlay').style.display = 'flex';
document.getElementById('mainPage').style.display = 'none';
}
/** 进入主页面 */
function enterMainPage() {
document.getElementById('loginOverlay').style.display = 'none';
document.getElementById('mainPage').style.display = 'block';
// 更新用户栏
const userBarEmail = document.getElementById('userBarEmail');
if (userBarEmail) userBarEmail.textContent = currentEmail || '—';
// 加载健康状态
checkHealth();
}
/** 校验已存储的会话 */
async function checkSession() {
if (!currentToken) return false;
try {
const res = await fetch(`${API_BASE}/auth/session`, {
headers: { 'Authorization': `Bearer ${currentToken}` }
});
const data = await res.json();
if (!data.error && data.user) {
currentEmail = data.user.email || currentEmail;
localStorage.setItem('zhiku_email', currentEmail);
return true;
}
} catch {}
// Token 失效
currentToken = null;
currentEmail = null;
localStorage.removeItem('zhiku_token');
localStorage.removeItem('zhiku_email');
return false;
}
/* ═══ 验证码输入框回车确认 ═══ */
document.getElementById('loginCode')?.addEventListener('keydown', e => {
if (e.key === 'Enter') loginVerify();
});
document.getElementById('loginEmail')?.addEventListener('keydown', e => {
if (e.key === 'Enter') loginSendCode();
});
/*
* API 健康检查
* */
async function checkHealth() {
const el = document.getElementById('apiStatus');
try {
const res = await fetch(`${API_BASE}/health`);
const data = await res.json();
if (data.status === 'ok') {
// 计算数据源在线状态
const sources = data.data_sources || [];
const onlineSources = sources.filter(s => s.reachable);
const sourceSummary = onlineSources.length + '/' + sources.length;
el.textContent = `● API 在线 · ${data.uptime_human || ''} · 数据源 ${sourceSummary}`;
el.className = 'api-status ok';
// 如果有数据源离线,改为警告色
if (onlineSources.length < sources.length) {
el.className = 'api-status warn';
const offlineSources = sources.filter(s => !s.reachable).map(s => s.name).join(', ');
el.title = `离线数据源: ${offlineSources}`;
}
const statBooks = document.getElementById('statBooks');
const statUptime = document.getElementById('statUptime');
const statSources = document.getElementById('statSources');
const statCos = document.getElementById('statCos');
if (statBooks) statBooks.textContent = data.books_count || 0;
if (statUptime) statUptime.textContent = data.uptime_human || '-';
if (statSources) statSources.textContent = sourceSummary;
if (statCos) statCos.textContent = data.cos_configured ? '已连接' : '未配置';
} else {
throw new Error('not ok');
}
} catch {
if (el) {
el.textContent = '● API 离线';
el.className = 'api-status err';
}
}
}
setInterval(checkHealth, 30000);
/*
* 搜索功能 · 真实数据源搜索
* */
/** 构建数据源状态提示 HTML */
function buildSourceStatusHtml(sourceStatus) {
if (!sourceStatus || !sourceStatus.length) return '';
const errorSources = sourceStatus.filter(s => s.status === 'error');
if (errorSources.length === 0) return '';
const tips = errorSources.map(s => {
const name = escapeHtml(s.name);
const err = s.error || '';
const errMsg = err.includes('timeout') ? '连接超时' :
err.includes('ECONNREFUSED') ? '服务未启动' :
err.includes('ENOTFOUND') ? 'DNS解析失败' :
err.includes('ECONNRESET') ? '连接被重置' :
'不可达';
return `<span class="source-err-tag">${name}: ${errMsg}</span>`;
}).join(' ');
return `<div class="source-status-bar">⚠️ 部分数据源离线: ${tips} · 仅本地书库可用</div>`;
}
async function doSearch() {
const q = document.getElementById('searchInput').value.trim();
const resultDiv = document.getElementById('searchResults');
if (!q) {
resultDiv.innerHTML = '<div class="no-results">请输入书名或作者名</div>';
resultDiv.style.display = 'block';
return;
}
if (!currentToken) {
resultDiv.innerHTML = '<div class="no-results">请先登录后再搜索</div>';
resultDiv.style.display = 'block';
return;
}
resultDiv.innerHTML = '<div class="no-results search-loading">🔍 正在搜索「' + escapeHtml(q) + '」...</div>';
resultDiv.style.display = 'block';
try {
const res = await fetch(`${API_BASE}/search?q=${encodeURIComponent(q)}`, {
headers: { 'Authorization': `Bearer ${currentToken}` }
});
let data;
try {
data = await res.json();
} catch (jsonErr) {
resultDiv.innerHTML = '<div class="no-results">⚠️ 服务器响应异常,请稍后重试</div>';
return;
}
if (data.error) {
if (data.code === 'TOKEN_EXPIRED' || data.code === 'TOKEN_INVALID' || data.code === 'NO_TOKEN') {
resultDiv.innerHTML = '<div class="no-results">⚠️ 登录已过期,请重新登录</div>';
setTimeout(() => doLogout(), 2000);
return;
}
resultDiv.innerHTML = '<div class="no-results">⚠️ ' + escapeHtml(data.message) + '</div>';
} else if (!data.results || data.results.length === 0) {
// 空结果时展示数据源状态,让用户知道为什么搜不到
const statusHtml = buildSourceStatusHtml(data.source_status);
const localInfo = (data.source_status || []).find(s => s.id === 'local');
const localTotal = localInfo ? localInfo.books_total || 0 : 0;
resultDiv.innerHTML = statusHtml +
'<div class="no-results">' +
(statusHtml ? '数据源离线时只能搜索本地书库(当前收录 ' + localTotal + ' 本)' :
'未找到匹配书籍 · 可尝试其他关键词') +
'</div>';
} else {
const statusHtml = buildSourceStatusHtml(data.source_status);
const sourceColors = { local: '#34d399', fanqie: '#fbbf24', qimao: '#a78bfa', shu69: '#fb923c' };
resultDiv.innerHTML = statusHtml +
'<div class="search-summary">找到 ' + data.total + ' 个结果 · 来源: ' + (data.sources_queried || []).join(', ') + '</div>' +
data.results.map(book => {
const sourceColor = sourceColors[book.source] || '#94a7d0';
const sourceName = book.source_name || book.source;
const hasFile = book.has_file;
const downloadAttr = !hasFile && book.source_book_id
? ` data-source="${escapeHtml(book.source)}" data-bookid="${escapeHtml(book.source_book_id)}" data-title="${escapeHtml(book.title)}" data-author="${escapeHtml(book.author || '')}"`
: '';
return `<div class="search-result-card">
<div class="result-info">
<div class="result-title">📕 ${escapeHtml(book.title)}</div>
<div class="result-meta">
${escapeHtml(book.author || '未知作者')}
${book.category ? ' · ' + escapeHtml(book.category) : ''}
${book.word_count ? ' · ' + (book.word_count > 10000 ? Math.round(book.word_count / 10000) + '万字' : book.word_count + '字') : ''}
</div>
<div class="result-source">
<span class="source-badge" style="border-color:${sourceColor};color:${sourceColor}">${sourceName}</span>
${hasFile ? '<span class="source-badge local-badge">已收录</span>' : ''}
</div>
</div>
<div class="result-actions">
${hasFile
? `<button class="result-btn result-btn-read" onclick="readBook('${escapeHtml(book.id)}')">📖 阅读</button>
<button class="result-btn result-btn-dl" onclick="downloadLocal('${escapeHtml(book.id)}')"> 下载</button>`
: (book.source_book_id
? `<button class="result-btn result-btn-read" onclick="readBook('${escapeHtml(book.id)}')">📖 在线阅读</button>
<button class="result-btn result-btn-dl" onclick="startDownload('${escapeHtml(book.source)}','${escapeHtml(book.source_book_id)}','${escapeHtml(book.title)}','${escapeHtml(book.author || '')}')" ${downloadAttr}> 下载到智库</button>`
: '')
}
</div>
</div>`;
}).join('');
}
} catch (err) {
resultDiv.innerHTML = '<div class="no-results">搜索失败: ' + escapeHtml(err.message) + '</div>';
}
}
document.getElementById('searchInput')?.addEventListener('keydown', e => {
if (e.key === 'Enter') doSearch();
});
/*
* 下载功能
* */
/** 启动从数据源下载到COS */
async function startDownload(source, sourceBookId, title, author) {
if (!currentToken) { alert('请先登录'); return; }
// 确认下载
const sourceNames = { fanqie: '番茄小说', qimao: '七猫小说', shu69: '69书吧' };
if (!confirm(`确认下载「${title}」(${sourceNames[source] || source}) 到智库?`)) return;
try {
const res = await fetch(`${API_BASE}/download/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentToken}`
},
body: JSON.stringify({ source, source_book_id: sourceBookId, title, author })
});
const data = await res.json();
if (data.error) {
alert('下载失败: ' + data.message);
return;
}
// 弹出进度弹窗
showDownloadProgress(data.task_id, title);
} catch (err) {
alert('下载请求失败: ' + err.message);
}
}
/** 下载进度轮询 */
function showDownloadProgress(taskId, title) {
const resultDiv = document.getElementById('searchResults');
const progressHtml = `
<div class="download-progress-card" id="dl-${taskId}">
<div class="dl-title"> 正在下载${escapeHtml(title)}</div>
<div class="dl-bar-wrap"><div class="dl-bar" id="dl-bar-${taskId}" style="width:0%"></div></div>
<div class="dl-status" id="dl-status-${taskId}">排队中...</div>
</div>
`;
// 在搜索结果顶部插入
resultDiv.insertAdjacentHTML('afterbegin', progressHtml);
// 轮询进度
const pollInterval = setInterval(async () => {
try {
const res = await fetch(`${API_BASE}/download/status/${taskId}`, {
headers: { 'Authorization': `Bearer ${currentToken}` }
});
const data = await res.json();
const bar = document.getElementById(`dl-bar-${taskId}`);
const status = document.getElementById(`dl-status-${taskId}`);
if (bar) bar.style.width = (data.progress || 0) + '%';
if (status) status.textContent = data.message || data.status;
if (data.status === 'completed') {
clearInterval(pollInterval);
const card = document.getElementById(`dl-${taskId}`);
if (card) {
card.classList.add('dl-done');
if (status) status.textContent = '✅ 下载完成 · 已收录到智库';
}
} else if (data.status === 'failed') {
clearInterval(pollInterval);
if (status) status.textContent = '❌ ' + (data.message || '下载失败');
}
} catch {
// 网络异常时静默重试
}
}, 2000);
// 5分钟超时保护
setTimeout(() => clearInterval(pollInterval), 300000);
}
/** 本地已收录书籍下载 */
async function downloadLocal(bookId) {
if (!currentToken) return;
try {
// 优先尝试本地下载不依赖COS
const res = await fetch(`${API_BASE}/download/local/${encodeURIComponent(bookId)}`, {
headers: { 'Authorization': `Bearer ${currentToken}` }
});
// 如果返回的是文件流text/plain直接触发下载
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('text/plain')) {
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const disposition = res.headers.get('content-disposition') || '';
const filenameMatch = disposition.match(/filename\*=UTF-8''(.+)/);
a.href = url;
a.download = filenameMatch ? decodeURIComponent(filenameMatch[1]) : 'book.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
return;
}
const data = await res.json();
if (data.error) {
// 回退到旧版COS下载
const res2 = await fetch(`${API_BASE}/download/${encodeURIComponent(bookId)}`, {
headers: { 'Authorization': `Bearer ${currentToken}` }
});
const data2 = await res2.json();
if (data2.error) {
alert(data2.message);
} else if (data2.download_url) {
window.open(data2.download_url, '_blank');
}
} else if (data.download_url) {
window.open(data.download_url, '_blank');
}
} catch (err) {
alert('下载失败: ' + err.message);
}
}
/*
* 在线阅读器 · Online Reader
* */
let readerState = {
bookId: '',
source: '',
sourceBookId: '',
title: '',
chapters: [],
currentChapter: 0,
fontSize: 18,
theme: 'dark' // 'dark' | 'light'
};
/** 打开在线阅读器 */
async function readBook(bookId) {
if (!currentToken) {
alert('请先登录后再阅读');
return;
}
// 解析 bookId 获取 source 和 source_book_id
// bookId 格式: "fq-123456" 或 "qm-789" 或 "fanqie-123456"
let source = '';
let sourceBookId = '';
if (bookId.startsWith('fq-')) {
source = 'fanqie';
sourceBookId = bookId.replace('fq-', '');
} else if (bookId.startsWith('qm-')) {
source = 'qimao';
sourceBookId = bookId.replace('qm-', '');
} else if (bookId.includes('-')) {
const parts = bookId.split('-');
source = parts[0];
sourceBookId = parts.slice(1).join('-');
} else {
source = 'fanqie';
sourceBookId = bookId;
}
readerState.bookId = bookId;
readerState.source = source;
readerState.sourceBookId = sourceBookId;
readerState.currentChapter = 0;
// 显示阅读器
document.getElementById('readerOverlay').style.display = 'flex';
document.getElementById('readerContent').innerHTML = '<div class="reader-loading">📖 正在获取目录...</div>';
document.getElementById('readerChapterList').innerHTML = '<div class="reader-loading">加载中...</div>';
document.getElementById('readerNav').style.display = 'none';
try {
const res = await fetch(`${API_BASE}/reader/catalog/${encodeURIComponent(source)}/${encodeURIComponent(sourceBookId)}`, {
headers: { 'Authorization': `Bearer ${currentToken}` }
});
const data = await res.json();
if (data.error) {
document.getElementById('readerContent').innerHTML = `<div class="reader-error">⚠️ ${escapeHtml(data.message)}</div>`;
return;
}
readerState.chapters = data.chapters || [];
readerState.title = (data.book && data.book.title) || '未知书名';
document.getElementById('readerBookTitle').textContent = readerState.title;
if (readerState.chapters.length === 0) {
document.getElementById('readerContent').innerHTML = '<div class="reader-error">📭 未找到章节目录</div>';
document.getElementById('readerChapterList').innerHTML = '<div class="reader-empty">暂无章节</div>';
return;
}
// 渲染章节列表
renderChapterList();
// 加载第一章
loadChapter(0);
} catch (err) {
document.getElementById('readerContent').innerHTML = `<div class="reader-error">加载失败: ${escapeHtml(err.message)}</div>`;
}
}
/** 渲染章节侧边栏 */
function renderChapterList() {
const list = document.getElementById('readerChapterList');
list.innerHTML = readerState.chapters.map((ch, i) =>
`<div class="reader-chapter-item${i === readerState.currentChapter ? ' active' : ''}" data-index="${i}" onclick="loadChapter(${i})">
<span class="ch-index">${i + 1}</span>
<span class="ch-title">${escapeHtml(ch.title || '第' + (i + 1) + '章')}</span>
</div>`
).join('');
}
/** 加载指定章节 */
async function loadChapter(index) {
if (index < 0 || index >= readerState.chapters.length) return;
readerState.currentChapter = index;
const ch = readerState.chapters[index];
// 更新UI状态
document.getElementById('readerContent').innerHTML = '<div class="reader-loading">📖 加载章节中...</div>';
document.getElementById('readerNav').style.display = 'flex';
document.getElementById('readerNavInfo').textContent = `${index + 1} / ${readerState.chapters.length}`;
document.getElementById('readerPrevBtn').disabled = index === 0;
document.getElementById('readerNextBtn').disabled = index === readerState.chapters.length - 1;
// 更新章节列表高亮
const items = document.querySelectorAll('.reader-chapter-item');
items.forEach(el => el.classList.toggle('active', parseInt(el.dataset.index) === index));
// 滚动章节列表到可见位置
const activeItem = document.querySelector('.reader-chapter-item.active');
if (activeItem) activeItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
try {
const params = new URLSearchParams({
source: ch.source || readerState.source,
item_id: ch.item_id || '',
book_id: readerState.bookId
});
const res = await fetch(`${API_BASE}/reader/chapter?${params}`, {
headers: { 'Authorization': `Bearer ${currentToken}` }
});
const data = await res.json();
if (data.error) {
document.getElementById('readerContent').innerHTML = `<div class="reader-error">⚠️ ${escapeHtml(data.message)}</div>`;
return;
}
const content = data.content || '(空白章节)';
const title = ch.title || `${index + 1}`;
document.getElementById('readerContent').innerHTML =
`<h2 class="reader-chapter-title">${escapeHtml(title)}</h2>` +
`<div class="reader-text" style="font-size:${readerState.fontSize}px">${formatReaderText(content)}</div>`;
// 滚动到顶部
document.getElementById('readerBody').scrollTop = 0;
} catch (err) {
document.getElementById('readerContent').innerHTML = `<div class="reader-error">加载失败: ${escapeHtml(err.message)}</div>`;
}
}
function readerPrevChapter() {
if (readerState.currentChapter > 0) loadChapter(readerState.currentChapter - 1);
}
function readerNextChapter() {
if (readerState.currentChapter < readerState.chapters.length - 1) loadChapter(readerState.currentChapter + 1);
}
function closeReader() {
document.getElementById('readerOverlay').style.display = 'none';
}
function readerFontSmaller() {
readerState.fontSize = Math.max(12, readerState.fontSize - 2);
const textEl = document.querySelector('.reader-text');
if (textEl) textEl.style.fontSize = readerState.fontSize + 'px';
}
function readerFontLarger() {
readerState.fontSize = Math.min(32, readerState.fontSize + 2);
const textEl = document.querySelector('.reader-text');
if (textEl) textEl.style.fontSize = readerState.fontSize + 'px';
}
function toggleReaderTheme() {
const container = document.querySelector('.reader-container');
const btn = document.getElementById('readerThemeBtn');
if (readerState.theme === 'dark') {
readerState.theme = 'light';
container.classList.add('reader-light');
btn.textContent = '🌞';
} else {
readerState.theme = 'dark';
container.classList.remove('reader-light');
btn.textContent = '🌙';
}
}
function formatReaderText(text) {
// 将纯文本格式化为适合阅读的HTML
return text
.split('\n')
.map(line => {
line = line.trim();
if (!line) return '';
return `<p class="reader-para">${escapeHtml(line)}</p>`;
})
.filter(Boolean)
.join('');
}
/* 阅读器键盘快捷键 */
document.addEventListener('keydown', (e) => {
const overlay = document.getElementById('readerOverlay');
if (!overlay || overlay.style.display === 'none') return;
if (e.key === 'Escape') closeReader();
if (e.key === 'ArrowLeft') readerPrevChapter();
if (e.key === 'ArrowRight') readerNextChapter();
});
/*
* AI图书管理员Agent · 对话交互
* */
let agentSending = false;
async function agentSend() {
const input = document.getElementById('agentInput');
const msg = (input?.value || '').trim();
if (!msg || agentSending) return;
if (!currentToken) {
appendAgentMessage('bot', '请先登录后再和我对话 📚');
return;
}
// 显示用户消息
appendAgentMessage('user', msg);
input.value = '';
agentSending = true;
// 显示thinking状态
const thinkingId = appendAgentMessage('bot', '思考中...', true);
try {
const res = await fetch(`${API_BASE}/agent/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentToken}`
},
body: JSON.stringify({ message: msg })
});
const data = await res.json();
// 移除thinking
removeAgentMessage(thinkingId);
if (data.error) {
if (data.code === 'TOKEN_EXPIRED' || data.code === 'NO_TOKEN') {
appendAgentMessage('bot', '登录已过期,请重新登录');
setTimeout(() => doLogout(), 2000);
} else {
appendAgentMessage('bot', '⚠️ ' + (data.message || 'Agent异常'));
}
} else {
// 显示Agent回复
appendAgentMessage('bot', data.reply || '(无回复)');
// 处理工具调用结果
if (data.tool_results && data.tool_results.length > 0) {
for (const tool of data.tool_results) {
if (tool.type === 'search' && tool.results) {
appendAgentSearchResults(tool.results);
}
if (tool.type === 'download') {
appendAgentMessage('bot', `⬇️ 已创建下载任务: 「${tool.title}」(任务ID: ${tool.task_id})`);
showDownloadProgress(tool.task_id, tool.title);
}
}
}
}
} catch (err) {
removeAgentMessage(thinkingId);
appendAgentMessage('bot', '网络错误: ' + err.message);
}
agentSending = false;
}
let agentMsgCounter = 0;
function appendAgentMessage(role, text, isThinking) {
const container = document.getElementById('agentMessages');
if (!container) return;
const id = 'amsg-' + (++agentMsgCounter);
const div = document.createElement('div');
div.className = `agent-msg agent-msg-${role}${isThinking ? ' agent-msg-thinking' : ''}`;
div.id = id;
const content = document.createElement('div');
content.className = 'agent-msg-content';
content.innerHTML = isThinking ? '<span class="thinking-dots">●●●</span>' : formatAgentText(text);
div.appendChild(content);
container.appendChild(div);
// 自动滚动到底部
container.scrollTop = container.scrollHeight;
return id;
}
function removeAgentMessage(id) {
const el = document.getElementById(id);
if (el) el.remove();
}
function formatAgentText(text) {
// 增强 Markdown 渲染 · 书岚风格
let html = escapeHtml(text);
// 分隔线
html = html.replace(/^---$/gm, '<hr class="shulan-divider">');
// 引用块
html = html.replace(/^&gt;\s*(.+)$/gm, '<blockquote class="shulan-quote">$1</blockquote>');
// 粗体
html = html.replace(/\*\*(.+?)\*\*/g, '<strong class="shulan-bold">$1</strong>');
// 斜体
html = html.replace(/\*(.+?)\*/g, '<em class="shulan-italic">$1</em>');
// 行内代码
html = html.replace(/`(.+?)`/g, '<code>$1</code>');
// 书名号高亮(限制匹配长度避免性能问题)
html = html.replace(/《([^》]{1,100})》/g, '<span class="shulan-book-title">《$1》</span>');
// 换行
html = html.replace(/\n/g, '<br>');
return html;
}
function appendAgentSearchResults(results) {
const container = document.getElementById('agentMessages');
if (!container || !results.length) return;
const div = document.createElement('div');
div.className = 'agent-msg agent-msg-bot';
div.innerHTML = `<div class="agent-msg-content agent-search-results">
<div class="agent-search-title">📜 找到 ${results.length} </div>
${results.slice(0, 8).map(book => {
const sourceName = book.source_name || book.source;
const sourceClass = book.source === 'fanqie' ? 'fanqie' : (book.source === 'qimao' ? 'qimao' : 'local');
const hasFile = book.has_file;
// 操作按钮 · 搜到书后直接展示「在线阅读」和「下载」
let actionBtns = '';
if (hasFile) {
actionBtns = `
<button class="shulan-action-btn shulan-btn-read" onclick="readBook('${escapeHtml(book.id || '')}')">📖 在线阅读</button>
<button class="shulan-action-btn shulan-btn-dl" onclick="downloadLocal('${escapeHtml(book.id || '')}')"> 下载</button>
<span class="agent-local-tag">已收录</span>`;
} else if (book.source_book_id) {
actionBtns = `
<button class="shulan-action-btn shulan-btn-dl" onclick="startDownload('${escapeHtml(book.source)}','${escapeHtml(book.source_book_id)}','${escapeHtml(book.title)}','${escapeHtml(book.author || '')}')"> 下载到智库</button>`;
}
return `<div class="shulan-book-card">
<div class="shulan-book-info">
<span class="shulan-book-name">📖 ${escapeHtml(book.title)}</span>
<span class="shulan-book-meta">${escapeHtml(book.author || '')} · <span class="source-badge ${sourceClass}">${sourceName}</span>${book.word_count ? ' · ' + book.word_count : ''}</span>
</div>
<div class="shulan-book-actions">${actionBtns}</div>
</div>`;
}).join('')}
</div>`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
/* Agent输入框事件 */
document.getElementById('agentInput')?.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
agentSend();
}
});
/*
* 初始化 · 自动恢复登录态
* */
(async function init() {
if (currentToken) {
const valid = await checkSession();
if (valid) {
enterMainPage();
} else {
// Token失效显示登录
document.getElementById('loginOverlay').style.display = 'flex';
document.getElementById('mainPage').style.display = 'none';
}
} else {
document.getElementById('loginOverlay').style.display = 'flex';
document.getElementById('mainPage').style.display = 'none';
}
})();