a6f763ffd8
- 修复token泄露风险:改用X-Local-Creds-Token请求头传递token,从URL Hash读取set_token避免日志记录 - 重构全站认证逻辑:提取公用工具函数统一各服务的认证校验流程 - 升级投资账本工具:支持本地上传CSV和粘贴文本,移除硬编码路径,适配新参数格式 - 新增403/404/500标准错误响应文件,统一API错误返回格式 - 优化后端配置:关闭ETag,重构CORS中间件,新增全局错误捕获,限制敏感文件访问 - 优化web zen box的Service Worker,支持音频分片请求与断点续传 - 清理冗余前端代码,调整页面格式与权限控制
1132 lines
44 KiB
JavaScript
1132 lines
44 KiB
JavaScript
(function () {
|
|
'use strict'
|
|
|
|
const API = '/api/oss_file_cabinet'
|
|
const state = {
|
|
view: 'icon',
|
|
prefix: '',
|
|
history: [],
|
|
historyIndex: -1,
|
|
items: [],
|
|
selected: new Set(),
|
|
lastSelectedIdx: -1,
|
|
clipboard: null,
|
|
search: '',
|
|
mode: 'files',
|
|
shareToken: '',
|
|
toastTimer: null,
|
|
columnStack: [],
|
|
columnCache: {}
|
|
}
|
|
|
|
const $ = (sel) => document.querySelector(sel)
|
|
const $$ = (sel) => Array.from(document.querySelectorAll(sel))
|
|
|
|
const showToast = (msg, type) => {
|
|
let t = document.getElementById('globalToast')
|
|
if (!t) {
|
|
t = document.createElement('div')
|
|
t.id = 'globalToast'
|
|
t.className = 'toast'
|
|
document.body.appendChild(t)
|
|
}
|
|
t.textContent = msg
|
|
t.className = 'toast toast-' + (type || 'info')
|
|
t.style.display = 'flex'
|
|
clearTimeout(state.toastTimer)
|
|
state.toastTimer = setTimeout(() => { t.style.display = 'none' }, 2200)
|
|
}
|
|
|
|
const formatSize = (bytes) => {
|
|
const b = Number(bytes) || 0
|
|
if (b < 1024) return b + ' B'
|
|
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'
|
|
if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB'
|
|
return (b / 1024 / 1024 / 1024).toFixed(2) + ' GB'
|
|
}
|
|
|
|
const formatDate = (ts) => {
|
|
if (!ts) return ''
|
|
const d = new Date(ts)
|
|
const y = d.getFullYear()
|
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
const day = String(d.getDate()).padStart(2, '0')
|
|
const hh = String(d.getHours()).padStart(2, '0')
|
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
|
return y + '-' + m + '-' + day + ' ' + hh + ':' + mm
|
|
}
|
|
|
|
const getExt = (name) => {
|
|
const n = String(name || '')
|
|
const idx = n.lastIndexOf('.')
|
|
if (idx < 0 || idx === n.length - 1) return ''
|
|
return n.slice(idx + 1).toLowerCase()
|
|
}
|
|
|
|
const getFileIcon = (name, isFolder) => {
|
|
if (isFolder) return '📁'
|
|
const ext = getExt(name)
|
|
const iconMap = {
|
|
jpg: '🖼️', jpeg: '🖼️', png: '🖼️', gif: '🖼️', webp: '🖼️', svg: '🖼️', bmp: '🖼️', ico: '🖼️',
|
|
pdf: '📕',
|
|
doc: '📝', docx: '📝', odt: '📝',
|
|
xls: '📗', xlsx: '📗', csv: '📗', ods: '📗',
|
|
ppt: '📙', pptx: '📙', odp: '📙',
|
|
txt: '📄', md: '📄', log: '📄',
|
|
zip: '📦', rar: '📦', '7z': '📦', tar: '📦', gz: '📦',
|
|
mp3: '🎵', wav: '🎵', flac: '🎵', aac: '🎵', ogg: '🎵',
|
|
mp4: '🎬', mov: '🎬', avi: '🎬', mkv: '🎬', webm: '🎬', flv: '🎬', m4v: '🎬',
|
|
json: '📋', js: '📋', ts: '📋', jsx: '📋', tsx: '📋',
|
|
html: '🌐', css: '🌐', xml: '🌐',
|
|
py: '🐍', java: '☕', c: '⚙️', cpp: '⚙️', h: '⚙️', go: '🐹', rs: '⚙️', sh: '⚙️'
|
|
}
|
|
return iconMap[ext] || '📄'
|
|
}
|
|
|
|
const getFileIconClass = (name) => {
|
|
const ext = getExt(name)
|
|
if (['pdf'].includes(ext)) return 'file-red'
|
|
if (['doc', 'docx', 'odt'].includes(ext)) return 'file-blue'
|
|
if (['xls', 'xlsx', 'csv', 'ods'].includes(ext)) return 'file-green'
|
|
if (['ppt', 'pptx', 'odp'].includes(ext)) return 'file-orange'
|
|
if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'm4v'].includes(ext)) return 'file-purple'
|
|
if (['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(ext)) return 'file-pink'
|
|
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return 'file-yellow'
|
|
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(ext)) return 'file-image'
|
|
if (['txt', 'md', 'log'].includes(ext)) return 'file-text'
|
|
return 'file-gray'
|
|
}
|
|
|
|
const apiGet = (url) => fetch(url, { credentials: 'same-origin' }).then(r => r.json())
|
|
const apiPost = (url, body) => fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'same-origin',
|
|
body: body ? JSON.stringify(body) : '{}'
|
|
}).then(r => r.json())
|
|
|
|
const apiPostFormData = (url, formData) => fetch(url, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
body: formData
|
|
}).then(r => r.json())
|
|
|
|
const doLogin = async (user, pass) => {
|
|
const r = await apiPost(API + '/auth/login', { username: user, password: pass })
|
|
return r && r.ok
|
|
}
|
|
|
|
const checkAuth = async () => {
|
|
try {
|
|
const r = await apiGet(API + '/auth/me')
|
|
return r && r.ok && r.authorized
|
|
} catch { return false }
|
|
}
|
|
|
|
const tryAutoFill = async () => {
|
|
try {
|
|
const token = localStorage.getItem('toolbox_creds_token') || ''
|
|
if (!token) return
|
|
const r = await fetch(API + '/auth/local_creds', {
|
|
headers: { 'X-Local-Creds-Token': token },
|
|
credentials: 'same-origin',
|
|
cache: 'no-store'
|
|
}).then(resp => resp.json())
|
|
if (r && r.ok && r.creds) {
|
|
if (r.creds.username) $('#loginUsername').value = r.creds.username
|
|
if (r.creds.password) $('#loginPassword').value = r.creds.password
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
const clearUrlQuery = () => {
|
|
try {
|
|
if (!window.location.search) return
|
|
const clean = location.pathname + location.hash
|
|
history.replaceState(null, document.title, clean)
|
|
} catch {}
|
|
}
|
|
|
|
const main = async () => {
|
|
initTheme()
|
|
|
|
// ===== set_token 仅允许从 hash 读取,避免进入服务端日志 =====
|
|
try {
|
|
const currentUrl = new URL(window.location.href)
|
|
const hashRaw = String(currentUrl.hash || '').replace(/^#/, '')
|
|
const hashParams = new URLSearchParams(hashRaw.startsWith('?') ? hashRaw.slice(1) : hashRaw)
|
|
const setToken = hashParams.get('set_token')
|
|
if (setToken) {
|
|
localStorage.setItem('toolbox_creds_token', setToken)
|
|
hashParams.delete('set_token')
|
|
currentUrl.hash = hashParams.toString() ? `#${hashParams.toString()}` : ''
|
|
history.replaceState(null, document.title, currentUrl.toString())
|
|
}
|
|
} catch {}
|
|
|
|
clearUrlQuery()
|
|
|
|
// ===== 先检查是否已登录 =====
|
|
const already = await checkAuth()
|
|
if (already) {
|
|
showApp()
|
|
initEvents()
|
|
initDragDrop()
|
|
navigateTo('')
|
|
return
|
|
}
|
|
|
|
// ===== 未登录,仅自动填充,不自动提交 =====
|
|
showLogin()
|
|
await tryAutoFill()
|
|
const $loginBtn = $('#loginBtn')
|
|
if ($loginBtn && !$loginBtn.dataset.bound) {
|
|
$loginBtn.dataset.bound = '1'
|
|
$loginBtn.addEventListener('click', async () => {
|
|
const u = $('#loginUsername').value.trim()
|
|
const p = $('#loginPassword').value
|
|
$('#loginError').textContent = ''
|
|
if (!u || !p) { $('#loginError').textContent = '请输入用户名和密码'; return }
|
|
const ok = await doLogin(u, p)
|
|
if (ok) {
|
|
showApp()
|
|
initEvents()
|
|
initDragDrop()
|
|
navigateTo('')
|
|
} else $('#loginError').textContent = '用户名或密码错误'
|
|
})
|
|
}
|
|
$('#loginPassword').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#loginBtn').click() })
|
|
$('#loginUsername').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#loginBtn').click() })
|
|
}
|
|
|
|
const showLogin = () => {
|
|
$('#loginScreen').style.display = 'flex'
|
|
$('#app').style.display = 'none'
|
|
}
|
|
|
|
const showApp = () => {
|
|
$('#loginScreen').style.display = 'none'
|
|
$('#app').style.display = 'block'
|
|
}
|
|
|
|
const initTheme = () => {
|
|
const saved = localStorage.getItem('oss_file_cabinet_theme')
|
|
if (saved === 'dark' || (!saved && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
document.documentElement.classList.add('theme-dark')
|
|
}
|
|
}
|
|
|
|
const toggleTheme = () => {
|
|
const isDark = document.documentElement.classList.toggle('theme-dark')
|
|
localStorage.setItem('oss_file_cabinet_theme', isDark ? 'dark' : 'light')
|
|
}
|
|
|
|
const setBreadcrumb = () => {
|
|
const pb = $('#pathBar')
|
|
pb.innerHTML = ''
|
|
const parts = state.prefix ? state.prefix.split('/').filter(Boolean) : []
|
|
const homeItem = document.createElement('span')
|
|
homeItem.className = 'crumb active'
|
|
homeItem.textContent = ' '
|
|
pb.appendChild(homeItem)
|
|
let accPath = ''
|
|
parts.forEach((p, idx) => {
|
|
const sep = document.createElement('span')
|
|
sep.className = 'crumb-sep'
|
|
sep.textContent = ' | '
|
|
pb.appendChild(sep)
|
|
accPath = accPath ? accPath + '/' + p : p
|
|
const isLast = idx === parts.length - 1
|
|
const target = isLast && state.prefix && state.prefix.endsWith('/') ? accPath + '/' : accPath
|
|
const crumb = document.createElement('span')
|
|
crumb.className = 'crumb'
|
|
crumb.textContent = p
|
|
crumb.addEventListener('click', () => navigateTo(target))
|
|
pb.appendChild(crumb)
|
|
})
|
|
}
|
|
|
|
const setStatusBar = () => {
|
|
const total = state.items ? state.items.length : 0
|
|
const sel = state.selected.size
|
|
$('#statusInfo').textContent = total + ' 个项目'
|
|
$('#statusSelect').textContent = sel > 0 ? '已选择 ' + sel + ' 个' : ''
|
|
}
|
|
|
|
const buildColumnStack = (prefix) => {
|
|
const parts = String(prefix || '').split('/').filter(Boolean)
|
|
const stack = []
|
|
let currentPrefix = ''
|
|
for (let i = 0; i < parts.length; i++) {
|
|
stack.push({ prefix: currentPrefix, selectedKey: parts[i] })
|
|
currentPrefix = parts.slice(0, i + 1).join('/') + '/'
|
|
}
|
|
stack.push({ prefix: currentPrefix, selectedKey: null })
|
|
return stack
|
|
}
|
|
|
|
const navigateTo = (prefix, addHistory) => {
|
|
let clean = String(prefix || '').replace(/[\\]+/g, '/').replace(/\/+/g, '/')
|
|
if (clean.startsWith('/')) clean = clean.slice(1)
|
|
if (clean === '/') clean = ''
|
|
if (clean === state.prefix && addHistory === false) return
|
|
if (addHistory !== false) {
|
|
state.history = state.history.slice(0, state.historyIndex + 1)
|
|
state.history.push(clean)
|
|
state.historyIndex = state.history.length - 1
|
|
}
|
|
state.prefix = clean
|
|
state.selected.clear()
|
|
state.lastSelectedIdx = -1
|
|
state.columnStack = buildColumnStack(clean)
|
|
loadItems()
|
|
setBreadcrumb()
|
|
}
|
|
|
|
const goBack = () => {
|
|
if (state.historyIndex > 0) {
|
|
state.historyIndex--
|
|
state.prefix = state.history[state.historyIndex]
|
|
state.selected.clear()
|
|
loadItems()
|
|
setBreadcrumb()
|
|
}
|
|
}
|
|
|
|
const goForward = () => {
|
|
if (state.historyIndex < state.history.length - 1) {
|
|
state.historyIndex++
|
|
state.prefix = state.history[state.historyIndex]
|
|
state.selected.clear()
|
|
loadItems()
|
|
setBreadcrumb()
|
|
}
|
|
}
|
|
|
|
const goUp = () => {
|
|
if (!state.prefix) return
|
|
const parts = state.prefix.split('/').filter(Boolean)
|
|
parts.pop()
|
|
navigateTo(parts.length ? parts.join('/') + '/' : '')
|
|
}
|
|
|
|
const loadItems = async () => {
|
|
if (state.mode === 'trash') { await loadTrash(); return }
|
|
if (state.mode === 'images') { await loadImages(); return }
|
|
const r = await apiGet(API + '/files/list?prefix=' + encodeURIComponent(state.prefix) + (state.search ? '&search=' + encodeURIComponent(state.search) : ''))
|
|
console.log('[oss_file_cabinet] list response', r)
|
|
if (!r || !r.ok) { state.items = []; render(); return }
|
|
state.items = r.items || []
|
|
state.items.sort((a, b) => {
|
|
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1
|
|
return (a.name || '').localeCompare(b.name || '')
|
|
})
|
|
render()
|
|
}
|
|
|
|
const loadTrash = async () => {
|
|
const r = await apiGet(API + '/trash/list')
|
|
if (!r || !r.ok) { state.items = []; render(); return }
|
|
state.items = r.items || []
|
|
render()
|
|
}
|
|
|
|
const loadImages = async () => {
|
|
const r = await apiGet(API + '/files/list?prefix=')
|
|
if (!r || !r.ok) { state.items = []; render(); return }
|
|
const collect = async (prefix) => {
|
|
const res = await apiGet(API + '/files/list?prefix=' + encodeURIComponent(prefix))
|
|
const out = []
|
|
if (res && res.ok) {
|
|
for (const it of res.items) {
|
|
if (it.isFolder) {
|
|
const nested = await collect(it.key)
|
|
out.push(...nested)
|
|
} else if (it.isImage) out.push(it)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
state.items = await collect('')
|
|
render()
|
|
}
|
|
|
|
const render = () => {
|
|
const area = $('#fileArea')
|
|
area.className = 'file-area ' + state.view + '-view'
|
|
area.innerHTML = ''
|
|
setStatusBar()
|
|
if (!state.items || state.items.length === 0) {
|
|
const empty = document.createElement('div')
|
|
empty.className = 'empty-state'
|
|
empty.innerHTML = '<div class="empty-icon">🗑</div>' +
|
|
'<div class="empty-title">空文件夹</div>' +
|
|
'<div class="empty-hint">'+(state.mode === 'trash' ? '废纸篓为空' : '拖拽文件到此处或点击「上传」')+'</div>'
|
|
area.appendChild(empty)
|
|
return
|
|
}
|
|
|
|
if (state.view === 'icon') renderIconView(area)
|
|
else if (state.view === 'list') renderListView(area)
|
|
else renderColumnView(area).catch(() => {})
|
|
}
|
|
|
|
const renderIconView = (area) => {
|
|
state.items.forEach((it, idx) => {
|
|
const div = document.createElement('div')
|
|
div.className = 'icon-item' + (state.selected.has(it.key) ? ' selected' : '')
|
|
div.dataset.key = it.key
|
|
div.dataset.idx = String(idx)
|
|
|
|
const iconWrap = document.createElement('div')
|
|
iconWrap.className = 'icon-wrap ' + getFileIconClass(it.name)
|
|
if (it.isImage && state.mode !== 'trash') {
|
|
const img = document.createElement('img')
|
|
img.src = API + '/files/preview?key=' + encodeURIComponent(it.key)
|
|
img.className = 'icon-thumb'
|
|
img.addEventListener('error', () => { iconWrap.textContent = getFileIcon(it.name, it.isFolder) })
|
|
iconWrap.appendChild(img)
|
|
} else {
|
|
iconWrap.textContent = getFileIcon(it.name, it.isFolder)
|
|
}
|
|
div.appendChild(iconWrap)
|
|
|
|
const nameEl = document.createElement('div')
|
|
nameEl.className = 'icon-name'
|
|
nameEl.textContent = it.name || '未命名'
|
|
div.appendChild(nameEl)
|
|
|
|
bindItemEvents(div, it, idx)
|
|
area.appendChild(div)
|
|
})
|
|
}
|
|
|
|
const renderListView = (area) => {
|
|
const table = document.createElement('div')
|
|
table.className = 'list-table'
|
|
const header = document.createElement('div')
|
|
header.className = 'list-row list-header'
|
|
header.innerHTML = '<div class="col-name">名称</div><div class="col-size">大小</div><div class="col-modified">修改日期</div><div class="col-kind">类型</div>'
|
|
table.appendChild(header)
|
|
state.items.forEach((it, idx) => {
|
|
const row = document.createElement('div')
|
|
row.className = 'list-row' + (state.selected.has(it.key) ? ' selected' : '')
|
|
row.dataset.key = it.key
|
|
row.dataset.idx = String(idx)
|
|
const nameCell = document.createElement('div')
|
|
nameCell.className = 'col-name'
|
|
nameCell.innerHTML = '<span class="row-icon ' + getFileIconClass(it.name) + '">' + getFileIcon(it.name, it.isFolder) + '</span><span>' + (it.name || '未命名') + '</span>'
|
|
row.appendChild(nameCell)
|
|
const szCell = document.createElement('div')
|
|
szCell.className = 'col-size'
|
|
szCell.textContent = it.isFolder ? '-' : formatSize(it.size)
|
|
row.appendChild(szCell)
|
|
const modCell = document.createElement('div')
|
|
modCell.className = 'col-modified'
|
|
modCell.textContent = it.deleted_at ? '删除于 ' + formatDate(it.deleted_at) : formatDate(it.lastModified)
|
|
row.appendChild(modCell)
|
|
const kindCell = document.createElement('div')
|
|
kindCell.className = 'col-kind'
|
|
kindCell.textContent = it.isFolder ? '文件夹' : (it.ext ? it.ext.toUpperCase() : '文件')
|
|
row.appendChild(kindCell)
|
|
bindItemEvents(row, it, idx)
|
|
table.appendChild(row)
|
|
})
|
|
area.appendChild(table)
|
|
}
|
|
|
|
const loadColumnItems = async (prefix) => {
|
|
const cacheKey = prefix || '__root__'
|
|
if (state.columnCache[cacheKey]) return state.columnCache[cacheKey]
|
|
try {
|
|
const r = await apiGet(API + '/files/list?prefix=' + encodeURIComponent(prefix))
|
|
const items = (r && r.ok ? r.items : []) || []
|
|
items.sort((a, b) => {
|
|
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1
|
|
return (a.name || '').localeCompare(b.name || '')
|
|
})
|
|
state.columnCache[cacheKey] = items
|
|
return items
|
|
} catch { return [] }
|
|
}
|
|
|
|
const renderColumnView = async (area) => {
|
|
area.innerHTML = ''
|
|
if (!state.columnStack || state.columnStack.length === 0) {
|
|
state.columnStack = buildColumnStack(state.prefix)
|
|
}
|
|
|
|
let lastSelectedItem = null
|
|
for (let i = 0; i < state.columnStack.length; i++) {
|
|
const colDef = state.columnStack[i]
|
|
const colEl = document.createElement('div')
|
|
colEl.className = 'column'
|
|
const items = await loadColumnItems(colDef.prefix)
|
|
items.forEach((it) => {
|
|
const row = document.createElement('div')
|
|
row.className = 'column-row' + (colDef.selectedKey === it.key ? ' selected' : '')
|
|
row.innerHTML = '<span class="row-icon ' + getFileIconClass(it.name) + '">' + getFileIcon(it.name, it.isFolder) + '</span>' +
|
|
'<span class="col-name-text">' + (it.name || '未命名') + '</span>'
|
|
row.addEventListener('click', (ev) => {
|
|
ev.stopPropagation()
|
|
onColumnItemClick(i, it)
|
|
})
|
|
row.addEventListener('dblclick', (ev) => {
|
|
ev.stopPropagation()
|
|
if (it.isFolder) navigateTo(it.key)
|
|
else if (isPreviewable(it.name)) openFilePreview(it.key, it.name)
|
|
else downloadFile(it.key, it.name)
|
|
})
|
|
colEl.appendChild(row)
|
|
if (colDef.selectedKey === it.key) lastSelectedItem = it
|
|
})
|
|
area.appendChild(colEl)
|
|
}
|
|
|
|
const lastCol = state.columnStack[state.columnStack.length - 1]
|
|
if (lastSelectedItem && !lastSelectedItem.isFolder) {
|
|
const infoCol = document.createElement('div')
|
|
infoCol.className = 'column column-hint'
|
|
infoCol.innerHTML = '<div class="column-info-panel">' +
|
|
'<div class="column-info-name">' + (lastSelectedItem.name || '') + '</div>' +
|
|
'<div class="column-info-row">类型:' + (lastSelectedItem.ext ? lastSelectedItem.ext.toUpperCase() + ' 文件' : '文件') + '</div>' +
|
|
'<div class="column-info-row">大小:' + formatSize(lastSelectedItem.size) + '</div>' +
|
|
'<div class="column-info-row">修改日期:' + formatDate(lastSelectedItem.lastModified) + '</div>' +
|
|
'</div>'
|
|
area.appendChild(infoCol)
|
|
} else if (!lastCol.selectedKey) {
|
|
const hintCol = document.createElement('div')
|
|
hintCol.className = 'column column-hint'
|
|
hintCol.innerHTML = '<div class="column-empty">选择一项以查看详情</div>'
|
|
area.appendChild(hintCol)
|
|
}
|
|
}
|
|
|
|
const onColumnItemClick = async (colIndex, item) => {
|
|
// Truncate stack after this column
|
|
state.columnStack = state.columnStack.slice(0, colIndex + 1)
|
|
const colDef = state.columnStack[colIndex]
|
|
colDef.selectedKey = item.name
|
|
if (item.isFolder) {
|
|
const nextPrefix = colDef.prefix + item.name + '/'
|
|
state.columnStack.push({ prefix: nextPrefix, selectedKey: null })
|
|
// Pre-load next column into cache for smoother feel
|
|
await loadColumnItems(nextPrefix)
|
|
}
|
|
if (state.view === 'column') renderColumnView($('#fileArea'))
|
|
}
|
|
|
|
const updateSelectionUI = () => {
|
|
const elems = $$('#fileArea [data-key]')
|
|
elems.forEach(el => {
|
|
const k = el.dataset.key
|
|
if (state.selected.has(k)) el.classList.add('selected')
|
|
else el.classList.remove('selected')
|
|
})
|
|
}
|
|
|
|
const bindItemEvents = (el, item, idx) => {
|
|
el.addEventListener('click', (ev) => {
|
|
ev.stopPropagation()
|
|
const multi = ev.metaKey || ev.ctrlKey
|
|
const range = ev.shiftKey
|
|
if (range && state.lastSelectedIdx >= 0) {
|
|
const lo = Math.min(idx, state.lastSelectedIdx)
|
|
const hi = Math.max(idx, state.lastSelectedIdx)
|
|
for (let i = lo; i <= hi; i++) {
|
|
const k = state.items[i] && state.items[i].key
|
|
if (k) state.selected.add(k)
|
|
}
|
|
} else if (multi) {
|
|
if (state.selected.has(item.key)) state.selected.delete(item.key)
|
|
else state.selected.add(item.key)
|
|
} else {
|
|
state.selected.clear()
|
|
state.selected.add(item.key)
|
|
}
|
|
state.lastSelectedIdx = idx
|
|
updateSelectionUI()
|
|
})
|
|
|
|
el.addEventListener('dblclick', (ev) => {
|
|
ev.stopPropagation()
|
|
if (item.isFolder) {
|
|
if (state.mode === 'files') navigateTo(item.key)
|
|
} else if (isPreviewable(item.name) && state.mode !== 'trash') {
|
|
openFilePreview(item.key, item.name)
|
|
} else if (state.mode !== 'trash') {
|
|
downloadFile(item.key, item.name)
|
|
}
|
|
})
|
|
|
|
el.addEventListener('contextmenu', (ev) => {
|
|
ev.preventDefault()
|
|
ev.stopPropagation()
|
|
openContextMenu(ev.clientX, ev.clientY, item)
|
|
})
|
|
}
|
|
|
|
const openContextMenu = (x, y, firstItem) => {
|
|
hideAllContextMenus()
|
|
const menuId = state.mode === 'trash' ? 'trashContextMenu' : (firstItem ? 'contextMenu' : 'folderContextMenu')
|
|
const menu = document.getElementById(menuId)
|
|
if (!menu) return
|
|
|
|
// 文件夹显示"打开",文件隐藏"打开"
|
|
const openItem = menu.querySelector('.cm-item[data-action="open"]')
|
|
if (openItem) {
|
|
openItem.style.display = (firstItem && firstItem.isFolder) ? 'block' : 'none'
|
|
}
|
|
|
|
menu.style.left = x + 'px'
|
|
menu.style.top = y + 'px'
|
|
menu.style.display = 'block'
|
|
const rect = menu.getBoundingClientRect()
|
|
if (rect.right > window.innerWidth - 8) menu.style.left = Math.max(8, window.innerWidth - rect.width - 8) + 'px'
|
|
if (rect.bottom > window.innerHeight - 8) menu.style.top = Math.max(8, window.innerHeight - rect.height - 8) + 'px'
|
|
menu._firstItem = firstItem
|
|
}
|
|
|
|
const hideAllContextMenus = () => {
|
|
['contextMenu', 'folderContextMenu', 'trashContextMenu'].forEach(id => {
|
|
const el = document.getElementById(id)
|
|
if (el) el.style.display = 'none'
|
|
})
|
|
}
|
|
|
|
let previewScale = 1
|
|
const isVideo = name => ['mp4', 'mov', 'avi', 'webm', 'mkv', 'flv', 'm4v'].includes(getExt(name))
|
|
const isPdf = name => getExt(name) === 'pdf'
|
|
const isText = name => ['txt', 'md', 'json', 'js', 'ts', 'html', 'css', 'xml', 'log', 'csv', 'yml', 'yaml', 'sh', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs'].includes(getExt(name))
|
|
const isImage = name => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(getExt(name))
|
|
const isPreviewable = name => isImage(name) || isVideo(name) || isPdf(name) || isText(name)
|
|
const escapeHtml = s => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
|
|
const openFilePreview = async (key, name) => {
|
|
previewScale = 1
|
|
const modal = $('#filePreview')
|
|
const container = $('#previewContainer')
|
|
container.innerHTML = ''
|
|
modal.style.display = 'flex'
|
|
const url = API + '/files/preview?key=' + encodeURIComponent(key)
|
|
if (isVideo(name)) {
|
|
container.innerHTML = '<video controls autoplay style="max-width:100%;max-height:80vh;"><source src="' + url + '" type="video/' + getExt(name) + '"></video>'
|
|
} else if (isPdf(name)) {
|
|
container.innerHTML = '<embed src="' + url + '" type="application/pdf" style="width:90vw;height:85vh;border:none;">'
|
|
} else if (isText(name)) {
|
|
container.innerHTML = '<div id="previewLoading" style="color:var(--text-secondary);padding:40px;">正在加载…</div>'
|
|
try {
|
|
const r = await fetch(url, { credentials: 'include' })
|
|
if (!r.ok) throw new Error('加载失败')
|
|
const text = await r.text()
|
|
container.innerHTML = '<pre style="background:#1e1e1e;color:#d4d4d4;padding:16px;border-radius:6px;max-width:90vw;max-height:80vh;overflow:auto;font-family:monospace;font-size:13px;white-space:pre-wrap;word-break:break-all;">' + escapeHtml(text) + '</pre>'
|
|
} catch (e) {
|
|
container.innerHTML = '<div style="color:var(--danger);padding:40px;">预览加载失败</div>'
|
|
}
|
|
} else {
|
|
container.innerHTML = '<img id="previewImg" src="' + url + '" alt="' + (name || '预览') + '" style="max-width:100%;max-height:80vh;transition:transform .15s ease;">'
|
|
}
|
|
}
|
|
|
|
const closeFilePreview = () => {
|
|
$('#filePreview').style.display = 'none'
|
|
previewScale = 1
|
|
$('#previewContainer').innerHTML = ''
|
|
}
|
|
|
|
const downloadFile = (key, name) => {
|
|
const url = API + '/files/download?key=' + encodeURIComponent(key)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = name || '文件'
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
}
|
|
|
|
const showModal = (title, bodyHtml, onOk, okText) => {
|
|
$('#modalTitle').textContent = title
|
|
$('#modalBody').innerHTML = bodyHtml || ''
|
|
$('#modalOk').textContent = okText || '确定'
|
|
$('#modalDialog').style.display = 'flex'
|
|
$('#modalCancel').onclick = () => { $('#modalDialog').style.display = 'none' }
|
|
$('#modalOk').onclick = () => {
|
|
if (onOk) {
|
|
const result = onOk()
|
|
if (result === false) return
|
|
}
|
|
$('#modalDialog').style.display = 'none'
|
|
}
|
|
const firstInput = $('#modalBody').querySelector('input, select, textarea')
|
|
if (firstInput) setTimeout(() => firstInput.focus(), 50)
|
|
}
|
|
|
|
const handleMenuAction = async (action, firstItem) => {
|
|
if (action === 'open') {
|
|
if (!firstItem) return
|
|
if (firstItem.isFolder) navigateTo(firstItem.key)
|
|
else if (isPreviewable(firstItem.name)) openFilePreview(firstItem.key, firstItem.name)
|
|
else downloadFile(firstItem.key, firstItem.name)
|
|
return
|
|
}
|
|
if (action === 'preview') {
|
|
if (!firstItem || !isPreviewable(firstItem.name)) return
|
|
openFilePreview(firstItem.key, firstItem.name).catch(() => {})
|
|
return
|
|
}
|
|
if (action === 'rename') {
|
|
if (state.selected.size !== 1) return
|
|
const key = Array.from(state.selected)[0]
|
|
const it = state.items.find(x => x.key === key)
|
|
if (!it) return
|
|
showModal('重命名', '<label>名称</label><input id="renameInput" value="' + it.name.replace(/"/g, '"') + '" style="width:100%">', async () => {
|
|
const newName = $('#renameInput').value.trim()
|
|
if (!newName) { showToast('名称不能为空', 'error'); return false }
|
|
const r = await apiPost(API + '/files/rename', { key: key, name: newName })
|
|
if (r && r.ok) { showToast('重命名成功', 'success'); loadItems() }
|
|
else showToast(r && r.error ? r.error : '重命名失败', 'error')
|
|
})
|
|
return
|
|
}
|
|
if (action === 'info') {
|
|
const keys = Array.from(state.selected)
|
|
if (keys.length === 0) return
|
|
const key = keys[0]
|
|
const r = await apiGet(API + '/files/info?key=' + encodeURIComponent(key))
|
|
if (!r || !r.ok) { showToast('获取信息失败', 'error'); return }
|
|
const info = r.info || {}
|
|
const lines = [
|
|
['名称', info.name],
|
|
['类型', info.isFolder ? '文件夹' : info.ext ? info.ext.toUpperCase() + ' 文件' : '文件'],
|
|
['大小', info.isFolder ? '-' : formatSize(info.size)],
|
|
['修改日期', info.lastModified ? formatDate(info.lastModified) : '-'],
|
|
['路径', '<code style="font-size:11px;word-break:break-all;">' + (info.fullPath || '') + '</code>']
|
|
]
|
|
const html = lines.map(l => '<div class="info-row"><span class="info-label">' + l[0] + '</span><span class="info-value">' + l[1] + '</span></div>').join('')
|
|
showModal('简介', html, null, '关闭')
|
|
return
|
|
}
|
|
if (action === 'copy') {
|
|
if (state.selected.size === 0) return
|
|
state.clipboard = { op: 'copy', keys: Array.from(state.selected) }
|
|
showToast('已复制 ' + state.clipboard.keys.length + ' 个项目到剪贴板', 'success')
|
|
return
|
|
}
|
|
if (action === 'move') {
|
|
if (state.selected.size === 0) return
|
|
state.clipboard = { op: 'move', keys: Array.from(state.selected) }
|
|
showToast('已选择 ' + state.clipboard.keys.length + ' 个项目,点击目标文件夹粘贴', 'info')
|
|
return
|
|
}
|
|
if (action === 'paste') {
|
|
if (!state.clipboard || state.clipboard.keys.length === 0) return
|
|
const op = state.clipboard.op
|
|
let okCount = 0
|
|
for (const k of state.clipboard.keys) {
|
|
const r = op === 'copy'
|
|
? await apiPost(API + '/files/copy', { source: k, dest: state.prefix })
|
|
: await apiPost(API + '/files/move', { source: k, dest: state.prefix })
|
|
if (r && r.ok) okCount++
|
|
}
|
|
if (okCount === state.clipboard.keys.length) {
|
|
showToast(op === 'copy' ? '复制成功' : '移动成功', 'success')
|
|
if (op === 'move') state.clipboard = null
|
|
} else showToast('部分操作失败', 'error')
|
|
loadItems()
|
|
return
|
|
}
|
|
if (action === 'download') {
|
|
const keys = Array.from(state.selected)
|
|
if (keys.length === 0) return
|
|
// 多选或单个文件夹:打包 zip 下载
|
|
if (keys.length > 1 || (keys.length === 1 && state.items.find(x => x.key === keys[0])?.isFolder)) {
|
|
const a = document.createElement('a')
|
|
a.href = API + '/files/batch_download'
|
|
a.download = '归档.zip'
|
|
a.style.display = 'none'
|
|
document.body.appendChild(a)
|
|
// 使用 POST 触发下载
|
|
fetch(API + '/files/batch_download', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ keys, mode: 'download' })
|
|
}).then(r => {
|
|
if (!r.ok) throw new Error('下载失败')
|
|
return r.blob()
|
|
}).then(blob => {
|
|
const url = URL.createObjectURL(blob)
|
|
const a2 = document.createElement('a')
|
|
a2.href = url
|
|
a2.download = '归档.zip'
|
|
document.body.appendChild(a2)
|
|
a2.click()
|
|
document.body.removeChild(a2)
|
|
URL.revokeObjectURL(url)
|
|
}).catch(() => showToast('下载失败', 'error'))
|
|
return
|
|
}
|
|
// 单选文件:直接下载
|
|
const k = keys[0]
|
|
const it = state.items.find(x => x.key === k)
|
|
const name = it ? it.name : '文件'
|
|
downloadFile(k, name)
|
|
return
|
|
}
|
|
if (action === 'share') {
|
|
if (state.selected.size !== 1) { showToast('请选择一个项目分享', 'info'); return }
|
|
const key = Array.from(state.selected)[0]
|
|
showModal('分享临时链接', '<label>有效期</label><select id="shareTtl" style="width:100%">' +
|
|
'<option value="1">1 小时</option><option value="24" selected>1 天</option><option value="168">7 天</option>' +
|
|
'</select><div class="share-output" id="shareOutput" style="margin-top:12px;"></div>',
|
|
() => {
|
|
const out = $('#shareOutput')
|
|
if (out.dataset.done === '1') return false
|
|
const ttl = Number($('#shareTtl').value) || 24
|
|
out.dataset.done = '1'
|
|
out.innerHTML = '<div class="share-title">正在生成链接…</div>'
|
|
apiPost(API + '/share/create', { key: key, ttl_hours: ttl }).then(async (r) => {
|
|
if (!r || !r.ok) { showToast('分享失败', 'error'); return }
|
|
const shareUrl = location.origin + API + '/share/access/' + r.token
|
|
let copied = false
|
|
try { await navigator.clipboard.writeText(shareUrl); copied = true } catch {}
|
|
out.innerHTML = '<div class="share-title">临时链接(' + ttl + ' 小时后过期)</div>' +
|
|
'<div class="share-url-box" id="shareUrlBox">' + shareUrl + '</div>' +
|
|
(copied ? '<div style="color:var(--success);margin-top:6px;font-size:12px;">已复制到剪贴板</div>' : '')
|
|
})
|
|
return false
|
|
}, '生成并复制')
|
|
return
|
|
}
|
|
if (action === 'raw_url') {
|
|
if (state.selected.size !== 1) { showToast('请选择一个文件', 'info'); return }
|
|
const key = Array.from(state.selected)[0]
|
|
const r = await apiGet(API + '/files/raw_url?key=' + encodeURIComponent(key))
|
|
if (!r || !r.ok || !r.url) { showToast('获取原始链接失败', 'error'); return }
|
|
try {
|
|
await navigator.clipboard.writeText(r.url)
|
|
showToast('原始 OSS 链接已复制', 'success')
|
|
} catch { showToast('复制失败', 'error') }
|
|
return
|
|
}
|
|
if (action === 'delete') {
|
|
const keys = Array.from(state.selected)
|
|
if (keys.length === 0) return
|
|
showModal('确认删除', '<div>将 ' + keys.length + ' 个项目移到废纸篓?</div>', async () => {
|
|
let okCount = 0
|
|
for (const k of keys) {
|
|
const r = await apiPost(API + '/files/delete', { key: k })
|
|
if (r && r.ok) okCount++
|
|
}
|
|
if (okCount === keys.length) showToast('已移到废纸篓', 'success')
|
|
else showToast('部分删除失败', 'error')
|
|
loadItems()
|
|
}, '删除')
|
|
return
|
|
}
|
|
if (action === 'new_folder') {
|
|
showModal('新建文件夹', '<label>名称</label><input id="newFolderName" value="新建文件夹" style="width:100%">', async () => {
|
|
const name = $('#newFolderName').value.trim()
|
|
if (!name) { showToast('名称不能为空', 'error'); return false }
|
|
const r = await apiPost(API + '/files/mkdir', { parent: state.prefix, name: name })
|
|
if (r && r.ok) { showToast('创建成功', 'success'); loadItems() }
|
|
else showToast(r && r.error ? r.error : '创建失败', 'error')
|
|
})
|
|
return
|
|
}
|
|
if (action === 'upload') {
|
|
triggerFileUpload()
|
|
return
|
|
}
|
|
if (action === 'compress') {
|
|
const keys = Array.from(state.selected)
|
|
if (keys.length === 0) return
|
|
showToast('正在压缩...', 'info')
|
|
const r = await apiPost(API + '/files/batch_download', { keys, mode: 'compress', prefix: state.prefix })
|
|
if (r && r.ok) { showToast('已生成 ' + (r.name || '归档.zip'), 'success'); loadItems() }
|
|
else showToast(r && r.error ? r.error : '压缩失败', 'error')
|
|
return
|
|
}
|
|
if (action === 'refresh') { loadItems(); return }
|
|
if (action === 'restore') {
|
|
const keys = Array.from(state.selected)
|
|
if (keys.length === 0) return
|
|
let okCount = 0
|
|
for (const k of keys) {
|
|
const it = state.items.find(x => x.key === k)
|
|
const id = it && it.id
|
|
if (!id) continue
|
|
const r = await apiPost(API + '/trash/restore', { id: id })
|
|
if (r && r.ok) okCount++
|
|
}
|
|
if (okCount === keys.length) showToast('已还原', 'success')
|
|
else showToast('部分还原失败', 'error')
|
|
loadItems()
|
|
return
|
|
}
|
|
if (action === 'empty_perm') {
|
|
const keys = Array.from(state.selected)
|
|
showModal('彻底删除', '<div>此操作不可撤销。确认从 OSS 彻底删除 ' + keys.length + ' 个项目?</div>', async () => {
|
|
const ids = state.items.filter(it => keys.indexOf(it.key) >= 0).map(it => it.id).filter(Boolean)
|
|
const r = await apiPost(API + '/trash/empty', { ids: ids })
|
|
if (r && r.ok) showToast('已彻底删除 ' + r.deleted_count + ' 个项目', 'success')
|
|
else showToast(r && r.error ? r.error : '删除失败', 'error')
|
|
loadItems()
|
|
}, '彻底删除')
|
|
return
|
|
}
|
|
}
|
|
|
|
const triggerFileUpload = () => {
|
|
const input = $('#fileInput')
|
|
input.value = ''
|
|
input.click()
|
|
}
|
|
|
|
const doUpload = async (files) => {
|
|
const fd = new FormData()
|
|
fd.append('prefix', state.prefix)
|
|
for (const f of files) fd.append('files', f)
|
|
const r = await apiPostFormData(API + '/files/upload', fd)
|
|
if (r && r.ok) { showToast('已上传 ' + (r.results ? r.results.length : 0) + ' 个文件', 'success'); loadItems() }
|
|
else showToast(r && r.error ? r.error : '上传失败', 'error')
|
|
}
|
|
|
|
const uploadFiles = async (files) => {
|
|
if (!files || files.length === 0) return
|
|
const oversized = files.some(f => f.size > 50 * 1024 * 1024)
|
|
if (oversized) { showToast('文件超过 50MB 限制', 'error'); return }
|
|
|
|
await doUpload(files)
|
|
}
|
|
|
|
const initDragDrop = () => {
|
|
const overlay = $('#dropOverlay')
|
|
let dragOver = false
|
|
window.addEventListener('dragenter', (e) => {
|
|
if (state.mode !== 'files') return
|
|
if (e.dataTransfer && Array.from(e.dataTransfer.types || []).indexOf('Files') >= 0) {
|
|
dragOver = true
|
|
overlay.style.display = 'flex'
|
|
}
|
|
})
|
|
window.addEventListener('dragover', (e) => {
|
|
if (dragOver) { e.preventDefault(); e.stopPropagation() }
|
|
})
|
|
window.addEventListener('dragleave', (e) => {
|
|
if (!e.relatedTarget || e.relatedTarget === document.documentElement) {
|
|
dragOver = false
|
|
overlay.style.display = 'none'
|
|
}
|
|
})
|
|
window.addEventListener('drop', (e) => {
|
|
if (dragOver) {
|
|
e.preventDefault()
|
|
overlay.style.display = 'none'
|
|
dragOver = false
|
|
const files = Array.from(e.dataTransfer && e.dataTransfer.files || [])
|
|
uploadFiles(files)
|
|
}
|
|
})
|
|
}
|
|
|
|
const initEvents = () => {
|
|
$('#btnThemeToggle').addEventListener('click', toggleTheme)
|
|
$('#btnLogout').addEventListener('click', async () => {
|
|
try { await apiPost(API + '/auth/logout', {}) } catch {}
|
|
showLogin()
|
|
})
|
|
|
|
$('#btnBack').addEventListener('click', goBack)
|
|
$('#btnForward').addEventListener('click', goForward)
|
|
|
|
$$('.view-btn').forEach(btn => btn.addEventListener('click', () => {
|
|
const view = btn.dataset.view
|
|
if (!view || state.view === view) return
|
|
state.view = view
|
|
$$('.view-btn').forEach(b => b.classList.remove('active'))
|
|
btn.classList.add('active')
|
|
render()
|
|
}))
|
|
|
|
$('#searchInput').addEventListener('input', (e) => {
|
|
state.search = e.target.value.trim()
|
|
loadItems()
|
|
})
|
|
|
|
$$('.sidebar-item').forEach(item => item.addEventListener('click', () => {
|
|
$$('.sidebar-item').forEach(i => i.classList.remove('active'))
|
|
item.classList.add('active')
|
|
const target = item.dataset.target
|
|
if (target === 'home') { state.mode = 'files'; navigateTo('') }
|
|
else if (target === 'trash') { state.mode = 'trash'; state.prefix = ''; loadItems() }
|
|
else if (target === 'images') { state.mode = 'images'; state.prefix = ''; loadItems() }
|
|
}))
|
|
|
|
$('#pathBar').addEventListener('click', (ev) => {
|
|
const crumb = ev.target.closest('.crumb')
|
|
if (!crumb) return
|
|
})
|
|
|
|
$('#fileArea').addEventListener('click', (ev) => {
|
|
if (ev.target === $('#fileArea') || ev.target.classList.contains('empty-state') || ev.target.closest('.empty-state')) {
|
|
state.selected.clear()
|
|
state.lastSelectedIdx = -1
|
|
updateSelectionUI()
|
|
}
|
|
})
|
|
|
|
$('#fileArea').addEventListener('dblclick', (ev) => {
|
|
if (ev.target === $('#fileArea') || ev.target.classList.contains('empty-state') || ev.target.closest('.empty-state')) {
|
|
if (state.prefix) goUp()
|
|
}
|
|
})
|
|
|
|
$('#fileArea').addEventListener('contextmenu', (ev) => {
|
|
if (ev.target === $('#fileArea') || ev.target.classList.contains('empty-state') || ev.target.closest('.empty-state')) {
|
|
ev.preventDefault()
|
|
state.selected.clear()
|
|
updateSelectionUI()
|
|
if (state.mode === 'files') openContextMenu(ev.clientX, ev.clientY, null)
|
|
}
|
|
})
|
|
|
|
let dragStart = null
|
|
let dragRect = null
|
|
$('#fileArea').addEventListener('mousedown', (ev) => {
|
|
if (ev.button !== 0) return
|
|
const target = ev.target
|
|
if (target.closest('.icon-item') || target.closest('.list-row') || target.closest('.column-row')) return
|
|
const area = $('#fileArea')
|
|
const rect = area.getBoundingClientRect()
|
|
dragStart = { x: ev.clientX, y: ev.clientY, rect: rect }
|
|
dragRect = document.createElement('div')
|
|
dragRect.className = 'selection-rect'
|
|
dragRect.style.left = ev.clientX + 'px'
|
|
dragRect.style.top = ev.clientY + 'px'
|
|
dragRect.style.width = '0px'
|
|
dragRect.style.height = '0px'
|
|
document.body.appendChild(dragRect)
|
|
ev.preventDefault()
|
|
})
|
|
|
|
window.addEventListener('mousemove', (ev) => {
|
|
if (!dragStart || !dragRect) return
|
|
const x1 = dragStart.x
|
|
const y1 = dragStart.y
|
|
const x2 = ev.clientX
|
|
const y2 = ev.clientY
|
|
const left = Math.min(x1, x2)
|
|
const top = Math.min(y1, y2)
|
|
const width = Math.abs(x2 - x1)
|
|
const height = Math.abs(y2 - y1)
|
|
dragRect.style.left = left + 'px'
|
|
dragRect.style.top = top + 'px'
|
|
dragRect.style.width = width + 'px'
|
|
dragRect.style.height = height + 'px'
|
|
const sel = { left, top, right: left + width, bottom: top + height }
|
|
const els = $$('#fileArea .icon-item, #fileArea .list-row, #fileArea .column-row')
|
|
for (const el of els) {
|
|
const r = el.getBoundingClientRect()
|
|
const overlap = !(r.right < sel.left || r.left > sel.right || r.bottom < sel.top || r.top > sel.bottom)
|
|
const k = el.dataset.key
|
|
if (!k) continue
|
|
if (overlap) state.selected.add(k)
|
|
else state.selected.delete(k)
|
|
}
|
|
updateSelectionUI()
|
|
})
|
|
|
|
window.addEventListener('mouseup', () => {
|
|
if (dragRect) { try { document.body.removeChild(dragRect) } catch {}; dragRect = null }
|
|
dragStart = null
|
|
})
|
|
|
|
document.addEventListener('click', (ev) => {
|
|
if (!ev.target.closest('.context-menu') && !ev.target.closest('.icon-item') && !ev.target.closest('.list-row') && !ev.target.closest('.column-row')) {
|
|
hideAllContextMenus()
|
|
}
|
|
})
|
|
|
|
$$('.context-menu .cm-item').forEach(item => {
|
|
item.addEventListener('click', (ev) => {
|
|
ev.stopPropagation()
|
|
const parentMenu = item.closest('.context-menu')
|
|
const first = parentMenu && parentMenu._firstItem
|
|
const action = item.dataset.action
|
|
hideAllContextMenus()
|
|
if (action) handleMenuAction(action, first)
|
|
})
|
|
})
|
|
|
|
$('#fileInput').addEventListener('change', (e) => {
|
|
const files = Array.from(e.target.files || [])
|
|
uploadFiles(files)
|
|
})
|
|
|
|
$('#closePreview').addEventListener('click', closeFilePreview)
|
|
$('#filePreview').addEventListener('click', (e) => { if (e.target.id === 'filePreview' || e.target.classList.contains('modal-backdrop')) closeFilePreview() })
|
|
$('#filePreview').addEventListener('wheel', (e) => {
|
|
const img = $('#previewImg')
|
|
if (!img) return
|
|
e.preventDefault()
|
|
const delta = e.deltaY > 0 ? -0.15 : 0.15
|
|
previewScale = Math.max(0.3, Math.min(5, previewScale + delta))
|
|
img.style.transform = 'scale(' + previewScale + ')'
|
|
}, { passive: false })
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if ($('#filePreview').style.display === 'flex') {
|
|
if (e.key === 'Escape') { e.preventDefault(); closeFilePreview() }
|
|
return
|
|
}
|
|
if ($('#modalDialog').style.display === 'flex') {
|
|
if (e.key === 'Escape') { e.preventDefault(); $('#modalDialog').style.display = 'none' }
|
|
if (e.key === 'Enter') { e.preventDefault(); $('#modalOk').click() }
|
|
return
|
|
}
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') {
|
|
if (state.items.length > 0 && document.activeElement && document.activeElement.tagName !== 'INPUT') {
|
|
e.preventDefault()
|
|
state.selected.clear()
|
|
state.items.forEach(it => state.selected.add(it.key))
|
|
render()
|
|
}
|
|
}
|
|
if (e.key === 'Backspace' && document.activeElement && document.activeElement.tagName !== 'INPUT') {
|
|
if (state.mode === 'files') { e.preventDefault(); goUp() }
|
|
}
|
|
if (e.key === 'Delete' && document.activeElement && document.activeElement.tagName !== 'INPUT') {
|
|
if (state.selected.size > 0) { e.preventDefault(); handleMenuAction(state.mode === 'trash' ? 'empty_perm' : 'delete') }
|
|
}
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'c' && state.selected.size > 0) {
|
|
e.preventDefault()
|
|
state.clipboard = { op: 'copy', keys: Array.from(state.selected) }
|
|
showToast('已复制 ' + state.clipboard.keys.length + ' 个项目到剪贴板', 'success')
|
|
}
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'v' && state.clipboard && state.mode === 'files') {
|
|
e.preventDefault()
|
|
handleMenuAction('paste')
|
|
}
|
|
})
|
|
|
|
// 系统剪贴板粘贴:Ctrl+V 时如果剪贴板有文件,直接上传
|
|
document.addEventListener('paste', (e) => {
|
|
if (state.mode !== 'files') return
|
|
const items = e.clipboardData && e.clipboardData.items
|
|
if (!items) return
|
|
const files = []
|
|
for (let i = 0; i < items.length; i++) {
|
|
if (items[i].kind === 'file') files.push(items[i].getAsFile())
|
|
}
|
|
if (files.length > 0) {
|
|
e.preventDefault()
|
|
uploadFiles(files)
|
|
}
|
|
})
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', main)
|
|
})()
|