Files
memind/public/oa-drive/app.js
T
john 3e6b0c262a
Memind CI / Test, build, and release guards (push) Successful in 4m31s
feat(portal): add light/dark theme sync and OA drive navigation
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>
2026-09-10 20:10:06 +08:00

1853 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
'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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[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();
})();
})();