将原来的三页跳转结构(控制台/工作台/文件页)重构为单页资源管理器: 左侧数据源盘符树、主区列表/网格双视图、右侧内嵌播放器与详情面板, 管理功能收进侧栏对话框。媒体引擎(media-core + media-sw)原样复用, 仅把 file.js 的播放器逻辑抽成参数驱动组件。默认路由指向 explorer。 - 新增 explorer.html/js 与 modules/explorer/ 六个组件模块 - 删除旧的 index/source/file 页面及其控制器 - styles.css 新增 explorer 布局与三档响应式断点
584 lines
20 KiB
JavaScript
584 lines
20 KiB
JavaScript
import {
|
|
createFeedbackController,
|
|
escapeHtml,
|
|
fetchJson,
|
|
formatBytes,
|
|
optionalQueryParam
|
|
} from './modules/common.js';
|
|
import { bindLogoutButton, loadAuthState } from './modules/auth-client.js';
|
|
import { loadClientSecrets, loadGlobalClientSecrets, saveClientSecrets } from './modules/clientSecrets.js';
|
|
import { buildVolumeMap, restoreSegmentBytes, supportsFileDownload } from './modules/media-core.js';
|
|
import { createSidebar } from './modules/explorer/sidebar.js';
|
|
import { createFileList } from './modules/explorer/fileList.js';
|
|
import { createMediaPlayer } from './modules/explorer/mediaPlayer.js';
|
|
import { createDetailPanel } from './modules/explorer/detailPanel.js';
|
|
import { createManage } from './modules/explorer/manage.js';
|
|
import { createPropsDialog } from './modules/explorer/propsDialog.js';
|
|
|
|
const feedback = createFeedbackController(document.querySelector('#feedback'));
|
|
|
|
const state = {
|
|
sourceId: null,
|
|
source: null,
|
|
currentPath: '/',
|
|
currentParent: null,
|
|
selectedIds: new Set(),
|
|
fileCount: 0,
|
|
batchDownloading: false
|
|
};
|
|
|
|
// ── DOM 引用 ──
|
|
const authStatusElement = document.querySelector('#authStatus');
|
|
const logoutButton = document.querySelector('#logoutButton');
|
|
const sourceTreeElement = document.querySelector('#sourceTree');
|
|
const fileAreaElement = document.querySelector('#fileArea');
|
|
const detailContentElement = document.querySelector('#detailContent');
|
|
const breadcrumbElement = document.querySelector('#breadcrumb');
|
|
const splitElement = document.querySelector('#split');
|
|
const sidebarElement = document.querySelector('#sidebar');
|
|
const sidebarBackdrop = document.querySelector('#sidebarBackdrop');
|
|
const searchInput = document.querySelector('#searchInput');
|
|
const actionBar = document.querySelector('#actionBar');
|
|
const selectAllCheckbox = document.querySelector('#selectAllCheckbox');
|
|
const batchDownloadButton = document.querySelector('#batchDownloadButton');
|
|
const clearSelectionButton = document.querySelector('#clearSelectionButton');
|
|
const selectionCount = document.querySelector('#selectionCount');
|
|
const batchProgress = document.querySelector('#batchProgress');
|
|
const batchProgressContent = document.querySelector('#batchProgressContent');
|
|
const statusItems = document.querySelector('#statusItems');
|
|
const statusSelected = document.querySelector('#statusSelected');
|
|
const toggleDetailButton = document.querySelector('#toggleDetail');
|
|
const thumbnailCanvas = document.querySelector('#thumbnailCaptureCanvas');
|
|
const ctxFile = document.querySelector('#ctxFile');
|
|
const ctxDrive = document.querySelector('#ctxDrive');
|
|
const propsOverlay = document.querySelector('#propsOverlay');
|
|
const propsBody = document.querySelector('#propsBody');
|
|
const manageOverlay = document.querySelector('#manageOverlay');
|
|
const manageBody = document.querySelector('#manageBody');
|
|
const credentialOverlay = document.querySelector('#credentialOverlay');
|
|
const credentialForm = document.querySelector('#credentialForm');
|
|
|
|
// ── 组件实例 ──
|
|
const sidebar = createSidebar(sourceTreeElement, {
|
|
onOpenSource: (sourceId) => void openSource(sourceId),
|
|
onContextMenu: (sourceId, pos) => showDriveMenu(sourceId, pos)
|
|
});
|
|
|
|
const mediaPlayer = createMediaPlayer({
|
|
canvas: thumbnailCanvas,
|
|
onThumbnail: ({ fileId, thumbnail }) => applyThumbnailToList(fileId, thumbnail)
|
|
});
|
|
|
|
const detailPanel = createDetailPanel(detailContentElement, {
|
|
mediaPlayer,
|
|
feedback,
|
|
resolveSecrets: (sourceId, fileInfo) => resolveSecrets(sourceId, fileInfo),
|
|
onOpenProps: (sourceId) => openProps(sourceId),
|
|
onThumbnailUpdated: (fileId, thumbnail) => applyThumbnailToList(fileId, thumbnail)
|
|
});
|
|
|
|
const fileList = createFileList(fileAreaElement, {
|
|
onOpenDir: (path) => void loadDirectory(path),
|
|
onOpenFile: (entry) => void openFile(entry),
|
|
onSelectFile: (entry) => void detailPanel.showFile(state.sourceId, entry),
|
|
onSelectionChange: (selected, fileCount) => onSelectionChange(selected, fileCount),
|
|
onContextMenu: (entry, pos) => showFileMenu(entry, pos)
|
|
});
|
|
|
|
const manage = createManage(manageBody, {
|
|
feedback,
|
|
onSourcesChanged: (sources) => sidebar.setSources(sources),
|
|
onOpenSource: (sourceId) => {
|
|
closeOverlay(manageOverlay);
|
|
void openSource(sourceId);
|
|
},
|
|
close: () => closeOverlay(manageOverlay)
|
|
});
|
|
|
|
const propsDialog = createPropsDialog(propsBody, {
|
|
feedback,
|
|
onSourceChanged: (source) => {
|
|
if (source.id === state.sourceId) {
|
|
state.source = source;
|
|
}
|
|
void reloadSources();
|
|
},
|
|
onSourceDeleted: (sourceId) => {
|
|
void handleSourceDeleted(sourceId);
|
|
},
|
|
close: () => closeOverlay(propsOverlay)
|
|
});
|
|
|
|
// ── 凭据解析 ──
|
|
async function resolveSecrets(sourceId, fileInfo) {
|
|
const [sourceSecrets, globalSecrets] = await Promise.all([
|
|
loadClientSecrets(sourceId),
|
|
loadGlobalClientSecrets()
|
|
]);
|
|
const resolved = sourceSecrets ?? globalSecrets;
|
|
if (resolved?.webdavBaseUrl && resolved?.passphrase) {
|
|
return resolved;
|
|
}
|
|
// 缺凭据 → 弹窗收集
|
|
return promptForCredentials(sourceId, fileInfo, resolved);
|
|
}
|
|
|
|
function promptForCredentials(sourceId, fileInfo, partial) {
|
|
return new Promise((resolve, reject) => {
|
|
document.querySelector('#credWebdavBaseUrl').value =
|
|
partial?.webdavBaseUrl ?? fileInfo?.source?.webdav?.effectiveWebdavBaseUrl ?? state.source?.webdav?.effectiveWebdavBaseUrl ?? '';
|
|
document.querySelector('#credAuthMode').value = partial?.authMode ?? 'basic';
|
|
document.querySelector('#credUsername').value = partial?.username ?? '';
|
|
document.querySelector('#credPassword').value = partial?.password ?? '';
|
|
document.querySelector('#credPassphrase').value = partial?.passphrase ?? '';
|
|
document.querySelector('#credRemember').checked = false;
|
|
openOverlay(credentialOverlay);
|
|
|
|
function cleanup() {
|
|
credentialForm.removeEventListener('submit', onSubmit);
|
|
document.querySelector('#credCancel').removeEventListener('click', onCancel);
|
|
}
|
|
|
|
async function onSubmit(event) {
|
|
event.preventDefault();
|
|
const secrets = {
|
|
webdavBaseUrl: document.querySelector('#credWebdavBaseUrl').value.trim(),
|
|
authMode: document.querySelector('#credAuthMode').value,
|
|
username: document.querySelector('#credUsername').value.trim(),
|
|
password: document.querySelector('#credPassword').value,
|
|
passphrase: document.querySelector('#credPassphrase').value
|
|
};
|
|
if (!secrets.webdavBaseUrl || !secrets.passphrase) {
|
|
feedback.set('error', '请填写 WebDAV Base URL 和备份口令。');
|
|
return;
|
|
}
|
|
if (document.querySelector('#credRemember').checked) {
|
|
await saveClientSecrets(sourceId, secrets);
|
|
}
|
|
cleanup();
|
|
closeOverlay(credentialOverlay);
|
|
resolve(secrets);
|
|
}
|
|
|
|
function onCancel() {
|
|
cleanup();
|
|
closeOverlay(credentialOverlay);
|
|
const error = new Error('CREDENTIALS_CANCELLED: 已取消凭据输入。');
|
|
error.code = 'CREDENTIALS_CANCELLED';
|
|
reject(error);
|
|
}
|
|
|
|
credentialForm.addEventListener('submit', onSubmit);
|
|
document.querySelector('#credCancel').addEventListener('click', onCancel);
|
|
});
|
|
}
|
|
|
|
// ── 数据加载 ──
|
|
async function reloadSources() {
|
|
const payload = await fetchJson('/api/sources');
|
|
sidebar.setSources(payload.sources);
|
|
return payload.sources;
|
|
}
|
|
|
|
async function openSource(sourceId) {
|
|
if (!sourceId) {
|
|
return;
|
|
}
|
|
feedback.clear();
|
|
state.sourceId = sourceId;
|
|
sidebar.setActive(sourceId);
|
|
closeSidebarOnMobile();
|
|
|
|
try {
|
|
const payload = await fetchJson(`/api/sources/${encodeURIComponent(sourceId)}`);
|
|
state.source = payload.source;
|
|
void detailPanel.showSource(payload.source);
|
|
await loadDirectory('/');
|
|
} catch (error) {
|
|
feedback.set('error', error.message);
|
|
fileAreaElement.innerHTML = `<div class="empty-state-card">${escapeHtml(error.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadDirectory(path) {
|
|
if (!state.sourceId) {
|
|
return;
|
|
}
|
|
feedback.clear();
|
|
fileAreaElement.innerHTML = '<div class="empty-state-card">正在读取目录...</div>';
|
|
|
|
try {
|
|
const payload = await fetchJson(
|
|
`/api/ls?sourceId=${encodeURIComponent(state.sourceId)}&path=${encodeURIComponent(path)}`
|
|
);
|
|
state.currentPath = payload.path;
|
|
state.currentParent = payload.parent;
|
|
renderBreadcrumb(payload.path);
|
|
fileList.setEntries(payload.entries);
|
|
updateStatusBar();
|
|
} catch (error) {
|
|
feedback.set('error', error.message);
|
|
fileAreaElement.innerHTML = `<div class="empty-state-card"><p>无法列出目录内容。</p><p>${escapeHtml(error.message)}</p></div>`;
|
|
}
|
|
}
|
|
|
|
async function openFile(entry) {
|
|
// 双击:视频直接预览,其它文件在详情面板展示
|
|
await detailPanel.showFile(state.sourceId, entry);
|
|
fileList.setActiveFile(entry.id);
|
|
setDetailVisible(true);
|
|
}
|
|
|
|
async function handleSourceDeleted(sourceId) {
|
|
const sources = await reloadSources();
|
|
if (state.sourceId === sourceId) {
|
|
state.sourceId = null;
|
|
state.source = null;
|
|
void detailPanel.clear();
|
|
fileAreaElement.innerHTML = '<div class="empty-state-card">数据源已删除,请从左侧选择其它数据源。</div>';
|
|
breadcrumbElement.textContent = '/';
|
|
updateStatusBar();
|
|
if (sources.length > 0) {
|
|
void openSource(sources[0].id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 面包屑 ──
|
|
function renderBreadcrumb(path) {
|
|
const segments = path === '/' ? [] : path.split('/').filter(Boolean);
|
|
const crumbs = ['<button class="explorer-crumb" data-path="/" type="button">根</button>'];
|
|
let acc = '';
|
|
for (const segment of segments) {
|
|
acc += `/${segment}`;
|
|
crumbs.push('<span class="explorer-crumb-sep">▸</span>');
|
|
crumbs.push(`<button class="explorer-crumb" data-path="${escapeHtml(acc)}" type="button">${escapeHtml(segment)}</button>`);
|
|
}
|
|
breadcrumbElement.innerHTML = crumbs.join('');
|
|
}
|
|
|
|
// ── 选择与批量下载 ──
|
|
function onSelectionChange(selected, fileCount) {
|
|
state.selectedIds = selected;
|
|
state.fileCount = fileCount;
|
|
const count = selected.size;
|
|
actionBar.hidden = fileCount === 0;
|
|
selectionCount.textContent = `已选 ${count} 项`;
|
|
batchDownloadButton.disabled = count === 0 || state.batchDownloading || !supportsFileDownload();
|
|
if (!supportsFileDownload()) {
|
|
batchDownloadButton.title = '当前浏览器不支持 showSaveFilePicker';
|
|
}
|
|
selectAllCheckbox.checked = count > 0 && count === fileCount;
|
|
selectAllCheckbox.indeterminate = count > 0 && count < fileCount;
|
|
updateStatusBar();
|
|
}
|
|
|
|
function updateStatusBar() {
|
|
const entries = fileList.getEntries();
|
|
const dirs = entries.filter((entry) => entry.type === 'dir').length;
|
|
const files = entries.filter((entry) => entry.type === 'file').length;
|
|
statusItems.textContent = `${entries.length} 项(${dirs} 个文件夹,${files} 个文件)`;
|
|
statusSelected.textContent = state.selectedIds.size > 0 ? `已选 ${state.selectedIds.size} 项` : '';
|
|
}
|
|
|
|
async function initiateBatchDownload() {
|
|
if (!supportsFileDownload() || typeof window.showDirectoryPicker !== 'function') {
|
|
feedback.set('error', '当前浏览器不支持批量下载(需要 showDirectoryPicker)。');
|
|
return;
|
|
}
|
|
if (state.selectedIds.size === 0) {
|
|
return;
|
|
}
|
|
|
|
let secrets;
|
|
try {
|
|
secrets = await resolveSecrets(state.sourceId, null);
|
|
} catch (error) {
|
|
if (error?.code !== 'CREDENTIALS_CANCELLED') {
|
|
feedback.set('error', error.message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
let directoryHandle;
|
|
try {
|
|
directoryHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
|
} catch {
|
|
feedback.set('info', '已取消目录选择。');
|
|
return;
|
|
}
|
|
|
|
const fileIds = [...state.selectedIds];
|
|
const total = fileIds.length;
|
|
state.batchDownloading = true;
|
|
batchDownloadButton.disabled = true;
|
|
batchProgress.hidden = false;
|
|
const errors = [];
|
|
let totalWritten = 0;
|
|
|
|
for (let i = 0; i < fileIds.length; i += 1) {
|
|
const fileId = fileIds[i];
|
|
try {
|
|
const info = await fetchJson(
|
|
`/api/file-info?sourceId=${encodeURIComponent(state.sourceId)}&id=${encodeURIComponent(fileId)}`
|
|
);
|
|
renderBatchProgress(i + 1, total, info.file.name, totalWritten);
|
|
|
|
const safeName = info.file.name.replace(/[/\\:*?"<>|]/g, '_');
|
|
const fileHandle = await directoryHandle.getFileHandle(safeName, { create: true });
|
|
const writable = await fileHandle.createWritable();
|
|
const volumeByRef = buildVolumeMap(info.volumes);
|
|
|
|
for (const segment of info.segments) {
|
|
const volume = volumeByRef.get(segment.volumeRef);
|
|
const bytes = await restoreSegmentBytes({
|
|
sourceId: state.sourceId,
|
|
volume,
|
|
zip: segment.zip,
|
|
secrets,
|
|
onStatus: ({ phase, currentVolume }) => {
|
|
renderBatchProgress(i + 1, total, info.file.name, totalWritten, `${phase ?? ''} ${currentVolume ?? ''}`);
|
|
}
|
|
});
|
|
await writable.write(bytes);
|
|
totalWritten += bytes.length;
|
|
}
|
|
await writable.close();
|
|
} catch (error) {
|
|
errors.push(error.message);
|
|
}
|
|
}
|
|
|
|
state.batchDownloading = false;
|
|
batchProgress.hidden = true;
|
|
onSelectionChange(state.selectedIds, state.fileCount);
|
|
|
|
if (errors.length === 0) {
|
|
feedback.set('success', `已完成全部 ${total} 个文件的批量下载。`);
|
|
fileList.clearSelection();
|
|
} else {
|
|
feedback.set('error', `批量下载完成:${total - errors.length}/${total} 成功。失败:${errors.join('; ')}`);
|
|
}
|
|
}
|
|
|
|
function renderBatchProgress(current, total, fileName, written, phase = '') {
|
|
batchProgressContent.innerHTML = `
|
|
<div class="summary-stack">
|
|
<div class="summary-row"><span>进度</span><strong>${current} / ${total}</strong></div>
|
|
<div class="summary-row"><span>当前文件</span><strong>${escapeHtml(fileName)}</strong></div>
|
|
${phase ? `<div class="summary-row"><span>阶段</span><strong>${escapeHtml(phase)}</strong></div>` : ''}
|
|
<div class="summary-row"><span>已写入</span><strong>${formatBytes(written)}</strong></div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function applyThumbnailToList(fileId, thumbnail) {
|
|
const entries = fileList.getEntries();
|
|
const entry = entries.find((item) => item.id === fileId);
|
|
if (entry) {
|
|
entry.thumbnail = thumbnail;
|
|
fileList.setEntries(entries);
|
|
}
|
|
}
|
|
|
|
// ── 右键菜单 ──
|
|
function hideMenus() {
|
|
ctxFile.hidden = true;
|
|
ctxDrive.hidden = true;
|
|
}
|
|
|
|
function positionMenu(menu, { x, y }) {
|
|
hideMenus();
|
|
menu.hidden = false;
|
|
const width = menu.offsetWidth || 200;
|
|
const height = menu.offsetHeight || 220;
|
|
menu.style.left = `${Math.min(x, window.innerWidth - width - 8)}px`;
|
|
menu.style.top = `${Math.min(y, window.innerHeight - height - 8)}px`;
|
|
}
|
|
|
|
let ctxTargetEntry = null;
|
|
let ctxTargetSourceId = null;
|
|
|
|
function showFileMenu(entry, pos) {
|
|
if (entry.type !== 'file') {
|
|
return;
|
|
}
|
|
ctxTargetEntry = entry;
|
|
positionMenu(ctxFile, pos);
|
|
}
|
|
|
|
function showDriveMenu(sourceId, pos) {
|
|
ctxTargetSourceId = sourceId;
|
|
positionMenu(ctxDrive, pos);
|
|
}
|
|
|
|
ctxFile.addEventListener('click', (event) => {
|
|
const action = event.target.closest('[data-action]')?.dataset.action;
|
|
hideMenus();
|
|
if (!ctxTargetEntry) {
|
|
return;
|
|
}
|
|
if (action === 'open' || action === 'preview') {
|
|
void openFile(ctxTargetEntry);
|
|
} else if (action === 'download') {
|
|
void detailPanel.showFile(state.sourceId, ctxTargetEntry).then(() => {
|
|
// 详情面板加载后,用户可点下载;这里直接触发一次选择即可
|
|
});
|
|
fileList.setActiveFile(ctxTargetEntry.id);
|
|
setDetailVisible(true);
|
|
} else if (action === 'details') {
|
|
void detailPanel.showFile(state.sourceId, ctxTargetEntry);
|
|
fileList.setActiveFile(ctxTargetEntry.id);
|
|
setDetailVisible(true);
|
|
}
|
|
});
|
|
|
|
ctxDrive.addEventListener('click', (event) => {
|
|
const action = event.target.closest('[data-action]')?.dataset.action;
|
|
hideMenus();
|
|
if (!ctxTargetSourceId) {
|
|
return;
|
|
}
|
|
if (action === 'open') {
|
|
void openSource(ctxTargetSourceId);
|
|
} else if (action === 'refresh') {
|
|
if (state.sourceId === ctxTargetSourceId) {
|
|
void loadDirectory(state.currentPath);
|
|
} else {
|
|
void openSource(ctxTargetSourceId);
|
|
}
|
|
} else if (action === 'props') {
|
|
openProps(ctxTargetSourceId);
|
|
} else if (action === 'delete') {
|
|
openProps(ctxTargetSourceId, 'danger');
|
|
}
|
|
});
|
|
|
|
document.addEventListener('click', (event) => {
|
|
if (!event.target.closest('.explorer-context-menu')) {
|
|
hideMenus();
|
|
}
|
|
});
|
|
window.addEventListener('scroll', hideMenus, true);
|
|
|
|
// ── 弹窗 ──
|
|
function openOverlay(overlay) {
|
|
overlay.hidden = false;
|
|
}
|
|
function closeOverlay(overlay) {
|
|
overlay.hidden = true;
|
|
}
|
|
|
|
function openProps(sourceId, tab = 'enhance') {
|
|
openOverlay(propsOverlay);
|
|
void propsDialog.open(sourceId, tab);
|
|
}
|
|
|
|
for (const overlay of [propsOverlay, manageOverlay, credentialOverlay]) {
|
|
overlay.addEventListener('click', (event) => {
|
|
if (event.target === overlay && overlay !== credentialOverlay) {
|
|
closeOverlay(overlay);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── 详情面板显隐 ──
|
|
function setDetailVisible(visible) {
|
|
splitElement.classList.toggle('with-detail', visible);
|
|
toggleDetailButton.textContent = visible ? '隐藏详情面板 ▸' : '显示详情面板 ◂';
|
|
}
|
|
toggleDetailButton.addEventListener('click', () => {
|
|
setDetailVisible(!splitElement.classList.contains('with-detail'));
|
|
});
|
|
|
|
// ── 侧栏(移动端抽屉) ──
|
|
function openSidebarMobile() {
|
|
sidebarElement.classList.add('open');
|
|
sidebarBackdrop.hidden = false;
|
|
}
|
|
function closeSidebarOnMobile() {
|
|
sidebarElement.classList.remove('open');
|
|
sidebarBackdrop.hidden = true;
|
|
}
|
|
document.querySelector('#sidebarToggle').addEventListener('click', () => {
|
|
if (sidebarElement.classList.contains('open')) {
|
|
closeSidebarOnMobile();
|
|
} else {
|
|
openSidebarMobile();
|
|
}
|
|
});
|
|
sidebarBackdrop.addEventListener('click', closeSidebarOnMobile);
|
|
|
|
// ── 管理入口 ──
|
|
for (const link of document.querySelectorAll('[data-manage]')) {
|
|
link.addEventListener('click', () => {
|
|
closeSidebarOnMobile();
|
|
openOverlay(manageOverlay);
|
|
void manage.open(link.dataset.manage === 'sources' ? 'sources' : link.dataset.manage);
|
|
});
|
|
}
|
|
|
|
// ── 导航条 ──
|
|
document.querySelector('#navUp').addEventListener('click', () => {
|
|
if (state.currentParent !== null && state.currentParent !== undefined) {
|
|
void loadDirectory(state.currentParent);
|
|
} else if (state.currentPath !== '/') {
|
|
void loadDirectory('/');
|
|
}
|
|
});
|
|
document.querySelector('#navRoot').addEventListener('click', () => void loadDirectory('/'));
|
|
document.querySelector('#navRefresh').addEventListener('click', () => void loadDirectory(state.currentPath));
|
|
|
|
breadcrumbElement.addEventListener('click', (event) => {
|
|
const crumb = event.target.closest('[data-path]');
|
|
if (crumb instanceof HTMLElement) {
|
|
void loadDirectory(crumb.dataset.path);
|
|
}
|
|
});
|
|
|
|
// ── 视图 / 排序 / 搜索 ──
|
|
document.querySelector('#viewList').addEventListener('click', () => {
|
|
fileList.setView('list');
|
|
document.querySelector('#viewList').classList.add('active');
|
|
document.querySelector('#viewGrid').classList.remove('active');
|
|
});
|
|
document.querySelector('#viewGrid').addEventListener('click', () => {
|
|
fileList.setView('grid');
|
|
document.querySelector('#viewGrid').classList.add('active');
|
|
document.querySelector('#viewList').classList.remove('active');
|
|
});
|
|
document.querySelector('#sortSelect').addEventListener('change', (event) => {
|
|
const [key, dir] = event.target.value.split(':');
|
|
fileList.setSort(key, dir);
|
|
});
|
|
searchInput.addEventListener('input', (event) => {
|
|
fileList.setFilter(event.target.value);
|
|
});
|
|
|
|
// ── 选择操作条 ──
|
|
selectAllCheckbox.addEventListener('change', (event) => fileList.selectAll(event.target.checked));
|
|
clearSelectionButton.addEventListener('click', () => fileList.clearSelection());
|
|
batchDownloadButton.addEventListener('click', () => void initiateBatchDownload());
|
|
|
|
// ── 启动 ──
|
|
async function bootstrap() {
|
|
const authentication = await loadAuthState();
|
|
authStatusElement.textContent = authentication.authenticated ? `已登录:${authentication.username}` : '未登录';
|
|
bindLogoutButton(logoutButton, authStatusElement);
|
|
|
|
const sources = await reloadSources();
|
|
const requestedSource = optionalQueryParam('sourceId', '');
|
|
const target = sources.find((source) => source.id === requestedSource) ?? sources[0];
|
|
if (target) {
|
|
await openSource(target.id);
|
|
} else {
|
|
fileAreaElement.innerHTML = '<div class="empty-state-card">还没有数据源。点左侧「上传任务库」开始。</div>';
|
|
void detailPanel.clear();
|
|
}
|
|
}
|
|
|
|
void bootstrap().catch((error) => {
|
|
feedback.set('error', error.message);
|
|
fileAreaElement.innerHTML = '<div class="empty-state-card">无法进入资源管理器。</div>';
|
|
});
|