/** * ═══════════════════════════════════════════ * 光湖智库 · 前端主脚本 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 `${name}: ${errMsg}`; }).join(' '); return `
`; } async function doSearch() { const q = document.getElementById('searchInput').value.trim(); const resultDiv = document.getElementById('searchResults'); if (!q) { resultDiv.innerHTML = '${escapeHtml(line)}
`; }) .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 ? '●●●' : 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, '$1'); // 粗体 html = html.replace(/\*\*(.+?)\*\*/g, '$1'); // 斜体 html = html.replace(/\*(.+?)\*/g, '$1'); // 行内代码 html = html.replace(/`(.+?)`/g, '
$1');
// 书名号高亮(限制匹配长度避免性能问题)
html = html.replace(/《([^》]{1,100})》/g, '《$1》');
// 换行
html = html.replace(/\n/g, '