(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 = '
🗑
' +
'空文件夹
' +
''+(state.mode === 'trash' ? '废纸篓为空' : '拖拽文件到此处或点击「上传」')+'
'
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 = '名称
大小
修改日期
类型
'
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 = '' + getFileIcon(it.name, it.isFolder) + '' + (it.name || '未命名') + ''
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 = '' + getFileIcon(it.name, it.isFolder) + '' +
'' + (it.name || '未命名') + ''
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 = '' +
'
' + (lastSelectedItem.name || '') + '
' +
'
类型:' + (lastSelectedItem.ext ? lastSelectedItem.ext.toUpperCase() + ' 文件' : '文件') + '
' +
'
大小:' + formatSize(lastSelectedItem.size) + '
' +
'
修改日期:' + formatDate(lastSelectedItem.lastModified) + '
' +
'
'
area.appendChild(infoCol)
} else if (!lastCol.selectedKey) {
const hintCol = document.createElement('div')
hintCol.className = 'column column-hint'
hintCol.innerHTML = '选择一项以查看详情
'
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, '>')
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 = ''
} else if (isPdf(name)) {
container.innerHTML = '