Files
yangxiangyuan d3856cb52a chore: add and update gitignore rules for project files
add a comprehensive gitignore file to exclude unnecessary files like dependencies, runtime data, uploads, local configs and debug temporary files
2026-06-22 21:24:46 +08:00

1122 lines
43 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 apiGet(API + '/auth/local_creds?token=' + encodeURIComponent(token))
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 处理:仅存入 localStorage,不自动登录 =====
try {
const params = new URLSearchParams(location.search)
const setToken = params.get('set_token')
if (setToken) {
localStorage.setItem('toolbox_creds_token', setToken)
}
} 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">&#128465;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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, '&quot;') + '" 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)
})()