1032 lines
37 KiB
JavaScript
1032 lines
37 KiB
JavaScript
/**
|
||
* ═══════════════════════════════════════════
|
||
* 光湖智库 · 前端主脚本 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(/^>\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';
|
||
}
|
||
})();
|