(() => {
'use strict';
const THEME_STORAGE_KEY = 'memind-theme';
const THEME_MIGRATION_KEY = 'memind-theme-migration';
const THEME_MIGRATION_VERSION = 'light-default-2026-09-12';
const FORCE_DARK_MIGRATION_VERSION = 'dark-default-2026-09-11';
const DEFAULT_THEME = 'light';
function migrateThemePreferenceIfNeeded() {
if (localStorage.getItem(THEME_MIGRATION_KEY) === THEME_MIGRATION_VERSION) return;
const previousMigration = localStorage.getItem(THEME_MIGRATION_KEY);
const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (previousMigration === FORCE_DARK_MIGRATION_VERSION && stored !== 'light') {
localStorage.setItem(THEME_STORAGE_KEY, 'light');
}
localStorage.setItem(THEME_MIGRATION_KEY, THEME_MIGRATION_VERSION);
}
function getStoredTheme() {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (stored === 'dark' || stored === 'light') return stored;
return DEFAULT_THEME;
}
function applyTheme(theme) {
const normalized = theme === 'dark' ? 'dark' : 'light';
document.documentElement.dataset.theme = normalized;
document.documentElement.style.colorScheme = normalized;
}
function updateThemeToggleLabel() {
const themeToggle = document.getElementById('themeToggle');
if (!themeToggle) return;
themeToggle.textContent = getStoredTheme() === 'light' ? '深色' : '浅色';
themeToggle.title = getStoredTheme() === 'light' ? '切换深色模式' : '切换浅色模式';
}
function toggleTheme() {
const next = getStoredTheme() === 'light' ? 'dark' : 'light';
localStorage.setItem(THEME_STORAGE_KEY, next);
applyTheme(next);
updateThemeToggleLabel();
window.dispatchEvent(new CustomEvent('memind-theme-change', { detail: { theme: next } }));
return next;
}
function initTheme() {
migrateThemePreferenceIfNeeded();
applyTheme(getStoredTheme());
updateThemeToggleLabel();
window.addEventListener('storage', (event) => {
if (event.key === THEME_STORAGE_KEY) {
applyTheme(getStoredTheme());
updateThemeToggleLabel();
}
});
window.addEventListener('memind-theme-change', (event) => {
const detail = event.detail;
if (detail?.theme) {
applyTheme(detail.theme);
updateThemeToggleLabel();
}
});
const themeToggle = document.getElementById('themeToggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => toggleTheme());
}
}
initTheme();
const API_BASE = '/api/mindspace/v1/oa/drive';
const fetchOpts = { credentials: 'same-origin' };
/* ── DOM refs ── */
const $ = (id) => document.getElementById(id);
const dropZone = $('dropZone');
const fileInput = $('fileInput');
const folderInput = $('folderInput');
const statusEl = $('status');
const progressEl = $('progress');
const progressBar = progressEl.querySelector('i');
const progressDetail = $('progressDetail');
const progressSummary = $('progressSummary');
const progressSpeed = $('progressSpeed');
const progressList = $('progressList');
const fileTreeEl = $('fileTree');
const fileCountEl = $('fileCount');
const linksEl = $('links');
const syncBadgeEl = $('syncBadge');
const syncTextEl = $('syncText');
const previewModal = $('previewModal');
const previewTitle = $('previewTitle');
const previewBody = $('previewBody');
const previewDownload = $('previewDownload');
const previewOpen = $('previewOpen');
const selectAllCheck = $('selectAllCheck');
const bulkDeleteBtn = $('bulkDeleteBtn');
const downloadZipBtn = $('downloadZipBtn');
const breadcrumbEl = $('breadcrumb');
const fileListEl = $('fileList');
const viewListBtn = $('viewListBtn');
const viewIconsBtn = $('viewIconsBtn');
const panelShell = $('filePanelShell');
const filePanel = $('filePanel');
const panelResizer = $('panelResizer');
const searchInput = $('searchInput');
const conflictSelect = $('conflictSelect');
const notifyBtn = $('notifyBtn');
const dragOverlay = $('dragOverlay');
const dragOverlayHint = $('dragOverlayHint');
const detailModal = $('detailModal');
const detailGrid = $('detailGrid');
const detailTitle = $('detailTitle');
const conflictModal = $('conflictModal');
const conflictList = $('conflictList');
const trashDrawer = $('trashDrawer');
const trashBackdrop = $('trashBackdrop');
const trashBody = $('trashBody');
/* ── Storage keys ── */
const VIEW_KEY = 'file-drop-sync-view-mode';
const PANEL_SIZE_KEY = 'file-drop-sync-panel-size';
const SORT_KEY = 'file-drop-sync-sort-key';
const SORT_ASC_KEY = 'file-drop-sync-sort-asc';
const PATH_KEY = 'file-drop-sync-current-path';
const ON_CONFLICT_KEY = 'file-drop-sync-on-conflict';
const CLIENT_ID_KEY = 'fds-client-id';
const CLIENT_LABEL_KEY = 'fds-client-label';
const NOTIFY_KEY = 'fds-notify-enabled';
const DEFAULT_PANEL_HEIGHT = 520;
const MIN_PANEL_WIDTH = 520;
const MIN_PANEL_HEIGHT = 280;
const CHUNK_THRESHOLD = 8 * 1024 * 1024;
/* ── State ── */
let serverInfo = { pageSize: 100, chunkSize: 4 * 1024 * 1024 };
let lastSnapshot = '';
let currentFiles = [];
let currentDirs = [];
let currentPath = localStorage.getItem(PATH_KEY) || '';
let sortKey = localStorage.getItem(SORT_KEY) || 'name';
let sortAsc = localStorage.getItem(SORT_ASC_KEY) !== 'false';
let onConflict = localStorage.getItem(ON_CONFLICT_KEY) || 'rename';
let viewMode = localStorage.getItem(VIEW_KEY) === 'icons' ? 'icons' : 'list';
let searchQuery = '';
let displayLimit = 100;
let selected = new Set();
let visibleKeys = [];
let lastSelectedIndex = -1;
let renameKey = null;
let sseConnected = false;
let lastSyncTime = null;
let dragDepth = 0;
let dragTargetFolder = '';
let internalClipboard = null;
let pendingConflictResolve = null;
let uploadAbort = false;
let filesHydrated = false;
let currentUser = null;
let panelSize = (() => {
try {
const saved = JSON.parse(localStorage.getItem(PANEL_SIZE_KEY) || 'null');
if (saved && Number(saved.height) >= MIN_PANEL_HEIGHT) {
return { width: saved.width ? Number(saved.width) : null, height: Number(saved.height) };
}
} catch {}
return { width: null, height: DEFAULT_PANEL_HEIGHT };
})();
/* ── Client identity ── */
function generateUuid() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
if (globalThis.crypto?.getRandomValues) {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
return `fds-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function getClientId() {
let id = localStorage.getItem(CLIENT_ID_KEY);
if (!id) {
id = generateUuid();
localStorage.setItem(CLIENT_ID_KEY, id);
}
return id;
}
function getClientLabel() {
let label = localStorage.getItem(CLIENT_LABEL_KEY);
if (!label) {
label = `浏览器 · ${navigator.platform || 'Web'}`;
localStorage.setItem(CLIENT_LABEL_KEY, label);
}
return label;
}
function clientHeaders(extra = {}) {
return {
'X-Fds-Client-Id': getClientId(),
'X-Fds-Client-Label': encodeURIComponent(getClientLabel()),
...extra,
};
}
/* ── Utilities ── */
function fmtSize(n) {
if (n == null || n === '') return '—';
if (n < 1024) return n + ' B';
if (n < 1024 ** 2) return (n / 1024).toFixed(1) + ' KB';
if (n < 1024 ** 3) return (n / 1024 ** 2).toFixed(1) + ' MB';
return (n / 1024 ** 3).toFixed(2) + ' GB';
}
function fmtTime(ms) {
if (!ms) return '—';
const d = new Date(ms);
return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function fmtSpeed(bytesPerSec) {
if (!bytesPerSec || !Number.isFinite(bytesPerSec)) return '—';
return fmtSize(bytesPerSec) + '/s';
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
})[c]);
}
function setStatus(text, type = '') {
statusEl.textContent = text;
statusEl.className = 'status ' + type;
}
function redirectToLogin() {
const returnTo = encodeURIComponent('/space/oa/drive');
const target = window.top && window.top !== window ? window.top : window;
target.location.href = `/?return_to=${returnTo}`;
}
async function ensureAuthenticated() {
const res = await fetch('/auth/status', fetchOpts);
if (!res.ok) {
redirectToLogin();
return null;
}
const data = await res.json();
if (!data.authenticated || !data.user?.id) {
redirectToLogin();
return null;
}
return data.user;
}
function renderUserBanner(user, quota) {
if (!linksEl || !user) return;
const name = user.displayName || user.username || user.slug || '用户';
const chips = [`${escapeHtml(name)} 的 OA 网盘`];
if (quota) {
chips.push(`剩余 ${fmtSize(quota.availableBytes)} / ${fmtSize(quota.quotaBytes)}`);
}
linksEl.innerHTML = chips.join('');
}
async function driveFetch(url, opts = {}) {
const res = await fetch(url, { ...fetchOpts, ...opts });
if (res.status === 401 || res.status === 403) {
redirectToLogin();
throw new Error('未登录');
}
return res;
}
function viewUrl(name) {
return API_BASE + '/view/' + encodeURIComponent(name).replace(/%2F/g, '/');
}
function downloadUrl(name) {
return API_BASE + '/download/' + encodeURIComponent(name).replace(/%2F/g, '/');
}
function extOf(name) {
const i = name.lastIndexOf('.');
return i >= 0 ? name.slice(i + 1).toLowerCase() : '';
}
function fileIconClass(name) {
const ext = extOf(name);
if (['xlsx', 'xls', 'csv'].includes(ext)) return 'sheet';
if (['doc', 'docx'].includes(ext)) return 'word';
if (ext === 'pdf') return 'pdf';
if (['mp4', 'webm', 'mov'].includes(ext)) return 'video';
return 'doc';
}
function isImageFile(name) {
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(extOf(name));
}
function rowKey(row) {
return row.type === 'folder' ? row.fullPath : row.fullName;
}
function parentPath(key) {
const i = key.lastIndexOf('/');
return i >= 0 ? key.slice(0, i) : '';
}
function persistPath() {
localStorage.setItem(PATH_KEY, currentPath);
}
function persistSort() {
localStorage.setItem(SORT_KEY, sortKey);
localStorage.setItem(SORT_ASC_KEY, String(sortAsc));
}
function updateSyncBadge(connected) {
sseConnected = connected;
syncBadgeEl.classList.toggle('offline', !connected);
if (!connected) {
syncTextEl.textContent = '离线 · 重连中…';
return;
}
if (lastSyncTime) {
const t = lastSyncTime.toLocaleTimeString('zh-CN', { hour12: false });
syncTextEl.textContent = `已连接 · ${t}`;
} else {
syncTextEl.textContent = '已连接';
}
}
/* ── Panel resize ── */
function maxPanelHeight() {
return Math.max(MIN_PANEL_HEIGHT, Math.floor(window.innerHeight * 0.85));
}
function maxPanelWidth() {
const wrap = panelShell.parentElement;
return Math.max(MIN_PANEL_WIDTH, (wrap?.clientWidth || window.innerWidth - 32));
}
function applyPanelSize() {
filePanel.style.height = `${Math.min(panelSize.height, maxPanelHeight())}px`;
if (panelSize.width) {
panelShell.style.width = `${Math.min(panelSize.width, maxPanelWidth())}px`;
} else {
panelShell.style.width = '';
}
}
function savePanelSize() {
localStorage.setItem(PANEL_SIZE_KEY, JSON.stringify(panelSize));
}
function initPanelResize() {
applyPanelSize();
panelResizer.addEventListener('mousedown', (e) => {
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const rect = panelShell.getBoundingClientRect();
const startW = rect.width;
const startH = filePanel.getBoundingClientRect().height;
document.body.classList.add('panel-shell-resizing');
const onMove = (ev) => {
panelSize.width = Math.min(maxPanelWidth(), Math.max(MIN_PANEL_WIDTH, Math.round(startW + ev.clientX - startX)));
panelSize.height = Math.min(maxPanelHeight(), Math.max(MIN_PANEL_HEIGHT, Math.round(startH + ev.clientY - startY)));
applyPanelSize();
};
const onUp = () => {
document.body.classList.remove('panel-shell-resizing');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
savePanelSize();
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
window.addEventListener('resize', () => {
if (panelSize.width && panelSize.width > maxPanelWidth()) {
panelSize.width = maxPanelWidth();
applyPanelSize();
savePanelSize();
}
if (panelSize.height > maxPanelHeight()) {
panelSize.height = maxPanelHeight();
applyPanelSize();
savePanelSize();
}
});
}
/* ── Tree building ── */
function folderSize(node) {
let total = 0;
for (const f of node.files) total += f.size;
for (const child of node.dirs.values()) total += folderSize(child);
return total;
}
function folderMtime(node) {
let max = 0;
for (const f of node.files) max = Math.max(max, f.mtime);
for (const child of node.dirs.values()) max = Math.max(max, folderMtime(child));
return max;
}
function buildTree(files, dirs = []) {
const root = { name: '', dirs: new Map(), files: [] };
for (const dirPath of dirs) {
const parts = dirPath.split('/');
let node = root;
for (const part of parts) {
if (!node.dirs.has(part)) node.dirs.set(part, { name: part, dirs: new Map(), files: [] });
node = node.dirs.get(part);
}
}
for (const f of files) {
const parts = f.name.split('/');
let node = root;
for (let i = 0; i < parts.length - 1; i++) {
if (!node.dirs.has(parts[i])) node.dirs.set(parts[i], { name: parts[i], dirs: new Map(), files: [] });
node = node.dirs.get(parts[i]);
}
node.files.push({ ...f, baseName: parts[parts.length - 1] });
}
return root;
}
function getNodeAtPath(tree, path) {
if (!path) return tree;
let node = tree;
for (const part of path.split('/')) {
if (!node.dirs.has(part)) return null;
node = node.dirs.get(part);
}
return node;
}
function compareRows(a, b) {
let va = a.sortVal[sortKey];
let vb = b.sortVal[sortKey];
if (sortKey === 'name') {
va = String(va).toLowerCase();
vb = String(vb).toLowerCase();
}
if (va < vb) return sortAsc ? -1 : 1;
if (va > vb) return sortAsc ? 1 : -1;
return 0;
}
function originBadgeHtml(origin) {
if (!origin?.clientLabel) return '';
const self = origin.clientId === getClientId();
return `${escapeHtml(origin.clientLabel)}`;
}
/* ── Selection ── */
function updateSelectionUi() {
const count = selected.size;
bulkDeleteBtn.disabled = count === 0;
bulkDeleteBtn.textContent = count ? `删除所选 (${count})` : '删除所选';
downloadZipBtn.disabled = count === 0;
const allSelected = visibleKeys.length > 0 && visibleKeys.every((k) => selected.has(k));
const someSelected = visibleKeys.some((k) => selected.has(k));
selectAllCheck.checked = allSelected;
selectAllCheck.indeterminate = !allSelected && someSelected;
fileTreeEl.querySelectorAll('.list-row, .icon-item').forEach((el) => {
const key = el.dataset.key;
el.classList.toggle('selected', key ? selected.has(key) : false);
const cb = el.querySelector('.row-check, .item-check');
if (cb) cb.checked = key ? selected.has(key) : false;
});
}
function syncCheckboxSelection(checkbox, shiftKey = false) {
const rowEl = checkbox.closest('[data-key]');
if (!rowEl) return;
const key = rowEl.dataset.key;
const index = visibleKeys.indexOf(key);
if (index < 0) return;
if (shiftKey && lastSelectedIndex >= 0) {
const start = Math.min(lastSelectedIndex, index);
const end = Math.max(lastSelectedIndex, index);
setSelected(visibleKeys.slice(start, end + 1), 'add');
} else if (checkbox.checked) {
setSelected([key], 'add');
} else {
selected.delete(key);
updateSelectionUi();
}
lastSelectedIndex = index;
}
function setSelected(keys, mode = 'replace') {
if (mode === 'replace') selected = new Set(keys);
else if (mode === 'toggle') {
for (const key of keys) {
if (selected.has(key)) selected.delete(key);
else selected.add(key);
}
} else if (mode === 'add') {
for (const key of keys) selected.add(key);
}
updateSelectionUi();
}
function selectAllVisible() {
setSelected(visibleKeys, 'replace');
lastSelectedIndex = visibleKeys.length - 1;
}
function clearSelection() {
selected.clear();
lastSelectedIndex = -1;
updateSelectionUi();
}
function handleRowSelect(e, key, index) {
if (e.metaKey || e.ctrlKey) {
setSelected([key], 'toggle');
lastSelectedIndex = index;
return;
}
if (e.shiftKey && lastSelectedIndex >= 0) {
const start = Math.min(lastSelectedIndex, index);
const end = Math.max(lastSelectedIndex, index);
setSelected(visibleKeys.slice(start, end + 1), 'add');
return;
}
setSelected([key], 'replace');
lastSelectedIndex = index;
}
/* ── View mode ── */
function setViewMode(mode) {
viewMode = mode === 'icons' ? 'icons' : 'list';
localStorage.setItem(VIEW_KEY, viewMode);
fileListEl.classList.toggle('icon-mode', viewMode === 'icons');
viewListBtn.classList.toggle('active', viewMode === 'list');
viewIconsBtn.classList.toggle('active', viewMode === 'icons');
lastSnapshot = '';
renderFiles(currentFiles);
}
function updateSortHeader() {
document.querySelectorAll('.list-header .sortable').forEach((el) => {
const key = el.dataset.sort;
el.classList.toggle('active', key === sortKey);
let mark = el.querySelector('.sort-mark');
if (key === sortKey) {
if (!mark) {
mark = document.createElement('span');
mark.className = 'sort-mark';
el.appendChild(document.createTextNode(' '));
el.appendChild(mark);
}
mark.textContent = sortAsc ? '▲' : '▼';
} else if (mark) {
mark.remove();
}
});
}
/* ── Breadcrumb ── */
function updateBreadcrumb() {
if (!currentPath) {
breadcrumbEl.innerHTML = '全部文件';
return;
}
const parts = currentPath.split('/');
let html = '';
let acc = '';
for (const part of parts) {
acc = acc ? `${acc}/${part}` : part;
html += '/';
if (acc === currentPath) {
html += `${escapeHtml(part)}`;
} else {
html += ``;
}
}
breadcrumbEl.innerHTML = html;
}
function enterFolder(path) {
currentPath = path;
persistPath();
displayLimit = serverInfo.pageSize || 100;
clearSelection();
lastSnapshot = '';
renderFiles(currentFiles);
}
/* ── Render ── */
function renderFolderContents(node, pathPrefix = '') {
const siblings = [];
for (const [dirName, child] of node.dirs) {
const fullPath = pathPrefix ? `${pathPrefix}/${dirName}` : dirName;
siblings.push({
type: 'folder',
name: dirName,
fullPath,
size: folderSize(child),
mtime: folderMtime(child),
sortVal: { name: dirName, size: folderSize(child), mtime: folderMtime(child) },
});
}
for (const f of node.files) {
siblings.push({
type: 'file',
name: f.baseName,
fullName: f.name,
size: f.size,
mtime: f.mtime,
previewable: f.previewable,
origin: f.origin,
sortVal: { name: f.baseName, size: f.size, mtime: f.mtime },
});
}
siblings.sort(compareRows);
if (searchQuery) {
const q = searchQuery.toLowerCase();
return siblings.filter((r) => r.name.toLowerCase().includes(q));
}
return siblings;
}
function listIconHtml(row) {
if (row.type === 'folder') return '';
return ``;
}
function iconThumbHtml(row) {
if (row.type === 'folder') return '';
if (isImageFile(row.fullName)) {
return `
`;
}
return ``;
}
function rowHtml(row) {
const key = rowKey(row);
const isRenaming = renameKey === key;
const originHtml = row.type === 'file' ? originBadgeHtml(row.origin) : '';
if (row.type === 'folder') {
const nameCell = isRenaming
? ``
: `${escapeHtml(row.name)}`;
return `
▸
${listIconHtml(row)}
${nameCell}
${fmtSize(row.size)}
${fmtTime(row.mtime)}
`;
}
const cls = row.previewable ? 'previewable' : '';
const nameCell = isRenaming
? ``
: `${escapeHtml(row.name)}`;
return `
▸
${listIconHtml(row)}
${nameCell}
${originHtml}
${fmtSize(row.size)}
${fmtTime(row.mtime)}
`;
}
function iconHtml(row) {
const key = rowKey(row);
const isRenaming = renameKey === key;
const originHtml = row.type === 'file' && row.origin?.clientLabel
? `${escapeHtml(row.origin.clientLabel)}
` : '';
if (row.type === 'folder') {
const nameInner = isRenaming
? ``
: escapeHtml(row.name);
return `
${iconThumbHtml(row)}
${nameInner}
${fmtSize(row.size)}
`;
}
const cls = row.previewable ? 'previewable' : '';
const nameInner = isRenaming
? ``
: row.previewable
? `${escapeHtml(row.name)}`
: escapeHtml(row.name);
return `
${iconThumbHtml(row)}
${nameInner}
${fmtSize(row.size)}
${originHtml}
`;
}
function renderFiles(files, meta = {}) {
if (Array.isArray(files)) currentFiles = files;
if (Array.isArray(meta.dirs)) currentDirs = meta.dirs;
const snapshot = JSON.stringify(currentFiles.length) + '|' + currentPath + '|' + sortKey + '|' + sortAsc + '|' + viewMode + '|' + searchQuery + '|' + displayLimit + '|' + renameKey;
if (snapshot === lastSnapshot && !meta.force) return;
lastSnapshot = snapshot;
const tree = buildTree(currentFiles, currentDirs);
let node = getNodeAtPath(tree, currentPath);
if (!node) {
currentPath = '';
persistPath();
node = tree;
}
const allRows = renderFolderContents(node, currentPath);
const totalCount = allRows.length;
const rows = allRows.slice(0, displayLimit);
const hasMore = totalCount > displayLimit;
fileCountEl.textContent = searchQuery
? `${rows.length}/${totalCount} 项`
: `${totalCount} 项`;
updateBreadcrumb();
if (meta.added?.length) {
setStatus(`已同步 ${meta.added.length} 个新文件`, 'ok');
notifyExternalChanges(meta.added, meta.files || currentFiles);
}
if (!allRows.length) {
const backBtn = currentPath
? ``
: '';
const emptyHtml = currentPath
? `
📥
此文件夹为空
拖入文件到此处上传,或返回上级查看其它文件
${backBtn}
`
: `还没有文件,先拖一个进来吧${backBtn}
`;
fileTreeEl.innerHTML = emptyHtml;
visibleKeys = [];
clearSelection();
updateSortHeader();
return;
}
visibleKeys = rows.map(rowKey);
for (const key of [...selected]) {
if (!visibleKeys.includes(key)) selected.delete(key);
}
let html = rows.map(viewMode === 'icons' ? iconHtml : rowHtml).join('');
if (hasMore) {
html += ``;
}
fileTreeEl.innerHTML = html;
updateSortHeader();
updateSelectionUi();
if (renameKey) {
const input = fileTreeEl.querySelector('.rename-input');
if (input) {
input.focus();
input.select();
}
}
}
/* ── File list merge (SSE delta) ── */
function mergeFilePayload(payload) {
const useFullSnapshot = !payload.delta || !filesHydrated;
if (useFullSnapshot) {
return { files: payload.files || [], dirs: payload.dirs || [] };
}
const byName = new Map(currentFiles.map((f) => [f.name, f]));
for (const name of payload.removed || []) byName.delete(name);
for (const f of payload.files || []) byName.set(f.name, f);
const dirs = payload.dirs || currentDirs;
return { files: [...byName.values()], dirs };
}
function applyFilePayload(payload) {
const merged = mergeFilePayload(payload);
filesHydrated = true;
lastSyncTime = new Date();
updateSyncBadge(true);
lastSnapshot = '';
renderFiles(merged.files, { ...payload, dirs: merged.dirs, files: merged.files });
}
/* ── API: load info ── */
async function loadInfo() {
const res = await driveFetch(API_BASE + '/info');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
serverInfo = await res.json();
displayLimit = serverInfo.pageSize || 100;
renderUserBanner(currentUser, serverInfo.quota);
}
async function refreshFiles() {
try {
const res = await driveFetch(API_BASE + '/files');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
applyFilePayload({ ...data, delta: false });
} catch (err) {
setStatus(`无法加载文件:${err.message}`, 'err');
}
}
/* ── SSE ── */
function connectEvents() {
const es = new EventSource(API_BASE + '/events');
es.onopen = () => updateSyncBadge(true);
es.onmessage = (e) => {
try {
applyFilePayload(JSON.parse(e.data));
} catch {}
};
es.onerror = () => {
updateSyncBadge(false);
es.close();
setTimeout(connectEvents, 2000);
};
}
/* ── Notifications ── */
function isNotifyEnabled() {
return localStorage.getItem(NOTIFY_KEY) === 'true';
}
async function requestNotifyPermission() {
if (!('Notification' in window)) {
setStatus('此浏览器不支持桌面通知', 'err');
return false;
}
const perm = await Notification.requestPermission();
const enabled = perm === 'granted';
localStorage.setItem(NOTIFY_KEY, String(enabled));
notifyBtn.classList.toggle('active', enabled);
return enabled;
}
function notifyExternalChanges(addedNames, allFiles) {
if (!isNotifyEnabled() || Notification.permission !== 'granted') return;
const myId = getClientId();
const external = addedNames.filter((name) => {
const f = allFiles.find((x) => x.name === name);
return f?.origin?.clientId && f.origin.clientId !== myId;
});
if (!external.length) return;
const label = external.length === 1 ? external[0].split('/').pop() : `${external.length} 个文件`;
const origin = allFiles.find((x) => x.name === external[0])?.origin?.clientLabel || '其他设备';
new Notification('文件互传 · 新文件', {
body: `${origin} 添加了 ${label}`,
icon: '/favicon.ico',
});
}
/* ── Conflict modal ── */
function showConflictModal(conflicts) {
return new Promise((resolve) => {
conflictList.innerHTML = conflicts.map((c) =>
`${escapeHtml(c.name)}${escapeHtml(c.path)}
`
).join('');
const chosen = document.querySelector('input[name="conflictChoice"]:checked');
if (chosen) chosen.checked = true;
conflictModal.classList.add('show');
pendingConflictResolve = resolve;
});
}
function closeConflictModal(result) {
conflictModal.classList.remove('show');
if (pendingConflictResolve) {
const choice = document.querySelector('input[name="conflictChoice"]:checked')?.value || 'rename';
pendingConflictResolve(result === false ? null : choice);
pendingConflictResolve = null;
}
}
function detectLocalConflicts(fileEntries, prefix) {
const existing = new Set(currentFiles.map((f) => f.name));
const conflicts = [];
for (const { file, relPath } of fileEntries) {
let fullPath = relPath.replace(/\\/g, '/');
if (prefix) fullPath = `${prefix}/${fullPath}`.replace(/\/+/g, '/');
if (existing.has(fullPath)) {
conflicts.push({ name: file.name, path: fullPath });
}
}
return conflicts;
}
/* ── Upload progress UI ── */
function createProgressItem(name) {
const id = 'pi-' + Math.random().toString(36).slice(2, 8);
const el = document.createElement('div');
el.className = 'progress-item';
el.id = id;
el.innerHTML = `
${escapeHtml(name)}
0%
等待
`;
progressList.appendChild(el);
return {
id,
el,
setProgress(pct, speed) {
el.querySelector('.progress-item-bar i').style.width = `${Math.round(pct * 100)}%`;
el.querySelector('.progress-item-meta').textContent = `${Math.round(pct * 100)}% · ${fmtSpeed(speed)}`;
},
setStatus(text, type = '') {
const s = el.querySelector('.progress-item-status');
s.textContent = text;
s.className = 'progress-item-status' + (type ? ' ' + type : '');
},
};
}
function showUploadProgress(show) {
progressEl.classList.toggle('show', show);
progressDetail.classList.toggle('show', show);
if (!show) {
progressBar.style.width = '0%';
progressList.innerHTML = '';
}
}
/* ── Upload: single file XHR ── */
function uploadSingleXHR(file, relPath, prefix, conflictPolicy, onProgress) {
return new Promise((resolve, reject) => {
const form = new FormData();
if (prefix) form.append('prefix', prefix);
form.append('file', file, relPath);
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE}/upload?onConflict=${encodeURIComponent(conflictPolicy)}`);
for (const [k, v] of Object.entries(clientHeaders())) {
try {
xhr.setRequestHeader(k, v);
} catch (err) {
console.warn('skip header', k, err);
}
}
let lastLoaded = 0;
let lastTime = Date.now();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const now = Date.now();
const dt = (now - lastTime) / 1000;
const speed = dt > 0 ? (e.loaded - lastLoaded) / dt : 0;
lastLoaded = e.loaded;
lastTime = now;
onProgress(e.loaded / e.total, speed);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); }
catch { resolve({ saved: [relPath], skipped: [] }); }
} else reject(new Error(xhr.responseText || xhr.statusText));
};
xhr.onerror = () => reject(new Error('网络错误'));
xhr.send(form);
});
}
/* ── Upload: chunked session ── */
async function uploadChunked(file, relPath, prefix, conflictPolicy, onProgress) {
const sessionRes = await fetch(API_BASE + '/upload/session', { ...fetchOpts,
method: 'POST',
headers: clientHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ name: relPath, size: file.size, prefix }),
});
if (!sessionRes.ok) throw new Error(await sessionRes.text());
const session = await sessionRes.json();
const chunkSize = serverInfo.chunkSize || 4 * 1024 * 1024;
let offset = 0;
let lastTime = Date.now();
let lastOffset = 0;
while (offset < file.size) {
if (uploadAbort) throw new Error('已取消');
const end = Math.min(offset + chunkSize, file.size);
const chunk = file.slice(offset, end);
const rangeEnd = end - 1;
const patchRes = await fetch(`${API_BASE}/upload/session/${session.id}`, { ...fetchOpts,
method: 'PATCH',
headers: clientHeaders({ 'Content-Range': `bytes ${offset}-${rangeEnd}/${file.size}` }),
body: chunk,
});
if (!patchRes.ok) throw new Error(await patchRes.text());
const now = Date.now();
const dt = (now - lastTime) / 1000;
const speed = dt > 0 ? (end - lastOffset) / dt : 0;
lastTime = now;
lastOffset = end;
offset = end;
onProgress(offset / file.size, speed);
}
const completeRes = await fetch(`${API_BASE}/upload/session/${session.id}/complete`, { ...fetchOpts,
method: 'POST',
headers: clientHeaders(),
});
if (!completeRes.ok) throw new Error(await completeRes.text());
return completeRes.json();
}
/* ── Upload batch ── */
async function uploadFiles(fileList, prefix = currentPath) {
const files = [...fileList].filter((f) => f && (typeof f.size !== 'number' || f.size >= 0));
if (!files.length) {
setStatus('没有可上传的文件', 'err');
return;
}
const entries = files.map((file) => ({
file,
relPath: (file.webkitRelativePath || file.name).replace(/\\/g, '/'),
}));
let conflictPolicy = onConflict;
uploadAbort = false;
showUploadProgress(true);
progressSummary.textContent = `上传 0/${files.length}`;
setStatus(`正在上传 ${files.length} 个文件${prefix ? ' 到 ' + prefix : ''}…`);
const trackers = entries.map(({ file }) => createProgressItem(file.name));
let doneCount = 0;
let savedTotal = 0;
let failed = 0;
let lastError = '';
for (let i = 0; i < entries.length; i++) {
if (uploadAbort) break;
const { file, relPath } = entries[i];
const tracker = trackers[i];
tracker.setStatus('上传中');
const onProgress = (pct, speed) => {
tracker.setProgress(pct, speed);
progressSpeed.textContent = fmtSpeed(speed);
const overall = (doneCount + pct) / files.length;
progressBar.style.width = `${Math.round(overall * 100)}%`;
};
try {
if (file.size > CHUNK_THRESHOLD) {
await uploadChunked(file, relPath, prefix, conflictPolicy, onProgress);
} else {
await uploadSingleXHR(file, relPath, prefix, conflictPolicy, onProgress);
}
tracker.setProgress(1, 0);
tracker.setStatus('完成', 'done');
doneCount += 1;
savedTotal += 1;
} catch (err) {
lastError = err?.message || String(err);
tracker.setStatus('失败', 'err');
failed += 1;
doneCount += 1;
}
progressSummary.textContent = `上传 ${doneCount}/${files.length}`;
}
lastSnapshot = '';
await refreshFiles();
if (failed) {
const detail = lastError ? `:${lastError.slice(0, 120)}` : '';
setStatus(`上传完成,${savedTotal} 成功,${failed} 失败${detail}`, failed === files.length ? 'err' : '');
} else setStatus(`已上传 ${savedTotal} 个文件`, 'ok');
setTimeout(() => showUploadProgress(false), 800);
fileInput.value = '';
folderInput.value = '';
}
/* ── DataTransfer helpers ── */
async function collectEntry(entry, base = '') {
if (entry.isFile) {
return new Promise((resolve) => {
entry.file((file) => {
const rel = base ? `${base}/${file.name}` : file.name;
Object.defineProperty(file, 'webkitRelativePath', { value: rel });
resolve([file]);
});
});
}
if (entry.isDirectory) {
const reader = entry.createReader();
const out = [];
const read = () => new Promise((resolve) => reader.readEntries(resolve));
let entries = await read();
while (entries.length) {
for (const child of entries) {
const childBase = base ? `${base}/${entry.name}` : entry.name;
out.push(...await collectEntry(child, childBase));
}
entries = await read();
}
return out;
}
return [];
}
function normalizePastedFile(file, index = 0) {
if (file.name) return file;
const ext = (file.type || '').split('/')[1] || 'bin';
const suffix = index > 0 ? `-${index + 1}` : '';
return new File([file], `粘贴文件${suffix}.${ext}`, { type: file.type || 'application/octet-stream' });
}
/** 必须在 paste/drop 事件回调里同步调用 getAsFile(),不能先 await */
function extractDataTransferSync(dt) {
const syncFiles = [];
const asyncEntries = [];
if (!dt) return { syncFiles, asyncEntries };
for (const item of [...(dt.items || [])]) {
if (item.kind !== 'file') continue;
const file = item.getAsFile();
if (file) {
syncFiles.push(normalizePastedFile(file, syncFiles.length));
continue;
}
const entry = item.webkitGetAsEntry?.();
if (entry) asyncEntries.push(entry);
}
if (!syncFiles.length && dt.files?.length) {
[...dt.files].forEach((file, i) => syncFiles.push(normalizePastedFile(file, i)));
}
return { syncFiles, asyncEntries };
}
async function fromDataTransfer(dt) {
const { syncFiles, asyncEntries } = extractDataTransferSync(dt);
const files = [...syncFiles];
for (const entry of asyncEntries) {
files.push(...await collectEntry(entry));
}
return files;
}
async function fromClipboard(clipboardData) {
return fromDataTransfer(clipboardData);
}
/* ── File operations ── */
async function deleteKeys(keys) {
const list = [...keys];
if (!list.length) return;
const label = list.length === 1 ? '这个文件' : `这 ${list.length} 项`;
if (!confirm(`确定删除${label}?(移入回收站)`)) return;
bulkDeleteBtn.disabled = true;
setStatus(`正在删除 ${list.length} 项…`);
let failed = 0;
for (const key of list) {
try {
const res = await fetch(API_BASE + '/files/' + encodeURIComponent(key).replace(/%2F/g, '/'), { method: 'DELETE' });
if (!res.ok) failed += 1;
else selected.delete(key);
} catch {
failed += 1;
}
}
lastSnapshot = '';
await refreshFiles();
if (failed) setStatus(`删除完成,${failed} 项失败`, 'err');
else setStatus(`已移入回收站 ${list.length - failed} 项`, 'ok');
}
async function createFolder() {
const name = prompt('新建文件夹名称:');
if (!name) return;
const trimmed = name.trim();
if (!trimmed || /[\\/]/.test(trimmed) || trimmed === '.' || trimmed === '..') {
setStatus('文件夹名称无效', 'err');
return;
}
try {
const res = await fetch(API_BASE + '/folders', { ...fetchOpts,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed, parent: currentPath }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (data.error === 'exists') throw new Error('名称已存在');
throw new Error(data.error || res.statusText);
}
setStatus(`已创建文件夹「${trimmed}」`, 'ok');
lastSnapshot = '';
await refreshFiles();
} catch (err) {
setStatus('创建失败: ' + err.message, 'err');
}
}
async function moveItems(keys, destFolder) {
const dest = destFolder || '';
const toMove = keys.filter((key) => {
if (parentPath(key) === dest) return false;
if (dest === key || dest.startsWith(key + '/')) return false;
return true;
});
if (!toMove.length) return;
setStatus(`正在移动 ${toMove.length} 项…`);
try {
const res = await fetch(API_BASE + '/move', { ...fetchOpts,
method: 'POST',
headers: clientHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ items: toMove, dest, onConflict }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || res.statusText);
lastSnapshot = '';
clearSelection();
await refreshFiles();
if (data.errors?.length) {
setStatus(`已移动 ${data.moved?.length || 0} 项,${data.errors.length} 项失败`, 'err');
} else {
setStatus(`已移动 ${data.moved?.length || 0} 项`, 'ok');
}
} catch (err) {
setStatus('移动失败: ' + err.message, 'err');
}
}
async function copyItems(keys, destFolder) {
const dest = destFolder || currentPath;
setStatus(`正在复制 ${keys.length} 项…`);
try {
const res = await fetch(API_BASE + '/copy', { ...fetchOpts,
method: 'POST',
headers: clientHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ items: keys, dest, onConflict }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || res.statusText);
lastSnapshot = '';
await refreshFiles();
setStatus(`已复制 ${data.copied?.length || 0} 项`, 'ok');
} catch (err) {
setStatus('复制失败: ' + err.message, 'err');
}
}
async function pasteClipboard() {
if (!internalClipboard?.items?.length) return;
const dest = currentPath;
if (internalClipboard.mode === 'cut') {
await moveItems(internalClipboard.items, dest);
internalClipboard = null;
} else {
await copyItems(internalClipboard.items, dest);
}
}
function startRename(key) {
renameKey = key;
lastSnapshot = '';
renderFiles(currentFiles, { force: true });
}
async function commitRename(key, newName) {
renameKey = null;
const trimmed = newName.trim();
if (!trimmed || trimmed === key.split('/').pop()) {
renderFiles(currentFiles, { force: true });
return;
}
if (/[\\/]/.test(trimmed)) {
setStatus('名称不能包含 / 或 \\', 'err');
renderFiles(currentFiles, { force: true });
return;
}
try {
const res = await fetch(API_BASE + '/rename', { ...fetchOpts,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: key, name: trimmed, onConflict }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || res.statusText);
if (selected.has(key) && data.to) {
selected.delete(key);
selected.add(data.to);
}
setStatus('已重命名', 'ok');
lastSnapshot = '';
await refreshFiles();
} catch (err) {
setStatus('重命名失败: ' + err.message, 'err');
renderFiles(currentFiles, { force: true });
}
}
function downloadSelectedZip() {
const items = [...selected].filter((k) => {
const f = currentFiles.find((x) => x.name === k);
return f || currentDirs.includes(k);
});
if (!items.length) return;
const url = API_BASE + '/download-zip?items=' + items.map(encodeURIComponent).join(',');
window.location.href = url;
}
/* ── Detail modal ── */
function openDetail(key) {
const file = currentFiles.find((f) => f.name === key);
const folder = currentDirs.includes(key);
detailTitle.textContent = key.split('/').pop() || key;
const rows = [
['路径', key],
['类型', folder ? '文件夹' : '文件'],
];
if (file) {
rows.push(['大小', fmtSize(file.size)]);
rows.push(['修改时间', fmtTime(file.mtime)]);
if (file.origin) {
rows.push(['来源设备', file.origin.clientLabel || '—']);
rows.push(['来源 ID', file.origin.clientId || '—']);
}
} else if (folder) {
const tree = buildTree(currentFiles, currentDirs);
const node = getNodeAtPath(tree, key);
if (node) {
rows.push(['大小', fmtSize(folderSize(node))]);
rows.push(['修改时间', fmtTime(folderMtime(node))]);
}
}
detailGrid.innerHTML = rows.map(([k, v]) =>
`${escapeHtml(k)}${escapeHtml(String(v))}`
).join('');
detailModal.classList.add('show');
}
/* ── Preview ── */
function openPreview(name) {
const url = viewUrl(name);
const ext = extOf(name);
previewTitle.textContent = name;
previewDownload.href = downloadUrl(name);
previewOpen.href = url;
previewBody.innerHTML = '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) {
previewBody.innerHTML = `
`;
} else if (['mp4', 'webm'].includes(ext)) {
previewBody.innerHTML = ``;
} else if (['mp3', 'wav', 'ogg'].includes(ext)) {
previewBody.innerHTML = ``;
} else if (ext === 'pdf') {
previewBody.innerHTML = ``;
} else if (['txt', 'md', 'json', 'js', 'mjs', 'css', 'html', 'htm', 'csv', 'xml'].includes(ext)) {
fetch(url).then((r) => r.text()).then((text) => {
previewBody.innerHTML = `${escapeHtml(text)}`;
});
} else {
window.open(url, '_blank');
return;
}
previewModal.classList.add('show');
}
/* ── Trash ── */
async function loadTrash() {
trashBody.innerHTML = '加载中…
';
try {
const res = await fetch(API_BASE + '/trash', fetchOpts);
const data = await res.json();
const items = data.items || [];
if (!items.length) {
trashBody.innerHTML = '回收站为空
';
return;
}
trashBody.innerHTML = items.map((item) => `
${escapeHtml(item.original.split('/').pop())}
${escapeHtml(item.original)} · ${fmtTime(item.trashedAt)} · ${item.isDirectory ? '文件夹' : fmtSize(item.size)}
`).join('');
} catch {
trashBody.innerHTML = '加载失败
';
}
}
function openTrash() {
trashDrawer.classList.add('show');
trashBackdrop.classList.add('show');
loadTrash();
}
function closeTrash() {
trashDrawer.classList.remove('show');
trashBackdrop.classList.remove('show');
}
async function restoreTrashItem(id) {
try {
const res = await fetch(API_BASE + '/trash/restore', { ...fetchOpts,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, onConflict }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || res.statusText);
setStatus('已恢复', 'ok');
await loadTrash();
lastSnapshot = '';
await refreshFiles();
} catch (err) {
setStatus('恢复失败: ' + err.message, 'err');
}
}
async function purgeTrashItem(id) {
if (!confirm('永久删除此项?不可恢复。')) return;
try {
await fetch(API_BASE + '/trash/' + encodeURIComponent(id), { method: 'DELETE' });
await loadTrash();
setStatus('已永久删除', 'ok');
} catch (err) {
setStatus('删除失败: ' + err.message, 'err');
}
}
async function emptyTrash() {
if (!confirm('清空回收站?所有项目将永久删除。')) return;
try {
await fetch(API_BASE + '/trash', { ...fetchOpts, method: 'DELETE' });
await loadTrash();
setStatus('回收站已清空', 'ok');
} catch (err) {
setStatus('清空失败: ' + err.message, 'err');
}
}
/* ── Drag overlay ── */
function showDragOverlay(target) {
const label = target || currentPath || '根目录';
dragOverlayHint.textContent = `松手移入 ${label || '根目录'}`;
dragOverlay.classList.add('show');
}
function hideDragOverlay() {
dragOverlay.classList.remove('show');
}
/* ── Event bindings ── */
function bindEvents() {
conflictSelect.value = onConflict;
conflictSelect.addEventListener('change', () => {
onConflict = conflictSelect.value;
localStorage.setItem(ON_CONFLICT_KEY, onConflict);
});
notifyBtn.classList.toggle('active', isNotifyEnabled() && Notification.permission === 'granted');
notifyBtn.addEventListener('click', () => requestNotifyPermission());
searchInput.addEventListener('input', () => {
searchQuery = searchInput.value.trim();
displayLimit = serverInfo.pageSize || 100;
lastSnapshot = '';
renderFiles(currentFiles);
});
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', async (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
hideDragOverlay();
await uploadFiles(await fromDataTransfer(e.dataTransfer));
});
document.addEventListener('dragenter', (e) => {
if (![...e.dataTransfer.types].includes('Files')) return;
dragDepth += 1;
showDragOverlay(dragTargetFolder || currentPath || '根目录');
});
document.addEventListener('dragleave', () => {
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) hideDragOverlay();
});
document.addEventListener('drop', () => { dragDepth = 0; hideDragOverlay(); });
fileTreeEl.addEventListener('dragstart', (e) => {
const row = e.target.closest('[data-key][draggable="true"]');
if (!row || e.target.closest('.col-check, .item-check-wrap, .row-check, .item-check, .col-actions, .icon-actions, a, button, .rename-input')) {
e.preventDefault();
return;
}
const key = row.dataset.key;
const keys = selected.has(key) && selected.size > 1 ? [...selected] : [key];
e.dataTransfer.setData('application/x-fds-keys', JSON.stringify(keys));
e.dataTransfer.effectAllowed = 'move';
row.classList.add('dragging');
});
fileTreeEl.addEventListener('dragend', () => {
fileTreeEl.querySelectorAll('.dragging').forEach((el) => el.classList.remove('dragging'));
fileTreeEl.querySelectorAll('.drop-target').forEach((el) => el.classList.remove('drop-target'));
dragTargetFolder = '';
});
fileTreeEl.addEventListener('dragover', (e) => {
const hasInternal = e.dataTransfer.types.includes('application/x-fds-keys');
const hasExternal = [...e.dataTransfer.types].includes('Files');
if (!hasInternal && !hasExternal) return;
e.preventDefault();
e.dataTransfer.dropEffect = hasInternal ? 'move' : 'copy';
const folderRow = e.target.closest('[data-folder]');
fileTreeEl.querySelectorAll('.drop-target').forEach((el) => el.classList.remove('drop-target'));
if (folderRow) {
folderRow.classList.add('drop-target');
dragTargetFolder = folderRow.getAttribute('data-folder');
showDragOverlay(dragTargetFolder);
} else {
fileTreeEl.classList.add('drop-target');
dragTargetFolder = currentPath || '根目录';
showDragOverlay(dragTargetFolder);
}
});
fileTreeEl.addEventListener('dragleave', (e) => {
if (!fileTreeEl.contains(e.relatedTarget)) {
fileTreeEl.classList.remove('drop-target');
fileTreeEl.querySelectorAll('.drop-target').forEach((el) => el.classList.remove('drop-target'));
}
});
fileTreeEl.addEventListener('drop', async (e) => {
e.preventDefault();
fileTreeEl.classList.remove('drop-target');
fileTreeEl.querySelectorAll('.drop-target').forEach((el) => el.classList.remove('drop-target'));
hideDragOverlay();
const internal = e.dataTransfer.getData('application/x-fds-keys');
const folderRow = e.target.closest('[data-folder]');
const destFolder = folderRow ? folderRow.getAttribute('data-folder') : currentPath;
if (internal) {
try {
await moveItems(JSON.parse(internal), destFolder);
} catch {}
return;
}
await uploadFiles(await fromDataTransfer(e.dataTransfer), destFolder);
});
breadcrumbEl.addEventListener('click', (e) => {
const btn = e.target.closest('.bc-link');
if (!btn) return;
enterFolder(btn.getAttribute('data-path') || '');
});
document.querySelector('.list-header').addEventListener('click', (e) => {
const btn = e.target.closest('.sortable');
if (!btn) return;
const key = btn.dataset.sort;
if (sortKey === key) sortAsc = !sortAsc;
else { sortKey = key; sortAsc = key === 'name'; }
persistSort();
lastSnapshot = '';
renderFiles(currentFiles);
});
fileTreeEl.addEventListener('change', (e) => {
const rowCheck = e.target.closest('.row-check, .item-check');
if (!rowCheck || e.target !== rowCheck) return;
e.stopPropagation();
syncCheckboxSelection(rowCheck, e.shiftKey);
fileTreeEl.focus();
});
fileTreeEl.addEventListener('click', async (e) => {
if (e.target.id === 'loadMoreBtn') {
displayLimit += serverInfo.pageSize || 100;
lastSnapshot = '';
renderFiles(currentFiles);
return;
}
if (e.target.id === 'backToRootBtn') {
enterFolder('');
return;
}
const rowEl = e.target.closest('[data-key]');
const checkWrap = e.target.closest('.col-check, .item-check-wrap');
const rowCheck = e.target.closest('.row-check, .item-check')
|| checkWrap?.querySelector('.row-check, .item-check');
if (rowCheck && rowEl && e.shiftKey) {
e.preventDefault();
const nextChecked = !selected.has(rowEl.dataset.key);
rowCheck.checked = nextChecked;
syncCheckboxSelection(rowCheck, true);
fileTreeEl.focus();
return;
}
if (rowCheck || checkWrap) {
return;
}
const infoBtn = e.target.closest('[data-info]');
if (infoBtn) {
openDetail(decodeURIComponent(infoBtn.getAttribute('data-info')));
return;
}
const delBtn = e.target.closest('[data-del]');
if (delBtn) {
await deleteKeys([decodeURIComponent(delBtn.getAttribute('data-del'))]);
return;
}
const link = e.target.closest('[data-name]');
if (link && link.dataset.preview === '1') {
e.preventDefault();
openPreview(link.dataset.name);
return;
}
if (rowEl && !e.target.closest('.col-check, .item-check-wrap, .col-actions, .icon-actions, [data-name], .rename-input')) {
const key = rowEl.dataset.key;
const index = visibleKeys.indexOf(key);
handleRowSelect(e, key, index);
fileTreeEl.focus();
}
});
fileTreeEl.addEventListener('dblclick', (e) => {
const folderRow = e.target.closest('[data-folder]');
if (folderRow && !e.target.closest('.col-check, .item-check-wrap, .col-actions, .icon-actions, .row-check, .item-check')) {
enterFolder(folderRow.getAttribute('data-folder'));
}
});
fileTreeEl.addEventListener('keydown', (e) => {
if (renameKey) return;
if (e.key === 'F2' && selected.size === 1) {
e.preventDefault();
startRename([...selected][0]);
return;
}
if (e.key === ' ' && selected.size === 1) {
e.preventDefault();
openDetail([...selected][0]);
return;
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') {
e.preventDefault();
selectAllVisible();
return;
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'c') {
e.preventDefault();
if (selected.size) internalClipboard = { mode: 'copy', items: [...selected] };
return;
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'x') {
e.preventDefault();
if (selected.size) internalClipboard = { mode: 'cut', items: [...selected] };
return;
}
/* ⌘V 交给 document paste 事件:同时支持桌面文件粘贴与内部剪切/复制 */
if (e.key === 'Escape') {
e.preventDefault();
if (selected.size) clearSelection();
else if (currentPath) {
const parts = currentPath.split('/');
parts.pop();
enterFolder(parts.join('/'));
}
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
if (selected.size) {
e.preventDefault();
deleteKeys(selected);
}
}
});
fileTreeEl.addEventListener('blur', (e) => {
const input = e.target.closest?.('.rename-input');
if (input) {
commitRename(input.dataset.rename, input.value);
}
}, true);
fileTreeEl.addEventListener('keydown', (e) => {
const input = e.target.closest?.('.rename-input');
if (!input) return;
if (e.key === 'Enter') {
e.preventDefault();
commitRename(input.dataset.rename, input.value);
} else if (e.key === 'Escape') {
e.preventDefault();
renameKey = null;
renderFiles(currentFiles, { force: true });
}
}, true);
document.addEventListener('paste', (e) => {
if (document.activeElement?.closest('.rename-input, input, textarea')) return;
const { syncFiles, asyncEntries } = extractDataTransferSync(e.clipboardData);
if (syncFiles.length || asyncEntries.length) {
e.preventDefault();
void (async () => {
const files = [...syncFiles];
for (const entry of asyncEntries) {
files.push(...await collectEntry(entry));
}
if (files.length) await uploadFiles(files);
else setStatus('无法读取剪贴板文件,请改用拖放或「选择文件」', 'err');
})();
return;
}
if (internalClipboard?.items?.length) {
e.preventDefault();
void pasteClipboard();
return;
}
const types = [...(e.clipboardData?.types || [])];
if (types.includes('Files') || types.some((t) => t.includes('uri-list'))) {
setStatus('浏览器无法读取 Finder 复制的文件,请直接拖放到页面', 'err');
}
}, true);
selectAllCheck.addEventListener('change', () => {
if (selectAllCheck.checked) selectAllVisible();
else clearSelection();
fileTreeEl.focus();
});
bulkDeleteBtn.addEventListener('click', () => deleteKeys(selected));
downloadZipBtn.addEventListener('click', () => downloadSelectedZip());
viewListBtn.addEventListener('click', () => setViewMode('list'));
viewIconsBtn.addEventListener('click', () => setViewMode('icons'));
$('newFolderBtn').addEventListener('click', () => createFolder());
$('refreshBtn').addEventListener('click', () => { lastSnapshot = ''; refreshFiles(); });
$('pickFiles').onclick = () => fileInput.click();
$('pickFolder').onclick = () => folderInput.click();
fileInput.onchange = () => uploadFiles(fileInput.files);
folderInput.onchange = () => uploadFiles(folderInput.files);
$('previewClose').onclick = () => previewModal.classList.remove('show');
previewModal.addEventListener('click', (e) => { if (e.target === previewModal) previewModal.classList.remove('show'); });
$('detailClose').onclick = () => detailModal.classList.remove('show');
detailModal.addEventListener('click', (e) => { if (e.target === detailModal) detailModal.classList.remove('show'); });
$('conflictClose').onclick = () => closeConflictModal(false);
$('conflictCancel').onclick = () => closeConflictModal(false);
$('conflictConfirm').onclick = () => closeConflictModal(true);
$('trashBtn').addEventListener('click', openTrash);
$('trashClose').addEventListener('click', closeTrash);
trashBackdrop.addEventListener('click', closeTrash);
$('trashRefreshBtn').addEventListener('click', loadTrash);
$('trashEmptyBtn').addEventListener('click', emptyTrash);
trashBody.addEventListener('click', (e) => {
const restoreBtn = e.target.closest('[data-restore]');
if (restoreBtn) { restoreTrashItem(restoreBtn.getAttribute('data-restore')); return; }
const purgeBtn = e.target.closest('[data-purge]');
if (purgeBtn) { purgeTrashItem(purgeBtn.getAttribute('data-purge')); }
});
}
/* ── Init ── */
bindEvents();
initPanelResize();
setViewMode(viewMode);
void (async () => {
currentUser = await ensureAuthenticated();
if (!currentUser) {
setStatus('未登录,正在跳转…', 'err');
return;
}
await loadInfo();
await refreshFiles();
connectEvents();
})();
})();