3e6b0c262a
Memind CI / Test, build, and release guards (push) Successful in 4m31s
Unify chat and OA drive appearance with shared memind-theme storage, default light mode, and cross-frame sync so users can switch themes from chat or the drive shell. Co-authored-by: Cursor <cursoragent@cursor.com>
1853 lines
64 KiB
JavaScript
1853 lines
64 KiB
JavaScript
(() => {
|
||
'use strict';
|
||
|
||
const THEME_STORAGE_KEY = 'memind-theme';
|
||
|
||
function getStoredTheme() {
|
||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||
return stored === 'dark' ? 'dark' : 'light';
|
||
}
|
||
|
||
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() {
|
||
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 = [`<span class="link-chip active">${escapeHtml(name)} 的 OA 网盘</span>`];
|
||
if (quota) {
|
||
chips.push(`<span class="link-chip">剩余 ${fmtSize(quota.availableBytes)} / ${fmtSize(quota.quotaBytes)}</span>`);
|
||
}
|
||
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 `<span class="origin-badge${self ? ' self' : ''}" title="${escapeHtml(origin.clientLabel)}">${escapeHtml(origin.clientLabel)}</span>`;
|
||
}
|
||
|
||
/* ── 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 = '<span class="bc-current">全部文件</span>';
|
||
return;
|
||
}
|
||
const parts = currentPath.split('/');
|
||
let html = '<button type="button" class="bc-link" data-path="">全部文件</button>';
|
||
let acc = '';
|
||
for (const part of parts) {
|
||
acc = acc ? `${acc}/${part}` : part;
|
||
html += '<span class="bc-sep">/</span>';
|
||
if (acc === currentPath) {
|
||
html += `<span class="bc-current">${escapeHtml(part)}</span>`;
|
||
} else {
|
||
html += `<button type="button" class="bc-link" data-path="${escapeHtml(acc)}">${escapeHtml(part)}</button>`;
|
||
}
|
||
}
|
||
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 '<span class="file-icon folder"></span>';
|
||
return `<span class="file-icon ${fileIconClass(row.fullName)}"></span>`;
|
||
}
|
||
|
||
function iconThumbHtml(row) {
|
||
if (row.type === 'folder') return '<span class="file-icon folder large"></span>';
|
||
if (isImageFile(row.fullName)) {
|
||
return `<img class="icon-thumb-img" src="${viewUrl(row.fullName)}" alt="" loading="lazy">`;
|
||
}
|
||
return `<span class="file-icon ${fileIconClass(row.fullName)} large"></span>`;
|
||
}
|
||
|
||
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
|
||
? `<input class="rename-input" data-rename="${escapeHtml(key)}" value="${escapeHtml(row.name)}" />`
|
||
: `<span class="name-text">${escapeHtml(row.name)}</span>`;
|
||
return `
|
||
<div class="list-row folder" draggable="true" data-key="${escapeHtml(key)}" data-folder="${escapeHtml(row.fullPath)}">
|
||
<label class="col-check"><input type="checkbox" class="row-check" aria-label="选择 ${escapeHtml(row.name)}" /></label>
|
||
<div class="col-name">
|
||
<span class="chevron">▸</span>
|
||
${listIconHtml(row)}
|
||
${nameCell}
|
||
</div>
|
||
<div class="col-size">${fmtSize(row.size)}</div>
|
||
<div class="col-date">${fmtTime(row.mtime)}</div>
|
||
<div class="col-actions">
|
||
<button type="button" data-info="${escapeHtml(key)}">详情</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
const cls = row.previewable ? 'previewable' : '';
|
||
const nameCell = isRenaming
|
||
? `<input class="rename-input" data-rename="${escapeHtml(key)}" value="${escapeHtml(row.name)}" />`
|
||
: `<a class="name-link ${cls}" href="${viewUrl(row.fullName)}" target="_blank" rel="noopener"
|
||
data-preview="${row.previewable ? '1' : '0'}" data-name="${escapeHtml(row.fullName)}">${escapeHtml(row.name)}</a>`;
|
||
|
||
return `
|
||
<div class="list-row file" draggable="true" data-key="${escapeHtml(key)}">
|
||
<label class="col-check"><input type="checkbox" class="row-check" aria-label="选择 ${escapeHtml(row.name)}" /></label>
|
||
<div class="col-name">
|
||
<span class="chevron hidden">▸</span>
|
||
${listIconHtml(row)}
|
||
${nameCell}
|
||
${originHtml}
|
||
</div>
|
||
<div class="col-size">${fmtSize(row.size)}</div>
|
||
<div class="col-date">${fmtTime(row.mtime)}</div>
|
||
<div class="col-actions">
|
||
<button type="button" data-info="${escapeHtml(row.fullName)}">详情</button>
|
||
<a href="${downloadUrl(row.fullName)}">下载</a>
|
||
<button type="button" data-del="${encodeURIComponent(row.fullName)}">删除</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function iconHtml(row) {
|
||
const key = rowKey(row);
|
||
const isRenaming = renameKey === key;
|
||
const originHtml = row.type === 'file' && row.origin?.clientLabel
|
||
? `<div class="icon-meta">${escapeHtml(row.origin.clientLabel)}</div>` : '';
|
||
|
||
if (row.type === 'folder') {
|
||
const nameInner = isRenaming
|
||
? `<input class="rename-input" data-rename="${escapeHtml(key)}" value="${escapeHtml(row.name)}" />`
|
||
: escapeHtml(row.name);
|
||
return `
|
||
<div class="icon-item folder" draggable="true" data-key="${escapeHtml(key)}" data-folder="${escapeHtml(row.fullPath)}">
|
||
<label class="item-check-wrap"><input type="checkbox" class="item-check" aria-label="选择 ${escapeHtml(row.name)}" /></label>
|
||
<div class="icon-thumb">${iconThumbHtml(row)}</div>
|
||
<div class="icon-name">${nameInner}</div>
|
||
<div class="icon-meta">${fmtSize(row.size)}</div>
|
||
</div>`;
|
||
}
|
||
|
||
const cls = row.previewable ? 'previewable' : '';
|
||
const nameInner = isRenaming
|
||
? `<input class="rename-input" data-rename="${escapeHtml(key)}" value="${escapeHtml(row.name)}" />`
|
||
: row.previewable
|
||
? `<a class="${cls}" href="${viewUrl(row.fullName)}" data-preview="1" data-name="${escapeHtml(row.fullName)}">${escapeHtml(row.name)}</a>`
|
||
: escapeHtml(row.name);
|
||
|
||
return `
|
||
<div class="icon-item file" draggable="true" data-key="${escapeHtml(key)}">
|
||
<label class="item-check-wrap"><input type="checkbox" class="item-check" aria-label="选择 ${escapeHtml(row.name)}" /></label>
|
||
<div class="icon-thumb">${iconThumbHtml(row)}</div>
|
||
<div class="icon-name">${nameInner}</div>
|
||
<div class="icon-meta">${fmtSize(row.size)}</div>
|
||
${originHtml}
|
||
<div class="icon-actions">
|
||
<button type="button" data-info="${escapeHtml(row.fullName)}">详情</button>
|
||
<a href="${downloadUrl(row.fullName)}">下载</a>
|
||
<button type="button" data-del="${encodeURIComponent(row.fullName)}">删除</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
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
|
||
? `<button type="button" class="load-more-btn" id="backToRootBtn">← 返回全部文件</button>`
|
||
: '';
|
||
const emptyHtml = currentPath
|
||
? `<div class="empty">
|
||
<div class="empty-drop-zone" id="emptyDropZone">
|
||
<div class="icon">📥</div>
|
||
<div>此文件夹为空</div>
|
||
<div style="margin-top:4px;font-size:12px">拖入文件到此处上传,或返回上级查看其它文件</div>
|
||
${backBtn}
|
||
</div>
|
||
</div>`
|
||
: `<div class="empty">还没有文件,先拖一个进来吧${backBtn}</div>`;
|
||
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 += `<div class="load-more-wrap"><button type="button" class="load-more-btn" id="loadMoreBtn">加载更多 (${totalCount - displayLimit} 项)</button></div>`;
|
||
}
|
||
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) =>
|
||
`<div class="conflict-item"><strong>${escapeHtml(c.name)}</strong><div class="path">${escapeHtml(c.path)}</div></div>`
|
||
).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 = `
|
||
<span class="progress-item-name">${escapeHtml(name)}</span>
|
||
<span class="progress-item-meta">0%</span>
|
||
<span class="progress-item-status">等待</span>
|
||
<div class="progress-item-bar"><i></i></div>`;
|
||
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]) =>
|
||
`<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(String(v))}</dd>`
|
||
).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 = `<img src="${url}" alt="${escapeHtml(name)}">`;
|
||
} else if (['mp4', 'webm'].includes(ext)) {
|
||
previewBody.innerHTML = `<video src="${url}" controls autoplay></video>`;
|
||
} else if (['mp3', 'wav', 'ogg'].includes(ext)) {
|
||
previewBody.innerHTML = `<audio src="${url}" controls autoplay style="width:100%;padding:24px"></audio>`;
|
||
} else if (ext === 'pdf') {
|
||
previewBody.innerHTML = `<iframe src="${url}"></iframe>`;
|
||
} else if (['txt', 'md', 'json', 'js', 'mjs', 'css', 'html', 'htm', 'csv', 'xml'].includes(ext)) {
|
||
fetch(url).then((r) => r.text()).then((text) => {
|
||
previewBody.innerHTML = `<pre>${escapeHtml(text)}</pre>`;
|
||
});
|
||
} else {
|
||
window.open(url, '_blank');
|
||
return;
|
||
}
|
||
previewModal.classList.add('show');
|
||
}
|
||
|
||
/* ── Trash ── */
|
||
async function loadTrash() {
|
||
trashBody.innerHTML = '<div class="trash-empty">加载中…</div>';
|
||
try {
|
||
const res = await fetch(API_BASE + '/trash', fetchOpts);
|
||
const data = await res.json();
|
||
const items = data.items || [];
|
||
if (!items.length) {
|
||
trashBody.innerHTML = '<div class="trash-empty">回收站为空</div>';
|
||
return;
|
||
}
|
||
trashBody.innerHTML = items.map((item) => `
|
||
<div class="trash-item" data-id="${escapeHtml(item.id)}">
|
||
<div class="trash-item-info">
|
||
<div class="trash-item-name">${escapeHtml(item.original.split('/').pop())}</div>
|
||
<div class="trash-item-meta">${escapeHtml(item.original)} · ${fmtTime(item.trashedAt)} · ${item.isDirectory ? '文件夹' : fmtSize(item.size)}</div>
|
||
</div>
|
||
<div class="trash-item-actions">
|
||
<button type="button" class="primary" data-restore="${escapeHtml(item.id)}">恢复</button>
|
||
<button type="button" data-purge="${escapeHtml(item.id)}">永久删除</button>
|
||
</div>
|
||
</div>`).join('');
|
||
} catch {
|
||
trashBody.innerHTML = '<div class="trash-empty">加载失败</div>';
|
||
}
|
||
}
|
||
|
||
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();
|
||
})();
|
||
})();
|