1152 lines
73 KiB
JavaScript
1152 lines
73 KiB
JavaScript
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() } } }
|