🚚 移动前端

This commit is contained in:
shufeng2012 committed 2026-08-08 19:09:36 +08:00
1 parent c8f2152d0a
commit b851a96a66
57 files changed
+214 -418

No files matched your search

+435
View File
@@ -0,0 +1,435 @@
'use strict';
/* ============================================================
* aibox Web 客户端 · 移动端版(经 gateway WS 反向代理,仅管理员可用)
* 协议与 BotOps/aibox client.py 完全兼容:
* 发送:{"type":"message","content":...} / {"type":"command","command":"/..."} / {"type":"interrupt",...}
* 接收:chunk(增量片段) / reasoning_start / reasoning / reasoning_end / ai_break /
* complete / line(过程行) / task_process / task_status / task_error / task_added /
* dialogue_ended / dialogue_reset / interrupt_ack / error / info /
* command_result / connection_ack / notification / pong
* ============================================================ */
/* gateway [WsProxy] routes 中配置的路径(HttpOnly q4_token Cookie 随握手自动携带,JS 不经手 Token)。
* 需与 gateway.ini 的 [WsProxy] routes 条目一致,如:routes = /ws/aibox=ws://127.0.0.1:8765; */
const WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/aibox';
const RECONNECT_MIN = 2000; /* 指数退避起点(2s→30s 封顶) */
const RECONNECT_MAX = 30000;
const REASONING_TAIL = 3000; /* 思考区域超长时仅显示末尾字符数 */
/* ---------- Markdown 渲染(链接一律新标签打开且不带 referrer) ---------- */
const md = window.markdownit({ html: false, linkify: true, breaks: true });
const defaultLinkOpen = md.renderer.rules.link_open || ((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options));
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
tokens[idx].attrSet('target', '_blank');
tokens[idx].attrSet('rel', 'noopener noreferrer');
return defaultLinkOpen(tokens, idx, options, env, self);
};
/* ---------- DOM ---------- */
const chatEl = document.getElementById('chat');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send-btn');
const interruptBtn = document.getElementById('interrupt-btn');
const connBtn = document.getElementById('conn-btn');
const statusEl = document.getElementById('conn-status');
const statusText = document.getElementById('status-text');
const taskPanel = document.getElementById('task-panel');
const taskList = document.getElementById('task-list');
const taskToggle = document.getElementById('task-toggle');
const taskBadge = document.getElementById('task-badge');
const backdrop = document.getElementById('backdrop');
/* ---------- 状态 ---------- */
let ws = null;
let manualClose = false; /* 手动断开则抑制自动重连 */
let reconnectDelay = RECONNECT_MIN;
let reconnectTimer = null;
let heartbeatTimer = null; /* 心跳定时器,10s 发送 ping 保持 WS 连接 */
const HEARTBEAT_INTERVAL = 10000; /* 心跳间隔:需低于前置代理(Nginx/CDN)的 proxy_read_timeout */
function startHeartbeat() {
stopHeartbeat();
heartbeatTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, HEARTBEAT_INTERVAL);
}
function stopHeartbeat() {
if (heartbeatTimer) { clearTimeout(heartbeatTimer); heartbeatTimer = null; }
}
/* 流式状态:不预创建空气泡,第一个 chunk 到来才挂载(保证思考区必然在正式回答之前) */
let aiBubble = null, aiText = '', aiActive = false;
let reasoningBox = null, reasoningBody = null, reasoningHead = null, reasoningText = '';
let unreadTasks = 0;
/* ---------- 滚动(距底 80px 内才自动跟随,用户上翻阅读不打断) ---------- */
function nearBottom() {
return chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 80;
}
function scrollToBottom(force) {
if (force || nearBottom()) chatEl.scrollTop = chatEl.scrollHeight;
}
/* ---------- 系统消息 / 任务面板行 ---------- */
function addSystem(text, isError) {
const div = document.createElement('div');
div.className = isError ? 'msg msg-error' : 'msg msg-system';
div.textContent = text;
chatEl.appendChild(div);
scrollToBottom();
}
function addTaskLine(text) {
const div = document.createElement('div');
div.textContent = text;
taskList.appendChild(div);
taskList.scrollTop = taskList.scrollHeight;
if (!taskPanel.classList.contains('open')) {
unreadTasks += 1;
taskBadge.style.display = '';
taskBadge.textContent = unreadTasks > 99 ? '99+' : String(unreadTasks);
}
}
/* ---------- 消息气泡 ---------- */
function addUserBubble(text) {
const div = document.createElement('div');
div.className = 'msg msg-user';
div.textContent = text;
chatEl.appendChild(div);
scrollToBottom(true);
}
function ensureAiBubble() {
if (aiBubble) return;
aiBubble = document.createElement('div');
aiBubble.className = 'msg msg-ai streaming';
chatEl.appendChild(aiBubble);
scrollToBottom(true);
}
/* 服务端 chunk 为增量片段(发送后即清零),直接拼接 */
function appendAi(delta) {
if (!delta || aiActive === false) return;
ensureAiBubble();
aiText += delta;
aiBubble.innerHTML = md.render(aiText);
scrollToBottom();
}
/* complete 为收尾信号:chunk 流式是内容权威来源,仅异常丢帧时用 full_text 补建 */
function finishAi(fullText) {
if (aiActive === false && !aiBubble && !reasoningBox) return;
if (!aiBubble && fullText) {
ensureAiBubble();
aiText = fullText;
aiBubble.innerHTML = md.render(aiText);
}
if (aiBubble) aiBubble.classList.remove('streaming');
aiBubble = null; aiText = ''; aiActive = false;
reasoningBox = null; reasoningBody = null; reasoningHead = null; reasoningText = '';
setStreaming(false);
scrollToBottom();
}
/* ---------- 思考区域(可折叠) ---------- */
function startReasoning(hint) {
if (reasoningBox) return;
reasoningText = '';
reasoningBox = document.createElement('div');
reasoningBox.className = 'msg msg-reasoning';
reasoningHead = document.createElement('div');
reasoningHead.className = 'reasoning-head';
reasoningHead.textContent = '💭 ' + (hint || '思考中...');
reasoningBody = document.createElement('div');
reasoningBody.className = 'reasoning-body';
reasoningHead.addEventListener('click', () => reasoningBody.classList.toggle('folded'));
reasoningBox.appendChild(reasoningHead);
reasoningBox.appendChild(reasoningBody);
chatEl.appendChild(reasoningBox);
scrollToBottom(true);
}
function appendReasoning(delta) {
if (!delta) return;
if (aiActive === false) return; /* 已收尾(中断/完成),丢弃残留片段 */
if (!reasoningBox) startReasoning();
reasoningText += delta;
reasoningBody.textContent = reasoningText.length > REASONING_TAIL
? '...(前略)\n' + reasoningText.slice(-REASONING_TAIL)
: reasoningText;
scrollToBottom();
}
function endReasoning() {
if (reasoningHead && reasoningText) {
reasoningHead.textContent = '💭 思考过程(' + reasoningText.length + ' 字符,点击折叠/展开)';
reasoningBody.classList.add('folded'); /* 结束后默认折叠,避免占屏 */
}
/* 清除引用(DOM元素保留显示),下一轮 reasoning_start 创建新的思考区域 */
reasoningBox = null; reasoningHead = null; reasoningBody = null; reasoningText = '';
}
function abortReasoning() {
/* 用户中断:更新思考区域标签为已中断,保留已展示内容 */
if (reasoningHead) {
if (reasoningText) {
reasoningHead.textContent = '🛑 思考已中断(' + reasoningText.length + ' 字符)';
} else {
reasoningHead.textContent = '🛑 思考已中断';
}
}
/* 清除引用,DOM元素保留显示 */
reasoningBox = null; reasoningHead = null; reasoningBody = null; reasoningText = '';
}
/* ---------- AI 文本分段(工具调用时 breakAi,后续 chunk 创建新气泡) ---------- */
function breakAi() {
if (aiBubble) {
aiBubble.classList.remove('streaming');
aiBubble = null; aiText = '';
}
}
/* ---------- 连接状态 ---------- */
function setStatus(cls, text) {
statusEl.className = cls;
statusText.textContent = text;
}
function setStreaming(on) {
interruptBtn.style.display = on ? '' : 'none';
sendBtn.disabled = false; /* 流式期间仍可发送(服务端排队),仅中断按钮显隐 */
}
/* ---------- WebSocket 生命周期(指数退避自动重连,手动断开抑制) ---------- */
function connect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
clearTimeout(reconnectTimer);
manualClose = false;
setStatus('connecting', '连接中');
try {
ws = new WebSocket(WS_URL);
} catch (e) {
addSystem('连接失败:' + e.message, true);
scheduleReconnect();
return;
}
ws.onopen = () => {
reconnectDelay = RECONNECT_MIN;
setStatus('connected', '已连接');
connBtn.textContent = '断开';
connBtn.classList.add('on');
startHeartbeat();
};
ws.onmessage = (ev) => {
let data;
try { data = JSON.parse(ev.data); }
catch (e) { data = { type: 'chunk', content: ev.data }; }
routeMessage(data);
};
ws.onclose = () => {
stopHeartbeat();
setStatus('disconnected', '已断开');
connBtn.textContent = '连接';
connBtn.classList.remove('on');
finishAi('');
ws = null;
if (!manualClose) scheduleReconnect();
};
ws.onerror = () => { /* onclose 随后触发,统一在 onclose 处理 */ };
}
function scheduleReconnect() {
if (manualClose) return;
setStatus('connecting', Math.round(reconnectDelay / 1000) + 's 后重连');
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
connect();
}, reconnectDelay);
}
function disconnect(manual) {
manualClose = manual !== false;
clearTimeout(reconnectTimer);
stopHeartbeat();
if (ws) { ws.close(); ws = null; }
setStatus('disconnected', manualClose ? '已手动断开' : '已断开');
connBtn.textContent = '连接';
connBtn.classList.remove('on');
}
/* ---------- aibox 协议消息路由 ---------- */
function routeMessage(data) {
const t = data.type || '';
const content = typeof data.content === 'string' ? data.content : (data.content == null ? '' : String(data.content));
if (t === 'chunk') {
appendAi(content);
} else if (t === 'reasoning_start') {
startReasoning(content || '深度思考中...');
} else if (t === 'reasoning') {
appendReasoning(content);
} else if (t === 'reasoning_end') {
endReasoning();
} else if (t === 'ai_break') {
/* 工具调用前分段:结束当前AI文本段(保留已显示内容),后续chunk创建新气泡 */
breakAi();
} else if (t === 'line') {
/* 过程行:工具调用提示 / 工具输出 → 任务面板;助手前缀 / 思考提示等聊天区已有承载,跳过 */
const stripped = content.trim();
if (!stripped) return;
if (/^(💬 助手|💬 Assistant|🤔 思考中|🤔 Thinking|🤔 深度思考中|🤔 分析工具结果|🤔 Analyzing|🔧 需要调用工具|🔧 Need to call)/.test(stripped)) return;
addTaskLine(stripped);
} else if (t === 'complete') {
finishAi(content);
} else if (t === 'dialogue_ended') {
finishAi(aiText);
if (content) addSystem('✅ ' + content);
} else if (t === 'task_added') {
addSystem('➕ ' + content);
} else if (t === 'dialogue_reset') {
if (content) addSystem('🔄 ' + content);
} else if (t === 'pong') {
/* 心跳响应,忽略 */
} else if (t === 'task_process') {
const sub = data.msg_type || '';
const title = data.task_title || '未知任务';
if (sub === 'chunk') {
const tail = content.slice(-80);
if (tail.trim()) addTaskLine('⚡ ' + title + ': ' + tail);
} else if (sub === 'complete') {
addTaskLine('✅ 任务完成: ' + title);
}
} else if (t === 'task_status') {
const icon = { executing: '⚙️', complete: '✅', failed: '❌', added: '➕', batch_start: '🚀', batch_complete: '🏁' }[data.status] || '📋';
addTaskLine(icon + ' [' + (data.task_id || '?') + '] ' + (data.title || '') + ': ' + (data.message || content));
} else if (t === 'task_error') {
addTaskLine('❌ [' + (data.task_id || '?') + '] ' + (data.task_title || '?') + ': ' + (data.error || '未知错误'));
} else if (t === 'interrupt_ack') {
/* 服务端确认中断:立即收尾前端状态 */
abortReasoning();
finishAi('');
addSystem('🛑 ' + (content || '已中断'));
} else if (t === 'error') {
addSystem('❌ ' + content, true);
} else if (t === 'info') {
/* 过滤隐式通知(对话开始等),避免污染聊天流 */
if (!/(Conversation started|对话开始|🎭)/.test(content)) addSystem('ℹ️ ' + content);
} else if (t === 'command_result') {
addSystem(content);
} else if (t === 'connection_ack') {
addSystem('✅ ' + content);
} else if (t === 'notification') {
addSystem('🔔 ' + content);
} else if (content) {
/* 未知消息类型不追加到AI聊天区(避免非AI内容污染回复),记入任务面板 */
addTaskLine('[未知消息 type=' + t + '] ' + content.slice(0, 200));
}
}
/* ---------- 发送 ---------- */
function wsSend(obj) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
addSystem('未连接到服务器', true);
return false;
}
ws.send(JSON.stringify(obj));
return true;
}
/* 本地命令表:命中则本地处理,其余 / 命令透传服务器 */
const LOCAL_COMMANDS = {
'/clear'() {
chatEl.replaceChildren();
finishAi('');
},
'/help'() {
addSystem('本地命令:/clear 清屏 · /connect 连接 · /disconnect 断开 · /interrupt 中断 · 其余 / 命令透传服务器');
},
'/connect'() { connect(); },
'/disconnect'() { disconnect(true); },
'/interrupt'() { sendInterrupt(); },
};
function sendCurrentInput() {
const text = inputEl.value.trim();
if (!text) return;
if (text.startsWith('/')) {
const local = LOCAL_COMMANDS[text.split(/\s/)[0]];
if (local) {
local();
inputEl.value = '';
autoResize();
return;
}
}
/* 与 BotOps 一致:/ 开头按 command 发送,否则 message */
const data = text.startsWith('/')
? { type: 'command', command: text }
: { type: 'message', content: text };
if (!wsSend(data)) return;
addUserBubble(text);
aiActive = true; /* 开始接收本轮 AI 流 */
setStreaming(true);
inputEl.value = '';
autoResize();
inputEl.focus();
}
function sendInterrupt() {
wsSend({ type: 'interrupt', content: '用户按下了 Ctrl+C 中断当前操作' });
}
/* ---------- 输入区自适应高度 ---------- */
function autoResize() {
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
}
inputEl.addEventListener('input', autoResize);
inputEl.addEventListener('keydown', (e) => {
/* Enter 发送,Shift+Enter 换行;isComposing 防中文输入法选词误发 */
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
sendCurrentInput();
}
});
/* 软键盘弹出动画结束后保持聊天区停留底部 */
inputEl.addEventListener('focus', () => setTimeout(() => scrollToBottom(true), 300));
sendBtn.addEventListener('click', sendCurrentInput);
interruptBtn.addEventListener('click', sendInterrupt);
/* ---------- 连接按钮与任务面板(抽屉开关) ---------- */
connBtn.addEventListener('click', () => {
if (ws && ws.readyState === WebSocket.OPEN) disconnect(true);
else connect();
});
function openTaskPanel(open) {
taskPanel.classList.toggle('open', open);
backdrop.classList.toggle('show', open);
if (open) {
unreadTasks = 0;
taskBadge.style.display = 'none';
}
}
taskToggle.addEventListener('click', () => openTaskPanel(!taskPanel.classList.contains('open')));
document.getElementById('task-close').addEventListener('click', () => openTaskPanel(false));
backdrop.addEventListener('click', () => openTaskPanel(false));
/* ---------- 启动:管理员预检(WS 握手由 gateway 强制 is_admin,此处仅提示) ---------- */
(async () => {
try {
const res = await fetch('/whoami', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
const data = res.ok ? await res.json() : null;
const tip = document.getElementById('admin-tip');
if (!data || !data.login) {
tip.textContent = '未登录:aibox 仅管理员可用,请先登录管理员账号';
tip.style.display = '';
} else if (!data.is_admin) {
tip.textContent = '当前账号不是管理员,连接将被网关拒绝';
tip.style.display = '';
}
} catch (e) { /* 预检失败不阻塞,由 WS 握手兜底 */ }
connect();
})();
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+103
View File
@@ -0,0 +1,103 @@
/* index.js — 社区首页逻辑
*
* 登录态检测:POST /whoami(HttpOnly q4_token Cookie 自动携带,JS 不经手 Token)
* 已登录:显示头像/用户名与"进入笔记/退出"按钮,首屏 CTA 指向笔记页
* 未登录:显示"登录/注册",首屏 CTA 指向登录页
*/
(function () {
'use strict';
const nameEl = document.getElementById('name');
const avatarEl = document.getElementById('avatar');
const enterBtn = document.getElementById('enterBtn');
const logoutBtn = document.getElementById('logoutBtn');
const loginBtn = document.getElementById('loginBtn');
const registerBtn = document.getElementById('registerBtn');
const ctaBtns = document.querySelectorAll('[data-cta]'); // 首屏与页尾 CTA 按钮
const logoEl = document.getElementById('logo');
let loggedIn = false;
function go(url) { window.location.href = url; }
logoEl.addEventListener('click', () => go('index.html'));
enterBtn.addEventListener('click', () => go('dnfs.html'));
loginBtn.addEventListener('click', () => go('login.html'));
registerBtn.addEventListener('click', () => go('register.html'));
ctaBtns.forEach(function (btn) {
btn.addEventListener('click', () => go(loggedIn ? 'dnfs.html' : 'login.html'));
});
async function checkLogin() {
try {
const res = await fetch('/whoami', { method: 'POST' });
if (!res.ok) return;
const data = await res.json();
if (data && data.login && data.user) {
loggedIn = true;
nameEl.textContent = String(data.user);
nameEl.style.display = '';
avatarEl.style.display = '';
enterBtn.style.display = '';
logoutBtn.style.display = '';
loginBtn.style.display = 'none';
registerBtn.style.display = 'none';
ctaBtns.forEach(function (btn) { btn.textContent = '进入我的知识树'; });
}
} catch (e) {
console.warn('index: 登录态检测失败', e.message);
}
}
logoutBtn.addEventListener('click', async function () {
try {
await fetch('/logout', { method: 'POST' });
} catch (e) {
console.warn('index: 退出登录请求异常', e.message);
}
location.reload();
});
checkLogin();
})();
/* ===== 视觉增强:滚动渐显 + 卡片光标光斑(装饰性,与登录逻辑解耦) ===== */
(function () {
'use strict';
const reduceMotion = !window.matchMedia('(prefers-reduced-motion: no-preference)').matches;
/* 滚动进入视口渐显:由 JS 添加 .reveal 隐藏初始态,无 JS / 减弱动效时内容天然可见 */
if (!reduceMotion && 'IntersectionObserver' in window) {
const targets = document.querySelectorAll('.section-eyebrow, .section-title, .section-sub, .feature-card, .site-intro, .about-stats, .quick-links, .cta-band');
targets.forEach(function (el, i) {
el.classList.add('reveal');
el.style.transitionDelay = (i % 4) * 70 + 'ms';
});
const io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
const el = entry.target;
el.classList.add('reveal-in');
io.unobserve(el);
/* 动画结束后移除类与延迟,把 transition 控制权交还 hover 等交互样式 */
setTimeout(function () {
el.classList.remove('reveal', 'reveal-in');
el.style.transitionDelay = '';
}, 900);
});
}, { threshold: 0.15, rootMargin: '0px 0px -8% 0px' });
targets.forEach(function (el) { io.observe(el); });
}
/* 特性卡片光标光斑:鼠标位置写入 CSS 变量,由 index.css 径向渐变消费(仅精确指针设备) */
if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
document.querySelectorAll('.feature-card').forEach(function (card) {
card.addEventListener('mousemove', function (e) {
const rect = card.getBoundingClientRect();
card.style.setProperty('--mx', (e.clientX - rect.left) + 'px');
card.style.setProperty('--my', (e.clientY - rect.top) + 'px');
});
});
}
})();
+153
View File
@@ -0,0 +1,153 @@
const msgTip = document.getElementById('msgTip');
const pwdTip = document.getElementById('pwdTip');
const agreeTip = document.getElementById('agreeTip');
const captchaTip = document.getElementById('captchaTip');
const form = document.getElementById('form');
const usernameInput = document.getElementById('loginUsername');
const passwordInput = document.getElementById('loginPassword');
const captchaInput = document.getElementById('captchaAnswer');
const captchaImg = document.getElementById('captchaImg');
const agreeBox = document.getElementById('agreeBox');
const resetUser = document.getElementById('resetUser');
const registerBtn = document.getElementById('registerBtn');
const loginButton = document.getElementById('loginBtn');
/* ── 图形验证码(gateway 代理 captcha 服务:GET /captcha/image) ── */
let captchaToken = '';
let captchaLoading = false; // 防重复点击:加载中忽略再次触发
async function refreshCaptcha() {
if (captchaLoading) return;
captchaLoading = true;
captchaToken = '';
try {
const res = await fetch('/captcha/image', { cache: 'no-store' });
if (res.status === 403) { // WAF 拦截:body 非图片,提示后待用户点击重试
captchaImg.removeAttribute('src');
captchaImg.alt = '验证码加载被安全规则拦截,点击重试';
return;
}
if (!res.ok) throw new Error('captcha load failed');
captchaToken = res.headers.get('X-Captcha-Token') || '';
const blob = await res.blob();
if (captchaImg.src && captchaImg.src.startsWith('blob:')) URL.revokeObjectURL(captchaImg.src);
captchaImg.src = URL.createObjectURL(blob);
captchaImg.alt = '点击刷新';
} catch (e) {
captchaImg.removeAttribute('src');
captchaImg.alt = '加载失败,点击重试';
} finally {
captchaLoading = false;
}
}
captchaImg.addEventListener('click', refreshCaptcha);
refreshCaptcha();
resetUser.addEventListener('click', function() { clearTipStyle(); });
function clearTipStyle() {
msgTip.innerHTML = '';
pwdTip.innerHTML = '';
agreeTip.innerHTML = '';
captchaTip.innerHTML = '';
msgTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
agreeTip.classList.remove('tip-red');
captchaTip.classList.remove('tip-red');
}
clearTipStyle();
function checkUsername() {
const result = FormValidators.username(usernameInput.value);
msgTip.textContent = result.message;
msgTip.className = result.className;
return result.valid ? result.value : false;
}
function checkPwd() {
const result = FormValidators.password(passwordInput.value);
pwdTip.textContent = result.message;
pwdTip.className = result.className;
return result.valid ? result.value : false;
}
function checkCaptcha() {
captchaTip.innerHTML = '';
captchaTip.classList.remove('tip-red');
const v = captchaInput.value.trim();
if (!v) {
captchaTip.innerHTML = '请输入验证码!';
captchaTip.classList.add('tip-red');
return false;
}
if (!captchaToken) {
captchaTip.innerHTML = '验证码未加载,请点击图片刷新';
captchaTip.classList.add('tip-red');
return false;
}
return v;
}
function checkAgree() {
agreeTip.innerHTML = '';
agreeTip.classList.remove('tip-red');
if (!agreeBox.checked) {
agreeTip.innerHTML = '请同意用户协议!';
agreeTip.classList.add('tip-red');
return false;
}
return true;
}
usernameInput.addEventListener('focus', checkUsername);
passwordInput.addEventListener('focus', checkPwd);
usernameInput.addEventListener('input', checkUsername);
passwordInput.addEventListener('input', checkPwd);
usernameInput.addEventListener('blur', checkUsername);
passwordInput.addEventListener('blur', checkPwd);
agreeBox.addEventListener('change', checkAgree);
registerBtn.addEventListener('click', function() { location.href = 'register.html'; });
form.addEventListener('submit', async function(e) {
e.preventDefault();
clearTipStyle();
const userOk = checkUsername();
const pwdOk = checkPwd();
const captchaOk = checkCaptcha();
const agreeOk = checkAgree();
if (!userOk || !pwdOk || !captchaOk || !agreeOk) return;
const jsonBody = JSON.stringify({
username: userOk,
password: pwdOk,
captcha_token: captchaToken,
captcha_answer: captchaOk
});
try {
loginButton.disabled = true;
const res = await fetch('/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: jsonBody
});
const responseText = await res.text();
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
let result;
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
if (!res.ok) throw new Error(result.message || '登录失败');
if (result.success) {
// Token 已由网关通过 HttpOnly Cookie 下发,JS 不再经手
alert('登录成功');
location.href = '/dnfs.html';
} else {
alert(result.message || '登录失败');
}
} catch (err) {
alert(err.message || '接口请求失败');
} finally {
// 无论成败都刷新验证码:Token 已被一次性消费,或需防重试
captchaInput.value = '';
refreshCaptcha();
loginButton.disabled = false;
}
});
File diff suppressed because it is too large. Load diff
+289
View File
@@ -0,0 +1,289 @@
const form = document.getElementById('form');
const emailInput = document.getElementById('regemail');
const codeInput = document.getElementById('code');
const getCodeButton = document.getElementById('getCode');
const passwordInput = document.getElementById('regpsw');
const repeatPasswordInput = document.getElementById('repwd');
const emlTip = document.getElementById('emlTip');
const pwdTip = document.getElementById('pwdTip');
const codeTip = document.getElementById('codeTip');
const captchaImg = document.getElementById('captchaImg');
const captchaInput = document.getElementById('captchaAnswer');
const captchaTip = document.getElementById('captchaTip');
const submitButton = document.getElementById('formsub');
let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手)
let cooldownTimer = null; // 重发倒计时(与服务端同邮箱 60s 发送冷却对齐)
/* ── 图形验证码(gateway 代理 captcha 服务:GET /captcha/image) ── */
let captchaToken = '';
let captchaLoading = false; // 防重复点击:加载中忽略再次触发
async function refreshCaptcha(preserveInput) {
if (captchaLoading) return;
captchaLoading = true;
captchaToken = '';
if (!preserveInput) captchaInput.value = ''; // 默认清空输入;preserveInput=true 时保留(获取验证码后)
captchaImg.style.opacity = '0.5'; // 视觉提示:正在刷新
try {
const res = await fetch('/captcha/image', { cache: 'no-store' });
if (res.status === 403) { // WAF 拦截:body 非图片,提示后待用户点击重试
captchaImg.removeAttribute('src');
captchaImg.alt = '验证码加载被安全规则拦截,点击重试';
return;
}
if (!res.ok) throw new Error('captcha load failed');
captchaToken = res.headers.get('X-Captcha-Token') || '';
const blob = await res.blob();
if (captchaImg.src && captchaImg.src.startsWith('blob:')) URL.revokeObjectURL(captchaImg.src);
captchaImg.src = URL.createObjectURL(blob);
captchaImg.alt = '点击刷新';
captchaImg.style.opacity = ''; // 恢复透明度
} catch (e) {
captchaImg.removeAttribute('src');
captchaImg.alt = '加载失败,点击重试';
} finally {
captchaLoading = false;
}
}
captchaImg.addEventListener('click', refreshCaptcha);
refreshCaptcha();
// 发送成功后进入倒计时,避免用户触发服务端同邮箱 60s 发送冷却
function startResendCooldown(seconds) {
clearInterval(cooldownTimer);
let remain = seconds;
getCodeButton.disabled = true;
getCodeButton.value = `${remain}s 后重发`;
cooldownTimer = setInterval(function () {
remain -= 1;
if (remain <= 0) {
clearInterval(cooldownTimer);
cooldownTimer = null;
getCodeButton.disabled = false;
getCodeButton.value = '获取验证码';
} else {
getCodeButton.value = `${remain}s 后重发`;
}
}, 1000);
}
form.addEventListener('reset', function () {
clearTipStyle();
codeTarget = '';
captchaInput.value = '';
refreshCaptcha();
});
function clearTipStyle() {
emlTip.innerHTML = '';
pwdTip.innerHTML = '';
codeTip.innerHTML = '';
captchaTip.innerHTML = '';
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
captchaTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
}
clearTipStyle();
function checkEmail() {
const result = FormValidators.email(emailInput.value);
emlTip.innerHTML = '';
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
emlTip.textContent = result.message;
emlTip.classList.add(result.className);
return result.valid ? result.value : false;
}
function checkPassword() {
const result = FormValidators.password(passwordInput.value, 6);
pwdTip.innerHTML = '';
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
if (!result.valid) {
pwdTip.textContent = result.message;
pwdTip.classList.add(result.className);
return false;
}
if (result.value !== repeatPasswordInput.value) {
pwdTip.textContent = '两次输入的密码不一致!';
pwdTip.classList.add('tip-yellow');
return false;
}
pwdTip.textContent = result.message;
pwdTip.classList.add(result.className);
return result.value;
}
function checkCode() {
const code = codeInput.value.trim();
codeTip.innerHTML = '';
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
if (!codeTarget) {
codeTip.textContent = '请先获取验证码!';
codeTip.classList.add('tip-red');
return false;
}
if (emailInput.value.trim() !== codeTarget) {
codeTip.textContent = '邮箱已修改,请重新获取验证码!';
codeTip.classList.add('tip-red');
return false;
}
if (!/^\d{6}$/.test(code)) {
codeTip.textContent = '验证码必须是6位数字!';
codeTip.classList.add('tip-yellow');
return false;
}
// 验证码正确性由服务端在注册提交时校验(一次性消费)
return code;
}
function checkCaptcha() {
captchaTip.innerHTML = '';
captchaTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
const v = captchaInput.value.trim();
if (!v) {
captchaTip.textContent = '请输入图形验证码!';
captchaTip.classList.add('tip-red');
return false;
}
if (!captchaToken) {
captchaTip.textContent = '图形验证码未加载,请点击图片刷新';
captchaTip.classList.add('tip-red');
return false;
}
return v;
}
passwordInput.addEventListener('focus', checkPassword);
repeatPasswordInput.addEventListener('focus', checkPassword);
passwordInput.addEventListener('input', checkPassword);
repeatPasswordInput.addEventListener('input', checkPassword);
passwordInput.addEventListener('blur', checkPassword);
repeatPasswordInput.addEventListener('blur', checkPassword);
emailInput.addEventListener('focus', checkEmail);
emailInput.addEventListener('input', function () {
checkEmail();
if (codeTarget) {
checkCode();
}
});
emailInput.addEventListener('blur', checkEmail);
codeInput.addEventListener('focus', checkCode);
codeInput.addEventListener('input', checkCode);
codeInput.addEventListener('blur', checkCode);
getCodeButton.addEventListener('click', async function () {
if (getCodeButton.disabled) return; // 防重复点击
const email = checkEmail();
if (!email) return;
const captchaAnswer = checkCaptcha();
if (!captchaAnswer) return;
getCodeButton.disabled = true;
codeTip.textContent = '验证码发送中…';
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
codeTip.classList.add('tip-yellow');
let httpStatus = 0;
try {
const res = await fetch('/register/send_code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
captcha_token: captchaToken,
captcha_answer: captchaAnswer
})
});
httpStatus = res.status;
const responseText = await res.text();
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
let result;
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
if (!res.ok || !result.success) throw new Error(result.message || '验证码发送失败');
codeTarget = email;
codeInput.value = '';
codeTip.textContent = '验证码已发送至邮箱,请查收(10 分钟内有效)';
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
codeTip.classList.add('tip-green');
startResendCooldown(60);
} catch (err) {
codeTip.textContent = err.message || '验证码发送失败,请稍后重试';
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
codeTip.classList.add('tip-red');
if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐
} finally {
// CAPTCHA Token 一次性消费,无论成败都刷新图形验证码;
// 刷新时保留已输入的图形验证码答案,避免误清空用户输入
refreshCaptcha(true);
if (!cooldownTimer) getCodeButton.disabled = false;
}
});
form.addEventListener('submit', async function (e) {
e.preventDefault();
const email = checkEmail();
const password = checkPassword();
const code = checkCode();
if (!email || !password || !code) {
return;
}
const jsonBody = JSON.stringify({
username: email,
password,
email_code: code
});
try {
submitButton.disabled = true;
const res = await fetch('/register', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: jsonBody
});
const responseText = await res.text();
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
let result;
try {
result = JSON.parse(responseText);
} catch {
console.error(`注册接口返回非 JSON 内容(HTTP ${res.status}):`, responseText);
throw new Error('服务器响应格式异常,请稍后重试');
}
if (!res.ok) {
throw new Error(result.message || `注册失败(HTTP ${res.status})`);
}
if (result.success) {
alert('注册成功');
form.reset();
codeTarget = '';
location.href = 'login.html';
return;
}
alert(result.message || '注册失败');
} catch (err) {
console.error('注册请求异常:', err);
alert(err.message || '接口请求失败,请检查后端服务是否启动');
} finally {
submitButton.disabled = false;
}
});
+119
View File
@@ -0,0 +1,119 @@
/**
* Token 生命周期管理器
* - 定时静默刷新:在 Token 80% 生命周期时提前调 /refresh_token
* - 全局 fetch 拦截器:收到 401 先尝试刷新一次,成功则重试原请求,失败则原样返回 401
* - 依赖:gateway POST /refresh_token(Cookie q4_token 自动携带)
* - 无侵入:与 dnfs.js 完全解耦
*/
let _tokenExpireAt = null;
let _refreshTimer = null;
const REFRESH_ADVANCE = 0.8; // 在 80% 生命周期时提前刷新
const REFRESH_MIN_GAP = 60; // 两次刷新最少间隔(秒)
const RETRY_COOLDOWN = 15; // 刷新失败后冷却期(秒)
/* ---------- 过期时间估算:通过 /whoami 获取 iat + keep_time ---------- */
async function estimateExpiry() {
try {
const res = await _rawFetch('/whoami', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
if (!res.ok) return false;
const data = await res.json();
if (!data.login) return false;
// 优先 created_at(刷新时更新),回退 iat(兼容旧 Token)
// 网关以 created_at + keep_time 判定令牌有效性,iat 刷新后不变,不能用于估算过期
const issueStr = data.created_at || data.iat;
const keepTime = data.keep_time;
if (!issueStr || !keepTime) return false;
const issueDate = new Date(issueStr.replace(' ', 'T') + 'Z');
if (isNaN(issueDate.getTime())) return false;
_tokenExpireAt = issueDate.getTime() + keepTime * 1000;
scheduleRefresh();
return true;
} catch (e) {
console.warn('TokenManager: 过期时间估算失败', e.message);
return false;
}
}
/* ---------- 定时刷新 ---------- */
function scheduleRefresh() {
clearTimeout(_refreshTimer);
if (!_tokenExpireAt) return;
const remainingSec = (_tokenExpireAt - Date.now()) / 1000;
if (remainingSec <= 0) {
// Token 已过期,立即尝试刷新
tryRefresh();
return;
}
// 80% 位置触发,但至少等 REFRESH_MIN_GAP 秒
const delaySec = Math.max(remainingSec * (1 - REFRESH_ADVANCE), REFRESH_MIN_GAP);
_refreshTimer = setTimeout(tryRefresh, delaySec * 1000);
}
async function tryRefresh() {
try {
const res = await _rawFetch('/refresh_token', { method: 'POST' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
if (data.message) console.warn('TokenManager: 刷新被拒 -', data.message);
// 冷却后重新估算
_refreshTimer = setTimeout(() => estimateExpiry(), RETRY_COOLDOWN * 1000);
return;
}
const data = await res.json();
if (data.success && data.data) {
console.log(`TokenManager: 已刷新 (第${data.data.refresh_count}次, 剩余${data.data.refreshes_remaining}次)`);
}
await estimateExpiry();
} catch (e) {
console.warn('TokenManager: 刷新网络异常', e.message);
_refreshTimer = setTimeout(() => estimateExpiry(), RETRY_COOLDOWN * 1000);
}
}
/* ---------- 全局 fetch 401 拦截器 ---------- */
const _rawFetch = window.fetch;
window.fetch = async function (url, options) {
let resp = await _rawFetch(url, options);
// 仅处理 401;/refresh_token 自身不重试防循环
if (resp.status !== 401 || (typeof url === 'string' && url.includes('/refresh_token'))) {
return resp;
}
try {
const refreshRes = await _rawFetch('/refresh_token', { method: 'POST' });
if (!refreshRes.ok) return resp;
const data = await refreshRes.json();
if (!data.success) return resp;
console.log('TokenManager: 401 拦截 → 刷新成功,重试原请求');
await estimateExpiry();
// Cookie 已由 gateway Set-Cookie 自动更新,直接重试
return await _rawFetch(url, options);
} catch (e) {
console.warn('TokenManager: 401 刷新异常', e.message);
}
return resp;
};
/* ---------- 启动 ---------- */
estimateExpiry();
+82
View File
@@ -0,0 +1,82 @@
(function () {
function username(value) {
const normalizedValue = value.trim();
if (!normalizedValue) {
return { valid: false, message: '用户名不可为空', className: 'tip-red' };
}
if (normalizedValue.length > 20) {
return { valid: false, message: '用户名不得长于20字符!', className: 'tip-yellow' };
}
return {
valid: true,
value: normalizedValue,
message: '用户名格式正确',
className: 'tip-green'
};
}
function password(value, minLength) {
const requiredLength = minLength || 1;
if (!value) {
return { valid: false, message: '密码不可为空!', className: 'tip-red' };
}
if (value.length < requiredLength) {
return {
valid: false,
message: `密码至少${requiredLength}位字符!`,
className: 'tip-red'
};
}
return {
valid: true,
value,
message: '密码格式正确',
className: 'tip-green'
};
}
function email(value) {
const normalizedValue = value.trim();
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
if (!normalizedValue) {
return { valid: false, message: '邮箱不可为空!', className: 'tip-red' };
}
if (!emailPattern.test(normalizedValue)) {
return { valid: false, message: '邮箱格式不正确!', className: 'tip-yellow' };
}
return {
valid: true,
value: normalizedValue,
message: '邮箱格式正确',
className: 'tip-green'
};
}
function note_title(value) {
return true;
}
function note_content(value) {
return true;
}
window.FormValidators = { username, password, email };
}());
/* WAF 拦截识别:waf 命中规则时返回 403 + 纯文本 "blocked by WAF"
* (见 waf/internal/proxy/proxy.go block());gateway 的 403(Target-Url
* 白名单拒绝)body 为 JSON 错误消息,不含此特征,不会误判。 */
window.WafTip = {
MESSAGE: '请求被 WAF 安全规则拦截,请检查输入内容',
isBlocked(res, responseText) {
return res.status === 403
&& typeof responseText === 'string'
&& responseText.indexOf('blocked by WAF') !== -1;
}
};
+190
View File
@@ -0,0 +1,190 @@
// ===== 路径解析:从 URL 取当前目录(去除首尾斜杠) =====
const currentPath = decodeURIComponent(window.location.pathname).replace(/^\/+|\/+$/g, '');
// ===== DOM 引用(id 与原版本完全一致,保持后端/前端契约不变) =====
const fileList = document.getElementById("fileList");
const loading = document.getElementById("loading");
const empty = document.getElementById("empty");
const searchInput = document.getElementById("searchInput");
const totalItems = document.getElementById("totalItems");
const totalDirs = document.getElementById("totalDirs");
const totalFiles = document.getElementById("totalFiles");
const totalVideos = document.getElementById("totalVideos");
const totalAudios = document.getElementById("totalAudios");
const btnRefresh = document.getElementById("btnRefresh");
const breadcrumb = document.getElementById("breadcrumb");
// ===== 状态 =====
let items = [];
let searchQuery = "";
// ===== 工具函数 =====
const SIZE_UNITS = ["B", "KB", "MB", "GB"];
function formatSize(bytes) {
if (bytes === 0) return "0B";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) + SIZE_UNITS[i];
}
const FILE_ICONS = {
pdf: "📄", doc: "📝", docx: "📝", xls: "📊", xlsx: "📊",
zip: "📦", rar: "📦", "7z": "📦",
jpg: "🖼️", jpeg: "🖼️", png: "🖼️", gif: "🖼️",
txt: "📃", json: "📋", html: "🌐", css: "🎨", js: "⚡"
};
function getIcon(item) {
if (item.type === "dir") return "📁";
if (item.type === "video") return "🎬";
if (item.type === "audio") return "🎵";
const ext = item.name.split(".").pop().toLowerCase();
return FILE_ICONS[ext] || "📄";
}
// 转义文本,防止文件名含特殊字符导致 XSS 或模板断裂
function esc(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ===== 导航行为(与原版本一致) =====
function navigate(path, type) {
if (type === "dir") window.location.href = "/" + path;
else if (type === "video" || type === "audio") window.location.href = "/video/" + path;
else if (/\.html?$/i.test(path)) window.open("/" + path, "_blank");
else window.location.href = "/" + path;
}
function playMedia(path) { window.location.href = "/video/" + path; }
function downloadFile(path) { window.location.href = "/" + path + "?download=true"; }
// ===== 面包屑 =====
function renderBreadcrumb() {
const parts = currentPath ? currentPath.split('/') : [];
const frags = ['<a href="/">根目录</a>'];
let acc = '';
parts.forEach((part, i) => {
if (i === parts.length - 1) {
frags.push(`<span class="sep">›</span><span class="current">${esc(part)}</span>`);
} else {
acc += (i === 0 ? '' : '/') + part;
frags.push(`<span class="sep">›</span><a href="/${encodeURIComponent(acc)}/">${esc(part)}</a>`);
}
});
breadcrumb.innerHTML = frags.join('');
}
// ===== 列表渲染(使用转义,避免文件名注入) =====
function render() {
const q = searchQuery.toLowerCase();
const filtered = searchQuery
? items.filter(i => i.name.toLowerCase().includes(q))
: items;
totalItems.textContent = filtered.length;
totalDirs.textContent = filtered.filter(i => i.type === "dir").length;
totalFiles.textContent = filtered.filter(i => i.type === "file").length;
totalVideos.textContent = filtered.filter(i => i.type === "video").length;
totalAudios.textContent = filtered.filter(i => i.type === "audio").length;
if (filtered.length === 0) {
fileList.innerHTML = "";
empty.style.display = "block";
loading.style.display = "none";
return;
}
empty.style.display = "none";
loading.style.display = "none";
fileList.innerHTML = filtered.map(item => {
const path = esc(item.path);
const type = esc(item.type);
const name = esc(item.name);
const sizeOrDir = item.type !== "dir"
? `<span>${formatSize(item.size)}</span>`
: '<span>📁 文件夹</span>';
const playBtn = (item.type === "video" || item.type === "audio")
? `<button class="btn-play" onclick="event.stopPropagation(); playMedia('${path}')">▶</button>`
: '';
return `
<div class="item" onclick="navigate('${path}', '${type}')">
<span class="icon">${getIcon(item)}</span>
<div class="info">
<div class="name">${name}</div>
<div class="meta">
${sizeOrDir}
<span>${esc(item.modified)}</span>
</div>
</div>
<div class="actions">
${playBtn}
<button class="btn-download" onclick="event.stopPropagation(); downloadFile('${path}')">⬇</button>
</div>
</div>`;
}).join("");
}
// ===== 数据拉取(接口与参数与原版本一致) =====
let isLoading = false; // 防重复点击:请求进行中忽略再次触发
async function loadData() {
if (isLoading) return;
isLoading = true;
loading.style.display = "block";
fileList.innerHTML = "";
empty.style.display = "none";
try {
const url = `/api/filelist?path=${encodeURIComponent(currentPath)}`;
const res = await fetch(url);
const responseText = await res.text();
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
const data = JSON.parse(responseText);
if (data.success) {
items = data.items || [];
render();
} else {
throw new Error(data.error || "加载失败");
}
} catch (e) {
fileList.innerHTML = `<div class="error">❌ 加载失败: ${esc(e.message)}</div>`;
loading.style.display = "none";
} finally {
isLoading = false;
}
}
// ===== 事件绑定(快捷键 / 搜索 / 刷新 与原版本一致) =====
let searchTimer;
searchInput.addEventListener("input", function () {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
searchQuery = this.value.trim();
render();
}, 200);
});
document.addEventListener("keydown", function (e) {
if ((e.ctrlKey && e.key === "f") ||
(e.key === "/" && !e.ctrlKey && !e.metaKey)) {
e.preventDefault();
searchInput.focus();
searchInput.select();
}
if (e.key === "Escape") {
searchInput.value = "";
searchQuery = "";
render();
searchInput.blur();
}
});
btnRefresh.addEventListener("click", function (e) {
e.preventDefault(); // <a href="#"> 默认会跳页顶,拦截
loadData();
});
// ===== 启动 =====
renderBreadcrumb();
loadData();