const state = { token: null, master: null, masterFp: null, isAdmin: false, dir: '/' } let shareCtx = { fileId: null, link: null } let debugEnabled = false let debugLines = [] function logDebug(){ if(!debugEnabled) return; const t=new Date().toISOString(); const msg=[t].concat([].slice.call(arguments)).join(' '); debugLines.push(msg); if(debugLines.length>200) debugLines.shift(); const el=qs('debugLog'); if(el) el.textContent=debugLines.join('\n'); const el2=qs('debugBottom'); if(el2) el2.textContent=debugLines.join('\n') } const api = { async setup(baseCode, master) { const r = await fetch('/api/psc/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ base_code: baseCode, master_password: master }) }) return r.json() }, async login(master, cap) { const r = await fetch('/api/psc/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ master_password: master, captcha_id: cap && cap.id, captcha_angle: cap && cap.angle }) }) return r.json() }, async list() { const r = await fetch('/api/psc/files?dir=' + encodeURIComponent(state.dir), { headers: { Authorization: 'Bearer ' + state.token } }) return r.json() }, async initFile(name, size, chunkSize, fileSaltB64) { const r = await fetch('/api/psc/files/init', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + state.token }, body: JSON.stringify({ name, size, chunkSize, salt: fileSaltB64, dir: state.dir }) }) return r.json() }, async uploadChunk(fileId, index, buf) { const r = await fetch(`/api/psc/files/chunk?fileId=${fileId}&index=${index}`, { method: 'POST', headers: { Authorization: 'Bearer ' + state.token, 'Content-Type': 'application/octet-stream' }, body: buf }) if(!r.ok){ throw new Error('chunk_http_'+r.status) } return r.json() }, async complete(fileId) { const r = await fetch('/api/psc/files/complete', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + state.token }, body: JSON.stringify({ fileId }) }) return r.json() }, async downloadInit(id) { const r = await fetch(`/api/psc/files/${id}/init`, { headers: { Authorization: 'Bearer ' + state.token } }) return r.json() }, async downloadChunk(id, index) { const r = await fetch(`/api/psc/files/${id}/chunk?index=${index}`, { headers: { Authorization: 'Bearer ' + state.token } }) if(!r.ok) throw new Error('http '+r.status) return r.arrayBuffer() } ,async sliceInfo(id){ const r = await fetch(`/api/psc/files/${id}/slice`, { headers:{ Authorization:'Bearer '+state.token } }); let txt=''; try{ txt=await r.text() }catch{}; let j=null; try{ j=JSON.parse(txt) }catch{ j=null }; try{ logDebug('slice.info.http', r.status); if(txt) logDebug('slice.info.body', txt.slice(0,200)) }catch{}; return j } ,async sliceCreate(id, volumeMB){ const r = await fetch(`/api/psc/files/${id}/slice`, { method:'POST', headers:{ 'Content-Type':'application/json', Authorization:'Bearer '+state.token }, body: JSON.stringify({ volumeMB }) }); let txt=''; try{ txt=await r.text() }catch{}; let j=null; try{ j=JSON.parse(txt) }catch{ j=null }; try{ logDebug('slice.create.http', r.status); if(txt) logDebug('slice.create.body', txt.slice(0,200)) }catch{}; return j } ,async sliceDownload(id, index){ const r = await fetch(`/api/psc/files/${id}/slice/download?index=${index}`, { headers:{ Authorization:'Bearer '+state.token } }); if(!r.ok) throw new Error('http '+r.status); return r.arrayBuffer() } } const dirApi = { async list(parent){ const r = await fetch('/api/psc/dirs?parent=' + encodeURIComponent(parent||'/'), { headers: { Authorization: 'Bearer ' + state.token } }); return r.json() }, async create(parent, name){ const r = await fetch('/api/psc/dirs/create', { method:'POST', headers:{ 'Content-Type':'application/json', Authorization:'Bearer '+state.token }, body: JSON.stringify({ parent, name })}); return r.json() }, async rename(path, newName){ const r = await fetch('/api/psc/dirs/rename', { method:'POST', headers:{ 'Content-Type':'application/json', Authorization:'Bearer '+state.token }, body: JSON.stringify({ path, newName })}); return r.json() }, async delete(path){ const r = await fetch('/api/psc/dirs?path=' + encodeURIComponent(path), { method:'DELETE', headers:{ Authorization:'Bearer '+state.token } }); return r.json() } } const logsApi = { async list(params){ const u = new URL(location.origin + '/api/psc/logs'); const { from, to, limit, offset, category } = params || {}; if(from) u.searchParams.set('from', from); if(to) u.searchParams.set('to', to); if(limit) u.searchParams.set('limit', String(limit)); if(offset) u.searchParams.set('offset', String(offset)); if(category) u.searchParams.set('category', category); const r = await fetch(u.toString(), { headers:{ Authorization:'Bearer '+state.token } }); return r.json() }, exportUrl(params){ const u = new URL(location.origin + '/api/psc/logs/export.csv'); const { from, to, limit, category } = params || {}; if(from) u.searchParams.set('from', from); if(to) u.searchParams.set('to', to); if(limit) u.searchParams.set('limit', String(limit)); if(category) u.searchParams.set('category', category); return u.toString() } } let logsState = { offset: 0, size: 200, total: 0 } function qs(id) { return document.getElementById(id) } async function detectAdmin(){ try{ const r = await fetch('/api/psc/debug/status', { cache:'no-store' }); const j = await r.json(); const enabled = !!(j && j.enabled); state.isAdmin = enabled; debugEnabled = enabled; if(state.isAdmin) ensureDebugUi(); applyRoleUi() }catch{ state.isAdmin=false; debugEnabled=false } } function ensureDebugUi(){ if(qs('btnCopyDebug')) return; const dash = qs('dashboard'); if(!dash) return; const btn=document.createElement('button'); btn.id='btnCopyDebug'; btn.textContent='复制调试信息'; const box=document.createElement('div'); box.id='debugLog'; box.className='debugbox'; dash.insertBefore(btn, qs('status')?.nextSibling||null); dash.insertBefore(box, btn.nextSibling); btn.onclick = async ()=>{ try{ await navigator.clipboard.writeText((qs('debugLog')?.textContent)||''); setStatus('调试信息已复制') } catch{ setStatus('请手动复制调试信息') } } } function applyRoleUi(){ const s=qs('btnOpenSettings'); const l=qs('btnOpenLogs'); const show = !!state.isAdmin; if(s){ if(show){ s.classList.remove('hidden'); s.style.display='' } else { s.classList.add('hidden'); s.style.display='none' } } if(l){ if(show){ l.classList.remove('hidden'); l.style.display='' } else { l.classList.add('hidden'); l.style.display='none' } } } function askPwd(msg){ return new Promise((resolve)=>{ const m=qs('pwdModal'); const t=qs('pwdPromptText'); const i=qs('pwdInput'); const ok=qs('btnPwdOk'); const cancel=qs('btnPwdCancel'); const close=qs('btnClosePwd'); function done(v){ m.classList.add('hidden'); ok.onclick=null; cancel.onclick=null; close.onclick=null; resolve(v) } if(t) t.textContent=msg||'请输入18位身份证号'; if(i){ i.value=''; setTimeout(()=>{ try{ i.focus() }catch{} },0) } m.classList.remove('hidden'); if(ok) ok.onclick=()=>{ const v=i? (i.value||'') : ''; try{ logDebug('ask.pwd.len', (v||'').length) }catch{}; done(v) }; if(cancel) cancel.onclick=()=>done(null); if(close) close.onclick=()=>done(null) }) } function askRotate(){ return new Promise(async(resolve)=>{ const m=qs('captchaModal'); const box=qs('capBox'); const inner=qs('capInner'); const slider=qs('capSlider'); const ok=qs('capOk'); const cancel=qs('capCancel'); const close=qs('capClose'); let id=null; let img=null; let seed=0; try{ const r=await fetch('/api/psc/captcha/rotate/init',{cache:'no-store'}); const j=await r.json(); id=j && j.id; img=j && j.image; seed=Number(j && j.seed || 0) }catch{}; function done(v){ m.classList.add('hidden'); ok.onclick=null; cancel.onclick=null; close.onclick=null; slider.oninput=null; resolve(v) } m.classList.remove('hidden'); slider.value='0'; if(img){ if(box){ box.style.background='none'; box.style.backgroundImage='url('+img+')'; box.style.backgroundSize='cover'; box.style.backgroundPosition='center' } if(inner){ inner.style.background='none'; inner.style.backgroundImage='url('+img+')'; inner.style.backgroundSize='cover'; inner.style.backgroundPosition='center' } } if(inner){ inner.style.transform='rotate('+seed+'deg)' } slider.oninput=()=>{ const a=Number(slider.value||0); if(inner){ inner.style.transform='rotate('+(seed+a)+'deg)' } }; ok.onclick=()=>{ const ang=Number(slider.value||0); done(id? { id, angle: ang } : null) }; cancel.onclick=()=>done(null); close.onclick=()=>done(null) }) } function askConfirm(msg) { return new Promise(resolve => { const m = qs('genericModal'); if(!m) return resolve(window.confirm(msg)); qs('genericModalTitle').textContent = '提示'; qs('genericModalMessage').textContent = msg; qs('genericModalInputWrap').style.display = 'none'; qs('genericModalInput').value = ''; const ok = qs('btnGenericOk'); const cancel = qs('btnGenericCancel'); const close = qs('btnGenericClose'); function done(val) { m.classList.add('hidden'); ok.onclick = null; cancel.onclick = null; close.onclick = null; resolve(val); } m.classList.remove('hidden'); ok.onclick = () => done(true); cancel.onclick = () => done(false); close.onclick = () => done(false); }); } function askPrompt(msg, defaultVal) { return new Promise(resolve => { const m = qs('genericModal'); if(!m) return resolve(window.prompt(msg, defaultVal)); qs('genericModalTitle').textContent = '输入'; qs('genericModalMessage').textContent = msg; qs('genericModalInputWrap').style.display = 'block'; const input = qs('genericModalInput'); input.value = defaultVal || ''; const ok = qs('btnGenericOk'); const cancel = qs('btnGenericCancel'); const close = qs('btnGenericClose'); function done(val) { m.classList.add('hidden'); ok.onclick = null; cancel.onclick = null; close.onclick = null; resolve(val); } m.classList.remove('hidden'); setTimeout(() => { try { input.focus(); input.select(); } catch{} }, 50); ok.onclick = () => done(input.value); cancel.onclick = () => done(null); close.onclick = () => done(null); input.onkeydown = (e) => { if (e.key === 'Enter') { done(input.value); } }; }); } function validateCNID(s){ if(typeof s!=='string' || !s){ return { ok:false, reason:'empty' } } s=s.trim(); if(!/^\d{17}[\dXx]$/.test(s)) return { ok:false, reason:'format' }; const y=Number(s.slice(6,10)); const m=Number(s.slice(10,12)); const d=Number(s.slice(12,14)); if(y<1900) return { ok:false, reason:'year' }; const dt=new Date(y, m-1, d); if(dt.getFullYear()!==y || dt.getMonth()+1!==m || dt.getDate()!==d) return { ok:false, reason:'date' }; if(dt>new Date()) return { ok:false, reason:'future' }; const w=[7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2]; let sum=0; for(let i=0;i<17;i++){ sum+=Number(s[i])*w[i] } const map=['1','0','X','9','8','7','6','5','4','3','2']; const code=map[sum%11]; const ok = code.toUpperCase()===s[17].toUpperCase(); return { ok, reason: ok? 'ok' : 'check' } } function isValidCNID(s){ return !!validateCNID(s).ok } function setStatus(t) { const a=qs('status'); if(a) { a.textContent=t; // 添加状态动画 a.style.opacity = '0'; setTimeout(() => { a.style.transition = 'opacity 0.3s ease'; a.style.opacity = '1'; }, 0); } const b=qs('statusTop'); if(b) b.textContent=t; try{ logDebug('status', t) }catch{} } // 添加按钮加载动画 function setButtonLoading(button, loading = true) { if (loading) { button.disabled = true; button.innerHTML = ' ' + button.textContent.replace('⏳ ', '').replace('🔄 ', ''); } else { button.disabled = false; // 恢复按钮原始图标 const originalText = button.dataset.originalText || button.textContent; button.innerHTML = originalText; } } // 添加按钮点击动画 function addButtonAnimation(button) { button.addEventListener('click', function() { // 保存原始文本 if (!this.dataset.originalText) { this.dataset.originalText = this.innerHTML; } // 添加点击效果 this.style.transform = 'scale(0.95)'; setTimeout(() => { this.style.transform = 'scale(1)'; }, 150); }); } async function saveTokenSecure(token) { try { if(!(window.crypto && window.crypto.subtle)){ localStorage.setItem('tk_plain', token); return } const salt = localStorage.getItem('dev_salt') || (Math.random().toString(36).slice(2)) localStorage.setItem('dev_salt', salt) const keyMaterial = await window.crypto.subtle.importKey('raw', new TextEncoder().encode(navigator.userAgent + salt), 'PBKDF2', false, ['deriveKey']) const key = await window.crypto.subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', salt: new TextEncoder().encode('local'), iterations: 100000 }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']) const iv = new Uint8Array(12) crypto.getRandomValues(iv) const ct = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(token)) const payload = btoa(String.fromCharCode(...new Uint8Array(ct))) localStorage.setItem('tk', payload) localStorage.setItem('iv', btoa(String.fromCharCode(...iv))) } catch { try { localStorage.setItem('tk_plain', token) } catch {} } } async function loadTokenSecure() { const plain = localStorage.getItem('tk_plain') if (plain) return plain const payload = localStorage.getItem('tk') const ivB64 = localStorage.getItem('iv') const salt = localStorage.getItem('dev_salt') if (!payload || !ivB64 || !salt) return null if(!(window.crypto && window.crypto.subtle)) return null const keyMaterial = await window.crypto.subtle.importKey('raw', new TextEncoder().encode(navigator.userAgent + salt), 'PBKDF2', false, ['deriveKey']) const key = await window.crypto.subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', salt: new TextEncoder().encode('local'), iterations: 100000 }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']) const iv = new Uint8Array([...atob(ivB64)].map(c => c.charCodeAt(0))) const ct = new Uint8Array([...atob(payload)].map(c => c.charCodeAt(0))) try { const pt = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct) return new TextDecoder().decode(pt) } catch { return null } } async function calcMasterFp(s){ const material = await window.crypto.subtle.importKey('raw', new TextEncoder().encode(s), 'PBKDF2', false, ['deriveBits']); const bits = await window.crypto.subtle.deriveBits({ name:'PBKDF2', hash:'SHA-256', salt: new TextEncoder().encode('local-fp'), iterations:120000 }, material, 256); const b = new Uint8Array(bits); return btoa(String.fromCharCode(...b)) } async function deriveFileKey(master, saltBytes) { const material = await window.crypto.subtle.importKey('raw', new TextEncoder().encode(master), 'PBKDF2', false, ['deriveBits']) const bits = await window.crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: saltBytes, iterations: 150000 }, material, 256) const keyBytes = new Uint8Array(bits) return window.crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']) } async function deriveFileKeyForShare(master, saltBytes) { const material = await window.crypto.subtle.importKey('raw', new TextEncoder().encode(master), 'PBKDF2', false, ['deriveBits']) const bits = await window.crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: saltBytes, iterations: 150000 }, material, 256) const keyBytes = new Uint8Array(bits) const key = await window.crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']) return { key, raw: keyBytes } } function ivForChunk(saltBytes, index) { const data = new Uint8Array(saltBytes.length + 4) data.set(saltBytes, 0) data.set(new Uint8Array([index >>> 24, (index >>> 16) & 255, (index >>> 8) & 255, index & 255]), saltBytes.length) return crypto.subtle.digest('SHA-256', data).then(d => new Uint8Array(d).slice(0, 12)) } async function encryptChunk(key, iv, chunk) { return window.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, chunk) } async function decryptChunk(key, iv, chunk) { return window.crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, chunk) } async function refreshList() { try { setStatus('🔄 正在加载文件列表...') const [r, dirs] = await Promise.all([api.list(), dirApi.list(state.dir)]) if ((r && (r.error === 'unauthorized' || r.error === 'invalid_token')) || (dirs && (dirs.error === 'unauthorized' || dirs.error === 'invalid_token'))) { setStatus('❌ 未登录或会话失效') qs('dashboard').classList.add('hidden') qs('login').classList.remove('hidden') return } const tbody = qs('fileTable').querySelector('tbody') tbody.innerHTML = '' if (!dirs || dirs.error) { setStatus('❌ 目录加载失败'); showEmptyState(); return } let totalItems = 0 // 处理文件夹 for (const d of (dirs.dirs || [])) { const tr = document.createElement('tr') const nameTd = document.createElement('td'); nameTd.innerHTML = `📁 ${d.name}` const typeTd = document.createElement('td'); typeTd.textContent = '文件夹' const sizeTd = document.createElement('td'); sizeTd.textContent = formatFileSize(d.size || 0) const lastTd = document.createElement('td'); lastTd.textContent = new Date(d.last_modified).toLocaleString() const statusTd = document.createElement('td'); statusTd.textContent = '' const opsTd = document.createElement('td') const enterBtn = document.createElement('button'); enterBtn.className = 'icon-btn' enterBtn.innerHTML = '📂'; enterBtn.dataset.tip = '进入该文件夹' enterBtn.onclick = ()=>{ state.dir = d.path; qs('curDir').textContent = state.dir; refreshList() } const delBtn = document.createElement('button'); delBtn.className = 'icon-btn' delBtn.innerHTML = '🗑️'; delBtn.dataset.tip = '删除文件夹' delBtn.onclick = async()=>{ const ok = await askConfirm('确认删除该文件夹?'); if(!ok){ setStatus('已取消'); return } logDebug('dir.del.click', d.path); const r= await dirApi.delete(d.path); logDebug('dir.del.res', JSON.stringify(r)); if(r && r.error==='not_empty'){ setStatus('文件夹非空,无法删除') } await refreshList() } const rnBtn = document.createElement('button'); rnBtn.className = 'icon-btn' rnBtn.innerHTML = '✏️'; rnBtn.dataset.tip = '重命名文件夹' rnBtn.onclick = async()=>{ const nn = await askPrompt('新名称', nameTd.textContent); if(!nn) return; const ok = await askConfirm('确认重命名为:' + nn + '?'); if(!ok){ setStatus('已取消'); return } await dirApi.rename(d.path, nn); await refreshList() } opsTd.appendChild(enterBtn); opsTd.appendChild(delBtn); opsTd.appendChild(rnBtn) tr.appendChild(nameTd); tr.appendChild(typeTd); tr.appendChild(sizeTd); tr.appendChild(lastTd); tr.appendChild(statusTd); tr.appendChild(opsTd) tbody.appendChild(tr) totalItems++ } if (!r || r.error) { setStatus('❌ 未登录或会话失效'); showEmptyState(); return } // 处理文件 for (const f of (r.files || [])) { const tr = document.createElement('tr') const fileIcon = getFileIcon(f.name) const nameTd = document.createElement('td'); nameTd.innerHTML = `${fileIcon} ${f.name}` const typeTd = document.createElement('td'); typeTd.textContent = f.type || '文件' const sizeTd = document.createElement('td') sizeTd.textContent = formatFileSize(f.size) const lastTd = document.createElement('td'); lastTd.textContent = new Date(f.last_modified).toLocaleString() const statusTd = document.createElement('td') statusTd.innerHTML = getStatusIcon(f.status) const opsTd = document.createElement('td') const dlBtn = document.createElement('button') dlBtn.className = 'icon-btn' dlBtn.innerHTML = '⬇️' dlBtn.dataset.tip = '下载文件' dlBtn.onclick = () => downloadFile(f.id, f.name) const ready = (f.status === 'complete' || f.status === 'stored') if(!ready){ dlBtn.disabled=true; dlBtn.title='文件未完成上传,暂不可下载' dlBtn.innerHTML = '⏳' dlBtn.dataset.tip = '文件未完成上传,暂不可下载' } const shareBtn = document.createElement('button') shareBtn.className = 'icon-btn' shareBtn.innerHTML = '🔗' shareBtn.dataset.tip = '分享链接' shareBtn.onclick = () => { shareCtx.fileId = f.id; shareCtx.link = null; const modal = qs('shareCreateModal'); modal.dataset.fileId = String(f.id); qs('shareQrImg').style.display='none'; qs('shareLinkOutput').textContent=''; qs('shareCreatePwd').value=''; const chips = document.querySelectorAll('#shareCreateModal .chip'); chips.forEach(c=>{ c.classList.remove('active') }); document.querySelector('#shareCreateModal .chip[data-days="30"]').classList.add('active'); qs('shareMasterWrap').style.display = 'block'; const sm=qs('shareMaster'); if(sm) sm.value=''; const af=qs('shareAutoFill'); if(af) af.checked=false; modal.classList.remove('hidden'); updateShareButtons() } if(!ready){ shareBtn.disabled=true; shareBtn.title='文件未完成上传,暂不可分享' shareBtn.innerHTML = '⏳' shareBtn.dataset.tip = '文件未完成上传,暂不可分享' } const delBtn = document.createElement('button') delBtn.className = 'icon-btn' delBtn.innerHTML = '🗑️' delBtn.dataset.tip = '删除文件' delBtn.onclick = async () => { const ok = await askConfirm('确认删除:' + f.name + '?'); if(!ok){ setStatus('已取消'); return } logDebug('file.del.click', f.id); const r = await fetch(`/api/psc/files/${f.id}`, { method: 'DELETE', headers: { Authorization: 'Bearer ' + state.token } }); let j=null; try{ j=await r.json() }catch{} logDebug('file.del.status', r.status); if(j) logDebug('file.del.json', JSON.stringify(j)); refreshList() } const sliceBtn = document.createElement('button') sliceBtn.className = 'icon-btn' sliceBtn.innerHTML = '🔪' sliceBtn.dataset.tip = '7Zip 分卷切片' sliceBtn.onclick = () => openSliceModal(f) if(!ready){ sliceBtn.disabled=true; sliceBtn.title='文件未完成上传,暂不可切片' sliceBtn.innerHTML = '⏳' sliceBtn.dataset.tip = '文件未完成上传,暂不可切片' } const viewSliceBtn = document.createElement('button') viewSliceBtn.className = 'icon-btn' viewSliceBtn.innerHTML = '📋' viewSliceBtn.dataset.tip = '查看切片' viewSliceBtn.onclick = () => openSliceModal(f) const sliceVolMB = Number( (f && (f.slice_volumeMB ?? f.sliceVolumeMB ?? f.volumeMB)) ?? (f && f.slice && (f.slice.volumeMB ?? f.slice.volume_mb)) ?? 0 ) const hasSlice = Number.isFinite(sliceVolMB) && sliceVolMB > 0 if(!ready || !hasSlice){ viewSliceBtn.disabled = true if(!ready){ viewSliceBtn.innerHTML = '⏳' viewSliceBtn.dataset.tip = '文件未完成上传,暂不可查看' } else { viewSliceBtn.dataset.tip = '未切片,无法查看' } } opsTd.appendChild(dlBtn) opsTd.appendChild(shareBtn) opsTd.appendChild(delBtn) opsTd.appendChild(sliceBtn) opsTd.appendChild(viewSliceBtn) initTooltips() tr.appendChild(nameTd); tr.appendChild(typeTd); tr.appendChild(sizeTd); tr.appendChild(lastTd); tr.appendChild(statusTd); tr.appendChild(opsTd) tr.oncontextmenu = (e)=> openFileCtx(e, f) tbody.appendChild(tr) totalItems++ } // 显示/隐藏空状态 if (totalItems === 0) { showEmptyState() } else { hideEmptyState() setStatus(`✅ 已加载 ${totalItems} 个项目`) } } catch(e) { setStatus('❌ 刷新失败,可能未登录或网络异常') showEmptyState() } } // 辅助函数 function getFileIcon(filename) { const ext = filename.split('.').pop().toLowerCase() const icons = { 'pdf': '📄', 'doc': '📝', 'docx': '📝', 'xls': '📊', 'xlsx': '📊', 'ppt': '📽️', 'pptx': '📽️', 'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️', 'svg': '🖼️', 'mp3': '🎵', 'wav': '🎵', 'flac': '🎵', 'mp4': '🎬', 'avi': '🎬', 'mkv': '🎬', 'zip': '📦', 'rar': '📦', '7z': '📦', 'txt': '📄', 'md': '📄', 'exe': '⚙️', 'msi': '⚙️' } return icons[ext] || '📄' } function formatFileSize(bytes) { if (bytes < 1024) return bytes + ' B' if (bytes < 1048576) return (bytes / 1024).toFixed(2) + ' KB' if (bytes < 1073741824) return (bytes / 1048576).toFixed(2) + ' MB' return (bytes / 1073741824).toFixed(2) + ' GB' } function getStatusIcon(status) { const icons = { 'stored': '✅ 已存储', 'uploading': '⏳ 上传中', 'error': '❌ 错误', 'pending': '⏸️ 等待中' } return icons[status] || status } function showEmptyState() { const table = qs('fileTable') const emptyState = qs('emptyState') if (table) table.classList.add('hidden') if (emptyState) emptyState.classList.remove('hidden') } function hideEmptyState() { const table = qs('fileTable') const emptyState = qs('emptyState') if (table) table.classList.remove('hidden') if (emptyState) emptyState.classList.add('hidden') } function openSliceModal(file){ const m=qs('sliceModal'); if(!m) return; m.dataset.fileId=String(file.id); m.dataset.fileSize=String(file.size||0); m.dataset.fileName=String(file.name||'file'); m.dataset.fileSalt=String((file && (file.salt || file.fileSalt)) || ''); m.dataset.chunkSize=String((file && (file.chunk_size || file.chunkSize)) || ''); const nameEl=qs('sliceFileName'); if(nameEl) nameEl.textContent=file.name; const sizeInp=qs('sliceSize'); if(sizeInp && !sizeInp.value) sizeInp.value='100'; renderSliceList(file.id); m.classList.remove('hidden'); const close=qs('btnSliceClose'); if(close) close.onclick=()=>{ m.classList.add('hidden') }; const start=qs('btnSliceStart'); if(start) start.onclick=async()=>{ const vol=Number(qs('sliceSize').value||'100'); setStatus('开始切片'); try{ const r=await api.sliceCreate(file.id, vol); if(r && r.error){ setStatus('切片失败:'+r.error + (r.code? ('('+r.code+')') : '') + (r.detail? (' | '+r.detail.slice(0,120)) : '')) } else if(r && r.ok){ setStatus('切片完成'); renderSliceList(file.id) } else { setStatus('切片失败:未知错误') } }catch{ setStatus('切片失败:网络错误或服务不可用') } }; const re=qs('btnSliceReslice'); if(re) re.onclick=async()=>{ const vol=Number(qs('sliceSize').value||'100'); setStatus('重新切片'); try{ const r=await api.sliceCreate(file.id, vol); if(r && r.error){ setStatus('切片失败:'+r.error + (r.code? ('('+r.code+')') : '') + (r.detail? (' | '+r.detail.slice(0,120)) : '')) } else if(r && r.ok){ setStatus('切片完成'); renderSliceList(file.id) } else { setStatus('切片失败:未知错误') } }catch{ setStatus('切片失败:网络错误或服务不可用') } } } let sliceDlCache = { fileId: null, fileSaltB64: null, key: null } async function getDecryptKeyForFile(fileId, fileSaltB64){ if(sliceDlCache && sliceDlCache.fileId === fileId && sliceDlCache.fileSaltB64 === fileSaltB64 && sliceDlCache.key){ return sliceDlCache.key } const input = await askPwd('请输入18位身份证号用于解密分卷下载') if(!input){ setStatus('请输入18位身份证号'); return null } if(state.masterFp){ const fp = await calcMasterFp(input) if(fp !== state.masterFp){ setStatus('身份证号不准确'); return null } } else { setStatus('会话失效,请重新登录') return null } const saltB64 = String(fileSaltB64 || '') if(!saltB64){ setStatus('文件密钥信息缺失'); return null } const fileSalt = new Uint8Array([...atob(saltB64)].map(c=>c.charCodeAt(0))) const key = await deriveFileKey(input, fileSalt) sliceDlCache = { fileId, fileSaltB64: saltB64, key } return key } async function downloadSlicePartDecrypted(fileId, partIndex, volMB){ const m = qs('sliceModal') const fileName = String((m && m.dataset && m.dataset.fileName) || 'file') const totalSize = Number((m && m.dataset && m.dataset.fileSize) || 0) const chunkSize = Number((m && m.dataset && m.dataset.chunkSize) || 0) const fileSaltB64 = String((m && m.dataset && m.dataset.fileSalt) || '') if(!Number.isFinite(totalSize) || totalSize <= 0){ setStatus('文件大小异常'); return } if(!Number.isFinite(chunkSize) || chunkSize <= 0){ setStatus('文件分片信息异常'); return } const volSize = Number(volMB) > 0 ? Number(volMB) * 1024 * 1024 : 0 if(!Number.isFinite(volSize) || volSize <= 0){ setStatus('分卷大小异常'); return } const start = partIndex * volSize const end = Math.min((partIndex + 1) * volSize, totalSize) if(start >= totalSize || end <= start){ setStatus('分卷索引超出范围'); return } const key = await getDecryptKeyForFile(fileId, fileSaltB64) if(!key) return const fileSalt = new Uint8Array([...atob(fileSaltB64)].map(c=>c.charCodeAt(0))) const outLen = end - start const out = new Uint8Array(outLen) let outPos = 0 const firstChunk = Math.floor(start / chunkSize) const lastChunk = Math.floor((end - 1) / chunkSize) for(let i=firstChunk;i<=lastChunk;i++){ setStatus(`分卷下载中:${partIndex+1} | 读取分片 ${i+1}/${lastChunk+1}`) const buf = new Uint8Array(await api.downloadChunk(fileId, i)) const iv = await ivForChunk(fileSalt, i) const pt = new Uint8Array(await decryptChunk(key, iv, buf)) const chunkStart = i * chunkSize const takeStart = Math.max(start, chunkStart) const takeEnd = Math.min(end, chunkStart + pt.length) const srcOff = takeStart - chunkStart const takeLen = takeEnd - takeStart if(takeLen > 0){ out.set(pt.subarray(srcOff, srcOff + takeLen), outPos) outPos += takeLen } } const pad3 = (n) => String(n).padStart(3,'0') const blob = new Blob([out], { type:'application/octet-stream' }) const a = document.createElement('a') a.href = URL.createObjectURL(blob) a.download = `${fileName}.${pad3(partIndex+1)}` document.body.appendChild(a) a.click() setTimeout(()=>{ URL.revokeObjectURL(a.href); document.body.removeChild(a) }, 0) setStatus(`分卷 ${partIndex+1} 下载完成`) } async function renderSliceList(id){ try{ const info=await api.sliceInfo(id); const m=qs('sliceModal'); const totalSize = Number((m && m.dataset && m.dataset.fileSize) || 0); const fileName = String((m && m.dataset && m.dataset.fileName) || 'file'); const volMB = Number((info && (info.volumeMB || info.volume_mb)) || (info && info.slice && info.slice.volMB) || 0); const volSize = volMB > 0 ? volMB * 1024 * 1024 : 0; const partCount = (volSize > 0 && totalSize > 0) ? Math.ceil(totalSize / volSize) : 0; const box=qs('sliceInfo'); if(box){ box.innerHTML=''; const note=document.createElement('div'); note.textContent = (volMB > 0 && partCount > 0) ? (`已设置分卷大小:${volMB} MB,共 ${partCount} 卷`) : '尚未切片'; box.appendChild(note) } const list=qs('sliceList'); if(list){ list.innerHTML=''; if(!(volMB > 0 && partCount > 0)) return; const pad3 = (n) => String(n).padStart(3,'0'); for(let i=0;i{ try{ await downloadSlicePartDecrypted(id, i, volMB) }catch{ setStatus('下载失败') } }; li.appendChild(t); li.appendChild(b); list.appendChild(li) } } }catch{} } async function uploadSelected() { const files = Array.from(qs('fileInput').files || []) if (files.length === 0) return try { logDebug('upload.ctx', 'has.token', !!state.token, 'has.master', !!state.master, 'dir', state.dir, 'files', files.length) } catch{} if(!state.token){ setStatus('请先登录'); try{ logDebug('upload.no_token') }catch{}; return } const input = await askPwd('请输入18位身份证号用于加密上传') try{ logDebug('upload.prompt', 'input.len', (input||'').length, 'has.login.master', !!state.master) }catch{} if(!input){ setStatus('请输入18位身份证号'); return } let master = input if(state.master){ const eq = (input === state.master); try{ logDebug('upload.master.eq', eq) }catch{}; if(!eq){ setStatus('身份证号不准确'); try{ logDebug('upload.master.mismatch') }catch{}; return } master = state.master } else if(state.masterFp){ const fp = await calcMasterFp(input); const eq = (fp === state.masterFp); try{ logDebug('upload.fp.eq', eq) }catch{}; if(!eq){ setStatus('身份证号不准确'); try{ logDebug('upload.fp.mismatch') }catch{}; return } master = input } else { setStatus('会话失效,请重新登录'); try{ logDebug('upload.no_master_fp') }catch{}; return } const range = qs('sizeRange').value const pickChunk = (f) => { if (range === 'lt1') return 5242880 if (range === '1to10') return 8388608 return 16777216 } async function uploadOne(file) { let progressEl try { // 显示上传进度条 progressEl = qs('uploadProgress') const fileNameEl = qs('uploadFileName') const progressTextEl = qs('uploadProgressText') const progressBarEl = qs('uploadProgressBar') if (progressEl && fileNameEl && progressTextEl && progressBarEl) { progressEl.classList.remove('hidden') fileNameEl.textContent = file.name progressTextEl.textContent = '准备中...' progressBarEl.style.width = '0%' } const chunkSize = pickChunk(file) const fileSalt = new Uint8Array(16) crypto.getRandomValues(fileSalt) const fileSaltB64 = btoa(String.fromCharCode(...fileSalt)) const key = await deriveFileKey(master, fileSalt) const init = await api.initFile(file.name, file.size, chunkSize, fileSaltB64) try{ logDebug('upload.init.json', JSON.stringify(init)) }catch{} if(init && init.error === 'unauthorized'){ try{ logDebug('upload.init.unauthorized') }catch{}; setStatus('会话失效,请重新登录'); qs('login').classList.remove('hidden'); qs('dashboard').classList.add('hidden'); return } if(init && init.error){ try{ logDebug('upload.init.error', String(init.error)) }catch{}; setStatus(`${file.name} 初始化失败:${init.error}`); return } setStatus(`⏫ 开始上传 ${file.name}`) const fileId = init.fileId if(!fileId){ setStatus(`初始化失败`); return } let index = 0 let processed = 0 if (file.stream && typeof file.stream === 'function') { const reader = file.stream().getReader() let buffer = new Uint8Array(0) async function pushChunk() { while (buffer.length >= chunkSize) { const chunk = buffer.slice(0, chunkSize) buffer = buffer.slice(chunkSize) const iv = await ivForChunk(fileSalt, index) const ct = await encryptChunk(key, iv, chunk) try { await api.uploadChunk(fileId, index, new Uint8Array(ct)) } catch(e){ setStatus(`${file.name} 分片上传失败`); throw e } index++ processed += chunk.length setStatus(`${file.name} 已上传 ${(processed/file.size*100).toFixed(2)}%`) } } while (true) { const { done, value } = await reader.read() if (done) break const concat = new Uint8Array(buffer.length + value.length) concat.set(buffer, 0) concat.set(value, buffer.length) buffer = concat await pushChunk() } if (buffer.length > 0) { const iv = await ivForChunk(fileSalt, index) const ct = await encryptChunk(key, iv, buffer) try { await api.uploadChunk(fileId, index, new Uint8Array(ct)) } catch(e){ setStatus(`${file.name} 分片上传失败`); throw e } index++ processed += buffer.length setStatus(`${file.name} 已上传 ${(processed/file.size*100).toFixed(2)}%`) } } else { const total = file.size while (index * chunkSize < total) { const start = index * chunkSize const end = Math.min(start + chunkSize, total) const part = new Uint8Array(await file.slice(start, end).arrayBuffer()) const iv = await ivForChunk(fileSalt, index) const ct = await encryptChunk(key, iv, part) try { await api.uploadChunk(fileId, index, new Uint8Array(ct)) } catch(e){ setStatus(`${file.name} 分片上传失败`); throw e } index++ processed += part.length const progress = (processed/file.size*100).toFixed(2) setStatus(`⏫ ${file.name} 已上传 ${progress}%`) // 更新进度条动画 const progressBar = document.getElementById('uploadProgressBar') if (progressBar) { progressBar.style.width = `${progress}%` } } } const fin = await api.complete(fileId) try { logDebug('upload.complete.json', JSON.stringify(fin)) } catch {} // 隐藏上传进度条 if (progressEl) { progressEl.classList.add('hidden') } if(!(fin && fin.ok)){ try { const stRes = await fetch(`/files/${fileId}/status`, { headers: { Authorization: 'Bearer ' + state.token } }); const st = await stRes.json(); logDebug('upload.status', JSON.stringify(st)) } catch{} } try { const latest = await logsApi.list({ limit: 1, offset: 0, category: 'file' }); logDebug('logs.latest', JSON.stringify(latest)) } catch{} if(fin && fin.ok){ setStatus(`✅ ${file.name} 上传完成`) // 添加成功动画 setTimeout(() => { setStatus('✅ 文件上传成功') }, 1000) } else { setStatus(`❌ ${file.name} 完成失败:${fin && fin.error || ''}`) } } catch(e){ setStatus(`❌ ${file.name} 上传失败`); // 隐藏上传进度条 if (progressEl) { progressEl.classList.add('hidden') } try { const stRes = await fetch(`/files/${fileId}/status`, { headers: { Authorization: 'Bearer ' + state.token } }); const st = await stRes.json(); logDebug('upload.status', JSON.stringify(st)) } catch{} } } const maxParallel = 4 let active = 0 let next = 0 return new Promise(async (resolve) => { async function runOne(i){ active++; try { await uploadOne(files[i]) } finally { active--; schedule() } } function schedule(){ while(active < maxParallel && next < files.length){ runOne(next); next++ } if(active===0 && next>=files.length){ resolve(); refreshList() } } schedule() }) } async function createShare(fileId){ const pwd = await askPrompt('设置分享密码') if(!pwd) return // get metadata for deriving file key const init = await api.downloadInit(fileId) const saltB64 = (init && (init.fileSalt || init.salt)) || '' const fileSalt = new Uint8Array([...atob(saltB64)].map(c => c.charCodeAt(0))) const inputM = await askPwd('请输入18位身份证号用于生成分享') if(!inputM){ setStatus('请输入18位身份证号'); return } if(state.masterFp){ const fp = await calcMasterFp(inputM); if(fp !== state.masterFp){ setStatus('身份证号不准确'); return } } const fKey = await deriveFileKey(inputM, fileSalt) // export key bytes const raw = new Uint8Array(await crypto.subtle.exportKey('raw', fKey)) const shareSalt = new Uint8Array(16); crypto.getRandomValues(shareSalt) const sMaterial = await crypto.subtle.importKey('raw', new TextEncoder().encode(pwd), 'PBKDF2', false, ['deriveKey']) const sKey = await crypto.subtle.deriveKey({ name:'PBKDF2', hash:'SHA-256', salt: shareSalt, iterations:200000 }, sMaterial, { name:'AES-GCM', length:256 }, false, ['encrypt']) const iv = new Uint8Array(12); crypto.getRandomValues(iv) const ct = await crypto.subtle.encrypt({ name:'AES-GCM', iv }, sKey, raw) const wrappedKeyB64 = btoa(String.fromCharCode(...new Uint8Array(ct))) const shareSaltB64 = btoa(String.fromCharCode(...shareSalt)) const ivB64 = btoa(String.fromCharCode(...iv)) const r = await fetch('/api/psc/share/create', { method:'POST', headers:{ 'Content-Type':'application/json', Authorization:'Bearer '+state.token }, body: JSON.stringify({ fileId, wrappedKeyB64, shareSaltB64, ivB64, password: pwd }) }) const j = await r.json() if(j.shareId){ const url = location.origin + '/s/' + j.shareId; setStatus('分享已生成:' + url); try { await navigator.clipboard.writeText(url); setStatus('分享已生成并复制到剪贴板:' + url) } catch {} } else { setStatus('分享生成失败') } } async function downloadFile(id, name) { try { setStatus('准备下载') const init = await api.downloadInit(id) if(init && init.error === 'unauthorized'){ setStatus('会话失效,请重新登录'); qs('login').classList.remove('hidden'); qs('dashboard').classList.add('hidden'); return } if(init && init.error === 'not_found'){ setStatus('文件未完成上传或不存在'); return } const saltB64 = (init && (init.fileSalt || init.salt)) || '' const fileSalt = new Uint8Array([...atob(saltB64)].map(c=>c.charCodeAt(0))) const chunkSize = Number((init && (init.chunkSize || init.chunk_size)) || 0) const totalSize = Number((init && init.size) || 0) const chunkCount = Number((init && (init.chunkCount || init.chunk_count)) || (chunkSize > 0 ? Math.ceil(totalSize / chunkSize) : 0)) const input = await askPwd('请输入18位身份证号用于解密下载') logDebug('download.prompt', 'input.len', (input||'').length, 'has.login.master', !!state.master) if(!input){ setStatus('请输入18位身份证号'); return } let m = input if(state.master){ const eq = (input === state.master); logDebug('download.master.eq', eq); if(!eq){ setStatus('身份证号不准确'); return } m = state.master } else if(state.masterFp){ const fp = await calcMasterFp(input); const eq = (fp === state.masterFp); logDebug('download.fp.eq', eq); if(!eq){ setStatus('身份证号不准确'); return } m = input } else { setStatus('会话失效,请重新登录'); return } const key = await deriveFileKey(m, fileSalt) logDebug('download.key.derived') if (!Number.isFinite(totalSize) || totalSize < 0) { setStatus('下载失败:文件大小异常'); logDebug('download.init.bad_size', JSON.stringify(init)); return } if (totalSize === 0) { const blob = new Blob([new Uint8Array(0)]) const a = document.createElement('a') a.href = URL.createObjectURL(blob) a.download = name a.click() URL.revokeObjectURL(a.href) setStatus('下载完成') return } if (!Number.isFinite(chunkCount) || chunkCount <= 0) { setStatus('下载失败:文件分片信息异常'); logDebug('download.init.bad_chunks', JSON.stringify(init)); return } const chunks = new Array(chunkCount) let active = 0, next = 0, processed = 0 const maxParallel = 4 return new Promise(async (resolve) => { async function runOne(i) { try { active++ const buf = new Uint8Array(await api.downloadChunk(id, i)) const iv = await ivForChunk(fileSalt, i) const pt = new Uint8Array(await decryptChunk(key, iv, buf)) chunks[i] = pt processed += pt.length setStatus(`已下载 ${(processed/totalSize*100).toFixed(2)}%`) } catch(e){ setStatus('解密失败,可能密码不匹配或网络异常'); logDebug('download.chunk.error', i, e && (e.message||String(e))) } finally { active--; schedule() } } function schedule() { while (active < maxParallel && next < chunkCount) { runOne(next); next++ } if (active === 0 && next >= chunkCount) doneAll() } function doneAll() { const out = new Uint8Array(totalSize) let offset = 0 for (let i = 0; i < chunkCount; i++) { out.set(chunks[i], offset); offset += chunks[i].length } const blob = new Blob([out]) const a = document.createElement('a') a.href = URL.createObjectURL(blob) a.download = name a.click() URL.revokeObjectURL(a.href) setStatus('下载完成') resolve() } schedule() }) } catch(e){ setStatus('下载失败'); logDebug('download.error', e && (e.message||String(e))) } } async function initUi() { logDebug('initUi.start') // 禁止浏览器记忆与辅助录入(全局) try { const inputs = Array.from(document.querySelectorAll('input,textarea')) const suffix = '-' + Math.random().toString(36).slice(2) inputs.forEach(el=>{ const isPwd = (el.type==='password') el.setAttribute('autocomplete', isPwd ? 'new-password' : 'off') el.setAttribute('autocapitalize','off') el.setAttribute('spellcheck','false') el.name = (el.name? el.name : ('no-save-'+(el.id||'field'))) + suffix if(isPwd || el.id==='shareCreatePwd' || el.id==='newDirName') el.value='' }) } catch{} let setupOk=false try { const st = await (await fetch('/api/psc/auth/status')).json(); logDebug('auth.status', JSON.stringify(st)); setupOk = !!(st && st.setup); if (setupOk) qs('setup').classList.add('hidden'); else { qs('setup').classList.remove('hidden'); qs('login').classList.add('hidden'); qs('dashboard').classList.add('hidden'); setStatus('未初始化') } } catch(e){ logDebug('auth.status.error', e && (e.message||String(e))) } const maybe = await loadTokenSecure() if (maybe) { state.token = maybe try{ state.masterFp = localStorage.getItem('mfp') || null; logDebug('token.loaded', true, 'has.mfp', !!state.masterFp) }catch{} qs('login').classList.add('hidden') qs('setup').classList.add('hidden') qs('dashboard').classList.remove('hidden') setStatus('已登录') applyRoleUi() await detectAdmin() await refreshList() } else if (setupOk) { qs('login').classList.remove('hidden') qs('setup').classList.add('hidden') qs('dashboard').classList.add('hidden') setStatus('请登录') } qs('btnSetup').onclick = async () => { const base = qs('baseCode').value const master = qs('newMaster').value const button = qs('btnSetup') if (!base || !master) { setStatus('❌ 请填写完整信息') return } try { setButtonLoading(button, true) logDebug('setup.req', 'base.len', (base||'').length, 'id.len', (master||'').length) const r = await api.setup(base, master) logDebug('setup.json', JSON.stringify(r)) if (r.ok) { setStatus('✅ 初始化成功'); qs('setup').classList.add('hidden') qs('login').classList.remove('hidden') } else { setStatus('❌ 初始化失败') } } catch(e) { logDebug('setup.error', e && (e.message||String(e))); setStatus('❌ 网络错误,初始化失败') } finally { setButtonLoading(button, false) } } qs('btnLogin').onclick = async () => { const m = qs('master').value const button = qs('btnLogin') if (!m) { setStatus('❌ 请输入身份证号') return } logDebug('login.skip_format_check') try { setButtonLoading(button, true) const cap = await askRotate(); if(!cap){ setStatus('❌ 请完成身份验证'); setButtonLoading(button, false) return } logDebug('login.req', 'id.len', (m||'').length) const r = await api.login(m, cap) logDebug('login.json', JSON.stringify(r)) if (r.token) { state.token = r.token try{ state.masterFp = await calcMasterFp(m); localStorage.setItem('mfp', state.masterFp) }catch{} await saveTokenSecure(r.token) qs('master').value = '' qs('login').classList.add('hidden') qs('setup').classList.add('hidden') qs('dashboard').classList.remove('hidden') setStatus('✅ 登录成功') applyRoleUi() await detectAdmin() await refreshList() } else if (r && r.error === 'not_setup') { setStatus('❌ 未初始化,请先设置身份证号') } else { setStatus(r && r.error === 'unauthorized' ? '❌ 登录失败:身份证号不正确' : (r && r.error === 'captcha_invalid' ? '❌ 验证失败:请将图像转正' : '❌ 登录失败')) } } catch(e) { setStatus('❌ 网络错误,登录失败') } finally { setButtonLoading(button, false) } } qs('btnLogout').onclick = () => { localStorage.removeItem('tk'); localStorage.removeItem('iv'); localStorage.removeItem('dev_salt'); localStorage.removeItem('mfp') state.token = null; state.master = null; state.masterFp = null qs('master').value = '' qs('newMaster').value = '' qs('baseCode').value = '' qs('dashboard').classList.add('hidden') qs('login').classList.remove('hidden') setStatus('已退出') } qs('btnUpload').onclick = ()=>{ logDebug('upload.click'); const button = qs('btnUpload') setButtonLoading(button, true) uploadSelected().finally(() => { setButtonLoading(button, false) }) } qs('btnRefresh').onclick = ()=>{ logDebug('refresh.click'); const button = qs('btnRefresh') setButtonLoading(button, true) refreshList().finally(() => { setButtonLoading(button, false) }) } qs('btnNewDir').onclick = async ()=>{ const name = qs('newDirName').value; const button = qs('btnNewDir') if(!name) { setStatus('❌ 请输入文件夹名称') return } logDebug('dir.new.click', 'name.len', (name||'').length); setButtonLoading(button, true) try { await dirApi.create(state.dir, name); setStatus('✅ 文件夹创建成功') qs('newDirName').value = '' await refreshList() } catch(e) { setStatus('❌ 文件夹创建失败') } finally { setButtonLoading(button, false) } } qs('btnUp').onclick = async ()=>{ logDebug('dir.up.click', 'from', state.dir); const parts = state.dir.split('/').filter(Boolean) parts.pop() state.dir = '/' + parts.join('/') if(state.dir==='') state.dir='/' qs('curDir').textContent = state.dir await refreshList() } qs('sizeRange').onchange = ()=>{ logDebug('sizeRange.change') const v = qs('sizeRange').value if(v==='lt1') setStatus('分块大小将使用 5MB') else if(v==='1to10') setStatus('分块大小将使用 8MB') else setStatus('分块大小将使用 16MB') } qs('btnOpenSettings').onclick = ()=>{ logDebug('settings.open.click'); qs('resetModal').classList.remove('hidden'); ['resetBase','resetMaster','setupWipeCode1','setupWipeCode2','wipeCode1','wipeCode2'].forEach(id=>{ const e=qs(id); if(e) e.value='' }); const rm = qs('resetMaster'); if(rm){ rm.value = '' } } qs('btnCloseReset').onclick = ()=>{ qs('resetModal').classList.add('hidden') } qs('btnResetMaster').onclick = async ()=>{ const bc = (qs('resetBase').value||'').trim(); const nm = (qs('resetMaster').value||'').trim(); logDebug('reset.click', 'base.len', (bc||'').length, 'pwd.len', (nm||'').length); if(!state.token){ setStatus('请先登录'); return } try { const r = await fetch('/api/psc/auth/change', { method:'POST', headers:{ 'Content-Type':'application/json', Authorization:'Bearer '+state.token }, body: JSON.stringify({ base_code: bc, new_master_password: nm }) }); const j = await r.json(); logDebug('reset.status', r.status); logDebug('reset.json', JSON.stringify(j)); if(j && j.ok) { setStatus('登录密码已重置'); try{ state.masterFp = await calcMasterFp(nm); localStorage.setItem('mfp', state.masterFp) }catch{}; qs('resetBase').value=''; qs('resetMaster').value=''; qs('resetModal').classList.add('hidden') } else { if(r.status===403 || (j && j.error==='forbidden')) setStatus('基础密码不正确'); else if(j && j.error==='not_setup') setStatus('未初始化,请先设置登录密码'); else setStatus('重置失败') } } catch(e) { setStatus('网络错误,重置失败') } } const btnSetupWipe = qs('btnSetupWipe'); if(btnSetupWipe){ btnSetupWipe.onclick = async ()=>{ const c1 = (qs('setupWipeCode1').value||'').trim(); const c2 = (qs('setupWipeCode2').value||'').trim(); logDebug('wipe.setup.req', 'c1.len', (c1||'').length, 'c2.len', (c2||'').length); if(!state.token){ setStatus('请先登录'); return } try { setStatus('初始化中...'); const r = await fetch('/api/psc/admin/wipe/setup', { method:'POST', headers:{ 'Content-Type':'application/json', Authorization:'Bearer '+state.token }, body: JSON.stringify({ code1: c1, code2: c2 }) }); const j = await r.json(); logDebug('wipe.setup.status', r.status); logDebug('wipe.setup.json', JSON.stringify(j)); if(r.ok){ setStatus('清空密钥已初始化'); qs('setupWipeCode1').value=''; qs('setupWipeCode2').value=''; } else { const msg = j && j.error === 'missing_wipe_key' ? '请设置WIPE_KEY并重启服务' : (j.error||('HTTP '+r.status)); setStatus('初始化失败:'+msg) } } catch(e){ setStatus('网络异常') } } } const wipeBtn = qs('btnWipeAll'); if(wipeBtn){ wipeBtn.onclick = async ()=>{ const c1 = (qs('wipeCode1').value||'').trim(); const c2 = (qs('wipeCode2').value||'').trim(); logDebug('wipe.req', 'c1.len', (c1||'').length, 'c2.len', (c2||'').length); if(!state.token){ setStatus('请先登录'); return } try { setStatus('校验中...'); const r = await fetch('/api/psc/admin/wipe', { method:'POST', headers:{ 'Content-Type':'application/json', Authorization:'Bearer '+state.token }, body: JSON.stringify({ code1: c1, code2: c2 }) }); const j = await r.json(); logDebug('wipe.status', r.status); logDebug('wipe.json', JSON.stringify(j)); if(r.ok){ setStatus('已清空所有数据'); qs('resetModal').classList.add('hidden'); await refreshList() } else { setStatus('清空失败:'+(j.error||('HTTP '+r.status))) } } catch(e){ setStatus('网络异常') } } } // 清空所有密码框,防止浏览器记住 qs('baseCode').value=''; qs('newMaster').value=''; qs('master').value=''; qs('resetBase').value=''; qs('resetMaster').value='' const bcd = qs('btnCopyDebug'); if(bcd){ bcd.onclick = async ()=>{ try{ await navigator.clipboard.writeText((qs('debugLog')?.textContent)||''); setStatus('调试信息已复制') } catch{ setStatus('请手动复制调试信息') } } } const bcd2 = qs('btnCopyDebugBottom'); if(bcd2){ bcd2.onclick = async ()=>{ try{ await navigator.clipboard.writeText((qs('debugBottom')?.textContent)||''); setStatus('调试信息已复制') } catch{ setStatus('请手动复制调试信息') } } } } function applyRoleUi(){ const s=qs('btnOpenSettings'); const l=qs('btnOpenLogs'); if(state.isAdmin){ if(s){ s.classList.remove('hidden'); s.style.display='' } if(l){ l.classList.remove('hidden'); l.style.display='' } } else { if(s){ s.classList.add('hidden'); s.style.display='none' } if(l){ l.classList.add('hidden'); l.style.display='none' } } } logDebug('app.loaded') ;(function(){ try{ const b=qs('statusTop'); if(b) b.textContent='' }catch{}; try{ fetch('/api/psc/debug/status').then(r=>r.json()).then(s=>{ debugEnabled = !!(s && s.enabled); const ids=['btnCopyDebug','debugLog','btnCopyDebugBottom','debugBottom']; ids.forEach(id=>{ const e=qs(id); if(e) e.style.display = debugEnabled ? '' : 'none' }); if(!debugEnabled){ const a=qs('debugLog'); if(a) a.textContent=''; const b=qs('debugBottom'); if(b) b.textContent='' } }) }catch{}; try{ logDebug('backend.origin', location.origin) }catch{}; try{ fetch('/version').then(r=>r.json()).then(v=>{ try{ logDebug('backend.version', String(v && v.version)) }catch{}; try{ logDebug('backend.port', String(v && v.port)) }catch{} }) }catch{} })() initUi() async function fetchAndRenderLogs(){ const from = qs('logsFrom').value; const to = qs('logsTo').value; const category = qs('logsCategory').value; const r = await logsApi.list({ from: from||'', to: to||'', limit: logsState.size, offset: logsState.offset, category }); logsState.total = Number(r.total||0); const tbody = qs('logsTable').querySelector('tbody'); tbody.innerHTML=''; for(const e of (r.logs||[])){ const tr=document.createElement('tr'); const td1=document.createElement('td'); td1.textContent=new Date(e.ts).toLocaleString(); const td2=document.createElement('td'); td2.textContent=e.type; const td3=document.createElement('td'); const d=e.data||{}; const parts=[]; if(d.name){ parts.push('名称: '); const b=document.createElement('b'); b.textContent=String(d.name); parts.push(b) } if(d.path){ if(parts.length) parts.push(' '); parts.push('路径: '); const b=document.createElement('b'); b.textContent=String(d.path); parts.push(b) } if(d.dir){ if(parts.length) parts.push(' '); parts.push('目录: '); const b=document.createElement('b'); b.textContent=String(d.dir); parts.push(b) } if(d.from){ if(parts.length) parts.push(' '); parts.push('源: '); const b=document.createElement('b'); b.textContent=String(d.from); parts.push(b) } if(d.to){ if(parts.length) parts.push(' '); parts.push('目标: '); const b=document.createElement('b'); b.textContent=String(d.to); parts.push(b) } if(d.fileId){ if(parts.length) parts.push(' '); parts.push('文件ID: ' + String(d.fileId)) } if(d.shareId){ if(parts.length) parts.push(' '); parts.push('分享ID: ' + String(d.shareId)) } if(d.toId){ if(parts.length) parts.push(' '); parts.push('新文件ID: ' + String(d.toId)) } if(parts.length===0){ td3.textContent=JSON.stringify(d) } else { parts.forEach(p=>{ if(typeof p==='string'){ const s=document.createElement('span'); s.textContent=p; td3.appendChild(s) } else { td3.appendChild(p) } }) } tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tbody.appendChild(tr) } const pi=qs('logsPageInfo'); if(pi){ const start=logsState.offset+1; const end=Math.min(logsState.offset+logsState.size, logsState.total); pi.textContent = `${start}-${end} / ${logsState.total}` } } function setupIdleLogout(){ let last=Date.now(); function u(){ last=Date.now() } ['mousemove','mousedown','keydown','scroll','touchstart','click','visibilitychange'].forEach(ev=>{ document.addEventListener(ev,u,{ passive:true }) }); setInterval(()=>{ if(state.token && Date.now()-last>=3600000){ try{ localStorage.removeItem('tk'); localStorage.removeItem('iv'); localStorage.removeItem('dev_salt'); localStorage.removeItem('mfp') }catch{}; state.token=null; state.master=null; state.masterFp=null; qs('dashboard').classList.add('hidden'); qs('login').classList.remove('hidden'); setStatus('已自动退出:超过1小时未操作') } },60000) } setupIdleLogout() document.addEventListener('visibilitychange', ()=>{ if(document.visibilityState==='visible'){ const inputs = Array.from(document.querySelectorAll('input,textarea')); inputs.forEach(el=>{ if(el.type==='checkbox' || el.type==='radio'){ el.checked=false } else { el.value='' } }); try{ logDebug('visibility.visible.clear_inputs', 'count', inputs.length) }catch{} } }) // 关闭辅助提示 // 捕获脚本错误,显示到状态栏,便于排查 window.addEventListener('error', (e)=>{ try { setStatus('脚本错误:'+ e.message) } catch{} }) window.addEventListener('unhandledrejection', (e)=>{ try { logDebug('promise.error', (e && e.reason && e.reason.message) || String(e && e.reason)) } catch{} }) let tooltipEl=null function showTipFor(el){ const text = el.dataset.tip || el.getAttribute('placeholder') || el.title || (el.tagName==='BUTTON'? el.textContent : '') ; if(!text) return; if(!tooltipEl){ tooltipEl=document.createElement('div'); tooltipEl.className='tooltip hidden'; document.body.appendChild(tooltipEl) } tooltipEl.textContent = text; const r = el.getBoundingClientRect(); const x = r.right + 8; const y = r.top + window.scrollY; tooltipEl.style.left = x + 'px'; tooltipEl.style.top = y + 'px'; tooltipEl.classList.remove('hidden') } function hideTip(){ if(tooltipEl) tooltipEl.classList.add('hidden') } function initTooltips(){ const sels = Array.from(document.querySelectorAll('input,select,textarea,button')); sels.forEach(el=>{ el.addEventListener('focus', ()=>showTipFor(el)); el.addEventListener('blur', hideTip); el.addEventListener('mouseenter', ()=>showTipFor(el)); el.addEventListener('mouseleave', hideTip) }) } // context menu let ctxEl=null function closeCtx(){ if(ctxEl){ ctxEl.remove(); ctxEl=null } } function openFileCtx(e, file){ e.preventDefault(); closeCtx(); const div = document.createElement('div'); div.className='ctx' const add = (label, fn)=>{ const b=document.createElement('button'); b.textContent=label; b.onclick=()=>{ closeCtx(); fn() }; div.appendChild(b) } add('移动到...', async()=>{ openDirPicker(async(dir)=>{ const ok = await askConfirm('确认移动到:' + dir + '?'); if(!ok){ setStatus('已取消'); return } const r = await fetch(`/api/psc/files/${file.id}/move`, { method:'POST', headers:{ 'Content-Type':'application/json', Authorization:'Bearer '+state.token }, body: JSON.stringify({ dir }) }); let j=null; try{ j=await r.json() }catch{}; try{ logDebug('file.move.json', JSON.stringify(j)) }catch{}; await refreshList() }) }) add('复制到...', async()=>{ openDirPicker(async(dir)=>{ let name = await askPrompt('新文件名', file.name) || file.name; name = String(name).trim(); const src = String(file.name); const hasDot = name.includes('.'); const ext = src.includes('.') ? src.split('.').pop() : ''; if(!hasDot && ext){ name = name + '.' + ext } const ok = await askConfirm('确认复制到:' + dir + ' 并命名为:' + name + '?'); if(!ok){ setStatus('已取消'); return } const r = await fetch(`/api/psc/files/${file.id}/copy`, { method:'POST', headers:{ 'Content-Type':'application/json', Authorization:'Bearer '+state.token }, body: JSON.stringify({ dir, name }) }); const j = await r.json(); try{ logDebug('file.copy.json', JSON.stringify(j)) }catch{}; if(j.ok) setStatus('已复制'); await refreshList() }) }) add('重命名', async()=>{ let name = await askPrompt('新文件名', file.name); if(!name) return; name = String(name).trim(); const src = String(file.name); const hasDot = name.includes('.'); const ext = src.includes('.') ? src.split('.').pop() : ''; if(!hasDot && ext){ name = name + '.' + ext } const ok = await askConfirm('确认将 ' + file.name + ' 重命名为:' + name + '?'); if(!ok){ setStatus('已取消'); return } const r = await fetch(`/api/psc/files/${file.id}/rename`, { method:'POST', headers:{ 'Content-Type':'application/json', Authorization:'Bearer '+state.token }, body: JSON.stringify({ name }) }); let j=null; try{ j=await r.json() }catch{}; try{ logDebug('file.rename.json', JSON.stringify(j)) }catch{}; await refreshList() }) add('删除', async()=>{ const ok = await askConfirm('确认删除:' + file.name + '?'); if(!ok){ setStatus('已取消'); return } logDebug('file.del.ctx.click', file.id); const r = await fetch(`/api/psc/files/${file.id}`, { method:'DELETE', headers:{ Authorization:'Bearer '+state.token } }); let j=null; try{ j=await r.json() }catch{}; logDebug('file.del.ctx.status', r.status); if(j) logDebug('file.del.ctx.json', JSON.stringify(j)); await refreshList() }) add('分享', ()=> { shareCtx.fileId = file.id; shareCtx.link = null; const modal = qs('shareCreateModal'); modal.dataset.fileId = String(file.id); qs('shareQrImg').style.display='none'; qs('shareLinkOutput').textContent=''; qs('shareCreatePwd').value=''; const chips = document.querySelectorAll('#shareCreateModal .chip'); chips.forEach(c=>{ c.classList.remove('active') }); document.querySelector('#shareCreateModal .chip[data-days="30"]').classList.add('active'); qs('shareMasterWrap').style.display = 'block'; modal.classList.remove('hidden'); updateShareButtons() }) div.style.left = e.clientX + 'px'; div.style.top = e.clientY + 'px' document.body.appendChild(div); ctxEl=div } document.addEventListener('click', closeCtx) function openDirPicker(cb){ const wrap = document.createElement('div'); wrap.className = 'picker' const box = document.createElement('div'); box.className = 'picker-content' const title = document.createElement('div'); title.style.display='flex'; title.style.justifyContent='space-between'; title.style.alignItems='center' const h = document.createElement('h3'); h.textContent='选择目标目录'; h.style.margin='0' const closeBtn = document.createElement('button'); closeBtn.textContent='关闭' title.appendChild(h); title.appendChild(closeBtn) const cur = document.createElement('div') let current = state.dir const updateCur = ()=>{ cur.textContent = '当前:' + current } updateCur() const ops = document.createElement('div') const chooseBtn = document.createElement('button'); chooseBtn.textContent='选择此目录' const upBtn = document.createElement('button'); upBtn.textContent='上一级' const list = document.createElement('div'); list.className='picker-list' async function render(){ list.innerHTML=''; const d = await dirApi.list(current); for(const item of d.dirs){ const b=document.createElement('button'); b.textContent=item.name; b.onclick=()=>{ current = item.path; updateCur(); render() }; list.appendChild(b) } } chooseBtn.onclick = ()=>{ cb(current); document.body.removeChild(wrap) } upBtn.onclick = ()=>{ const parts = current.split('/').filter(Boolean); parts.pop(); current = '/' + parts.join('/'); if(current==='') current='/'; updateCur(); render() } closeBtn.onclick = ()=>{ document.body.removeChild(wrap) } ops.appendChild(chooseBtn); ops.appendChild(upBtn) box.appendChild(title); box.appendChild(cur); box.appendChild(ops); box.appendChild(list) wrap.appendChild(box) document.body.appendChild(wrap) render() } function pickShareDays(){ const el = document.querySelector('#shareCreateModal .chip.active'); return Number(el?.dataset.days||'0') } function updateShareButtons(){ const pwd = qs('shareCreatePwd').value; const input = qs('shareMaster').value; const ok = !!pwd && !!input; const b1 = qs('btnCopyShareLink'); const b2 = qs('btnGenQr'); if(b1) b1.disabled = !ok; if(b2) b2.disabled = !ok } async function buildShareLink(){ try { const pwd = qs('shareCreatePwd').value; if(!pwd){ setStatus('请输入提取码'); qs('shareLinkOutput').textContent=''; return null } const fid = shareCtx.fileId || Number(qs('shareCreateModal').dataset.fileId||'0'); logDebug('share.fid', fid); if(!fid){ setStatus('请先选择要分享的文件'); qs('shareLinkOutput').textContent=''; return null } const init = await api.downloadInit(fid); logDebug('share.init.res', JSON.stringify(init)); if(init && init.error === 'unauthorized'){ setStatus('会话失效,请重新登录'); qs('login').classList.remove('hidden'); qs('dashboard').classList.add('hidden'); qs('shareLinkOutput').textContent=''; return null } if(init && init.error === 'not_found'){ setStatus('请选择已完成上传的文件'); qs('shareLinkOutput').textContent=''; return null } const saltB64 = (init && (init.fileSalt || init.salt)) || ''; const fileSalt = new Uint8Array([...atob(saltB64)].map(c => c.charCodeAt(0))); const input = qs('shareMaster').value; if(!input){ setStatus('请输入18位身份证号'); qs('shareLinkOutput').textContent=''; return null } let master = input; if(state.masterFp){ const fp = await calcMasterFp(input); const eq = (fp === state.masterFp); logDebug('share.fp.eq', eq); if(!eq){ setStatus('身份证号不准确'); qs('shareLinkOutput').textContent=''; return null } } const { key: fKey, raw } = await deriveFileKeyForShare(master, fileSalt); logDebug('share.raw.len', raw.length); const rawHash = new Uint8Array(await crypto.subtle.digest('SHA-256', raw)); const rawHashB64 = btoa(String.fromCharCode(...rawHash)); logDebug('share.fk.hash', rawHashB64); const shareSalt = new Uint8Array(16); crypto.getRandomValues(shareSalt); const sMaterial = await crypto.subtle.importKey('raw', new TextEncoder().encode(pwd), 'PBKDF2', false, ['deriveKey']); const sKey = await crypto.subtle.deriveKey({ name:'PBKDF2', hash:'SHA-256', salt: shareSalt, iterations:200000 }, sMaterial, { name:'AES-GCM', length:256 }, false, ['encrypt']); const iv = new Uint8Array(12); crypto.getRandomValues(iv); const ct = await crypto.subtle.encrypt({ name:'AES-GCM', iv }, sKey, raw); const wrappedKeyB64 = btoa(String.fromCharCode(...new Uint8Array(ct))); const shareSaltB64 = btoa(String.fromCharCode(...shareSalt)); const ivB64 = btoa(String.fromCharCode(...iv)); const days = pickShareDays(); logDebug('share.payload', JSON.stringify({ fileId: fid, days, wrappedKeyB64_len: wrappedKeyB64.length })); const resp = await fetch('/api/psc/share/create', { method:'POST', headers:{ 'Content-Type':'application/json', Authorization:'Bearer '+state.token }, body: JSON.stringify({ fileId: fid, wrappedKeyB64, shareSaltB64, ivB64, password: pwd, expiresInDays: days }) }); logDebug('share.create.status', resp.status); const j = await resp.json(); logDebug('share.create.json', JSON.stringify(j)); if(resp.ok && j.shareId){ let url = location.origin + '/s/' + j.shareId; if(qs('shareAutoFill').checked){ url += '?p=' + encodeURIComponent(pwd) } return url } else { const msg = j && j.error ? j.error : ('HTTP '+resp.status); setStatus('分享生成失败:' + msg); qs('shareLinkOutput').textContent=''; return null } } catch(e){ logDebug('share.error', e.message||String(e)); setStatus('分享生成失败'); qs('shareLinkOutput').textContent = ''; return null } } document.querySelectorAll('#shareCreateModal .chip').forEach(ch=>{ ch.onclick=()=>{ document.querySelectorAll('#shareCreateModal .chip').forEach(c=>c.classList.remove('active')); ch.classList.add('active'); updateShareButtons() } }) ;(()=>{ const sPwd = qs('shareCreatePwd'); const sId = qs('shareMaster'); if(sPwd) sPwd.addEventListener('input', updateShareButtons); if(sId) sId.addEventListener('input', updateShareButtons) })() qs('btnCopyShareLink').onclick = async ()=>{ updateShareButtons(); if(qs('btnCopyShareLink').disabled){ setStatus('请输入提取码和18位身份证号'); return } const url = shareCtx.link || await buildShareLink(); if(!url) return; shareCtx.link = url; qs('shareLinkOutput').textContent = url; try { await navigator.clipboard.writeText(url); setStatus('分享链接已复制') } catch { setStatus('请手动复制分享链接') } } qs('btnGenQr').onclick = async ()=>{ updateShareButtons(); if(qs('btnGenQr').disabled){ setStatus('请输入提取码和18位身份证号'); return } const url = shareCtx.link || await buildShareLink(); if(!url) return; shareCtx.link = url; const img = qs('shareQrImg'); img.src = await genQrDataUrl(url, 220); img.style.display='block' } async function genQrDataUrl(data, size){ try{ const r = await fetch('/share/qr?url=' + encodeURIComponent(data) + '&size=' + size); if(!r.ok) throw new Error('http_'+r.status); const blob = await r.blob(); return URL.createObjectURL(blob) } catch { if(window.QRCode && typeof window.QRCode.toDataURL==='function'){ try{ return await window.QRCode.toDataURL(data, { width: size, margin: 0 }) } catch{} } return 'data:image/png;base64,' } } qs('btnCloseCreateShare').onclick = ()=>{ qs('shareCreateModal').classList.add('hidden') } const btnOpenLogs = qs('btnOpenLogs'); if(btnOpenLogs){ btnOpenLogs.onclick = async ()=>{ const m=qs('logsModal'); m.classList.remove('hidden'); logsState.offset = 0; await fetchAndRenderLogs() } } const btnCloseLogs = qs('btnCloseLogs'); if(btnCloseLogs){ btnCloseLogs.onclick = ()=>{ qs('logsModal').classList.add('hidden') } } const btnFetchLogs = qs('btnFetchLogs'); if(btnFetchLogs){ btnFetchLogs.onclick = async ()=>{ logsState.offset = 0; logsState.size = Number(qs('logsPageSize').value||'200'); await fetchAndRenderLogs() } } const btnExportLogs = qs('btnExportLogs'); if(btnExportLogs){ btnExportLogs.onclick = ()=>{ const from = qs('logsFrom').value; const to = qs('logsTo').value; const category = qs('logsCategory').value; const url = logsApi.exportUrl({ from: from||'', to: to||'', limit: 100000, category }); window.open(url, '_blank') } } const btnLogsPrev = qs('btnLogsPrev'); if(btnLogsPrev){ btnLogsPrev.onclick = async ()=>{ logsState.size = Number(qs('logsPageSize').value||'200'); logsState.offset = Math.max(0, logsState.offset - logsState.size); await fetchAndRenderLogs() } } const btnLogsNext = qs('btnLogsNext'); if(btnLogsNext){ btnLogsNext.onclick = async ()=>{ logsState.size = Number(qs('logsPageSize').value||'200'); const next = logsState.offset + logsState.size; if(next < logsState.total){ logsState.offset = next; await fetchAndRenderLogs() } } }