Files

1152 lines
73 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = '<span class="loading"></span> ' + 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 = `<span style="color: var(--primary-color);">📁</span> ${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 = `<span style="color: var(--primary-color);">${fileIcon}</span> ${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<partCount;i++){
const li=document.createElement('div');
li.style.display='flex';
li.style.alignItems='center';
li.style.gap='12px';
li.style.padding='6px 0';
const t=document.createElement('span');
t.textContent = `分卷 ${i+1}/${partCount}`;
const b=document.createElement('button');
b.textContent='下载';
b.style.width='auto';
b.onclick=async()=>{
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() } } }