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() } } }