Files
duplicati_preview/public/modules/explorer/fileList.js
T
nanxun fcf56bcbd3 feat: rebuild frontend as file-manager style explorer SPA
将原来的三页跳转结构(控制台/工作台/文件页)重构为单页资源管理器:
左侧数据源盘符树、主区列表/网格双视图、右侧内嵌播放器与详情面板,
管理功能收进侧栏对话框。媒体引擎(media-core + media-sw)原样复用,
仅把 file.js 的播放器逻辑抽成参数驱动组件。默认路由指向 explorer。

- 新增 explorer.html/js 与 modules/explorer/ 六个组件模块
- 删除旧的 index/source/file 页面及其控制器
- styles.css 新增 explorer 布局与三档响应式断点
2026-07-04 14:53:07 +08:00

342 lines
11 KiB
JavaScript

import { escapeHtml, formatBytes } from '../common.js';
// 文件列表组件:列表(表格) + 网格双视图、列头/下拉排序、多选、缩略图。
// 移植自 source.js 的 renderBrowse / 批量选择逻辑,改造成表格与网格。
// 事件回调:onOpenDir(path) / onOpenFile(entry) / onSelectFile(entry) / onSelectionChange(set)
function isVideoEntry(entry) {
return String(entry.mime ?? '').toLowerCase().startsWith('video/') || entry.hints?.preview === 'video';
}
function typeLabel(entry) {
if (entry.type === 'dir') {
return '文件夹';
}
if (entry.hints?.preview) {
return entry.hints.preview;
}
return (entry.mime || 'file').split('/').pop();
}
function thumbInner(entry) {
if (entry.type === 'dir') {
return '<span class="explorer-thumb-ico">📁</span>';
}
if (entry.thumbnail?.available) {
return `<img src="${escapeHtml(entry.thumbnail.thumbnailUrl)}" alt="" loading="lazy">`;
}
if (isVideoEntry(entry)) {
return '<span class="explorer-thumb-ico">🎬</span>';
}
if (String(entry.mime ?? '').startsWith('image/')) {
return '<span class="explorer-thumb-ico">🖼</span>';
}
return '<span class="explorer-thumb-ico">📄</span>';
}
function compareEntries(a, b, sortKey, sortDir) {
// 文件夹永远排在文件前面(与后端 sortEntries 一致)
if (a.type !== b.type) {
return a.type === 'dir' ? -1 : 1;
}
let result = 0;
switch (sortKey) {
case 'size':
result = (Number(a.size) || 0) - (Number(b.size) || 0);
break;
case 'type':
result = typeLabel(a).localeCompare(typeLabel(b), 'en');
break;
case 'mtime':
result = String(a.mtime ?? '').localeCompare(String(b.mtime ?? ''));
break;
case 'name':
default:
result = a.name.localeCompare(b.name, 'zh-Hans-CN');
break;
}
return sortDir === 'desc' ? -result : result;
}
export function createFileList(rootElement, handlers = {}) {
const { onOpenDir, onOpenFile, onSelectFile, onSelectionChange } = handlers;
const state = {
entries: [],
view: 'list',
sortKey: 'name',
sortDir: 'asc',
filter: '',
selected: new Set(),
activeFileId: null
};
function visibleEntries() {
const filter = state.filter.trim().toLowerCase();
const filtered = filter
? state.entries.filter((entry) => entry.name.toLowerCase().includes(filter))
: state.entries.slice();
return filtered.sort((a, b) => compareEntries(a, b, state.sortKey, state.sortDir));
}
function fileEntries() {
return state.entries.filter((entry) => entry.type === 'file');
}
function renderRows(entries) {
return entries
.map((entry) => {
const selected = entry.type === 'file' && state.selected.has(entry.id);
const active = entry.type === 'file' && entry.id === state.activeFileId;
const rowClasses = [
'explorer-row',
entry.type === 'dir' ? 'is-dir' : 'is-file',
selected ? 'selected' : '',
active ? 'active' : ''
]
.filter(Boolean)
.join(' ');
const checkboxCell =
entry.type === 'file'
? `<input type="checkbox" class="explorer-row-check" data-file-id="${escapeHtml(entry.id)}" ${selected ? 'checked' : ''} aria-label="选择 ${escapeHtml(entry.name)}">`
: '';
const dataAttrs =
entry.type === 'dir'
? `data-type="dir" data-path="${escapeHtml(entry.path)}"`
: `data-type="file" data-file-id="${escapeHtml(entry.id)}"`;
return `
<tr class="${rowClasses}" ${dataAttrs}>
<td class="explorer-col-chk">${checkboxCell}</td>
<td>
<div class="explorer-name-cell">
<span class="explorer-thumb${entry.type === 'dir' ? ' folder' : ''}">
${thumbInner(entry)}${isVideoEntry(entry) ? '<span class="explorer-play">▶</span>' : ''}
</span>
<span class="explorer-name-txt">${escapeHtml(entry.name)}</span>
</div>
</td>
<td class="explorer-col-size">${entry.type === 'dir' ? '—' : formatBytes(entry.size)}</td>
<td class="explorer-col-type"><span class="explorer-badge-type">${escapeHtml(typeLabel(entry))}</span></td>
<td class="explorer-col-time muted">${escapeHtml(entry.mtime ?? '—')}</td>
</tr>
`;
})
.join('');
}
function renderCards(entries) {
return entries
.map((entry) => {
const selected = entry.type === 'file' && state.selected.has(entry.id);
const active = entry.type === 'file' && entry.id === state.activeFileId;
const cardClasses = [
'explorer-card',
entry.type === 'dir' ? 'is-dir' : 'is-file',
selected ? 'selected' : '',
active ? 'active' : ''
]
.filter(Boolean)
.join(' ');
const dataAttrs =
entry.type === 'dir'
? `data-type="dir" data-path="${escapeHtml(entry.path)}"`
: `data-type="file" data-file-id="${escapeHtml(entry.id)}"`;
const checkbox =
entry.type === 'file'
? `<input type="checkbox" class="explorer-row-check explorer-card-check" data-file-id="${escapeHtml(entry.id)}" ${selected ? 'checked' : ''} aria-label="选择 ${escapeHtml(entry.name)}">`
: '';
return `
<div class="${cardClasses}" ${dataAttrs}>
${checkbox}
<div class="explorer-cover${entry.type === 'dir' ? ' folder' : ''}">
${thumbInner(entry)}${isVideoEntry(entry) ? '<span class="explorer-play-lg">▶</span>' : ''}
</div>
<div class="explorer-card-cap" title="${escapeHtml(entry.name)}">${escapeHtml(entry.name)}</div>
<div class="explorer-card-sub">${entry.type === 'dir' ? '文件夹' : formatBytes(entry.size)}</div>
</div>
`;
})
.join('');
}
function sortArrow(key) {
if (state.sortKey !== key) {
return '';
}
return `<span class="explorer-arrow">${state.sortDir === 'asc' ? '▲' : '▼'}</span>`;
}
function render() {
const entries = visibleEntries();
if (state.entries.length === 0) {
rootElement.innerHTML = '<div class="empty-state-card">这个目录目前没有内容。</div>';
return;
}
if (entries.length === 0) {
rootElement.innerHTML = '<div class="empty-state-card">没有匹配当前搜索的文件。</div>';
return;
}
if (state.view === 'grid') {
rootElement.innerHTML = `<div class="explorer-grid">${renderCards(entries)}</div>`;
return;
}
rootElement.innerHTML = `
<table class="explorer-tbl">
<thead>
<tr>
<th class="explorer-col-chk"></th>
<th class="explorer-sortable" data-sort="name">名称 ${sortArrow('name')}</th>
<th class="explorer-col-size explorer-sortable" data-sort="size">大小 ${sortArrow('size')}</th>
<th class="explorer-col-type explorer-sortable" data-sort="type">类型 ${sortArrow('type')}</th>
<th class="explorer-col-time explorer-sortable" data-sort="mtime">修改时间 ${sortArrow('mtime')}</th>
</tr>
</thead>
<tbody>${renderRows(entries)}</tbody>
</table>
`;
}
function emitSelectionChange() {
onSelectionChange?.(new Set(state.selected), fileEntries().length);
}
function toggleSelection(fileId, selected) {
if (selected) {
state.selected.add(fileId);
} else {
state.selected.delete(fileId);
}
render();
emitSelectionChange();
}
// ── 事件委托 ──
rootElement.addEventListener('click', (event) => {
const checkbox = event.target.closest('.explorer-row-check');
if (checkbox instanceof HTMLInputElement) {
event.stopPropagation();
toggleSelection(checkbox.dataset.fileId, checkbox.checked);
return;
}
const sortable = event.target.closest('.explorer-sortable');
if (sortable instanceof HTMLElement) {
const key = sortable.dataset.sort;
if (state.sortKey === key) {
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
} else {
state.sortKey = key;
state.sortDir = 'asc';
}
render();
return;
}
const item = event.target.closest('[data-type]');
if (!(item instanceof HTMLElement)) {
return;
}
if (item.dataset.type === 'dir') {
onOpenDir?.(item.dataset.path);
return;
}
const entry = state.entries.find((candidate) => candidate.id === item.dataset.fileId);
if (entry) {
state.activeFileId = entry.id;
render();
onSelectFile?.(entry);
}
});
rootElement.addEventListener('dblclick', (event) => {
const item = event.target.closest('[data-type="file"]');
if (!(item instanceof HTMLElement)) {
return;
}
const entry = state.entries.find((candidate) => candidate.id === item.dataset.fileId);
if (entry) {
onOpenFile?.(entry);
}
});
rootElement.addEventListener('contextmenu', (event) => {
const item = event.target.closest('[data-type]');
if (!(item instanceof HTMLElement) || !handlers.onContextMenu) {
return;
}
event.preventDefault();
const entry =
item.dataset.type === 'file'
? state.entries.find((candidate) => candidate.id === item.dataset.fileId)
: { type: 'dir', path: item.dataset.path };
handlers.onContextMenu(entry, { x: event.clientX, y: event.clientY });
});
return {
setEntries(entries) {
state.entries = Array.isArray(entries) ? entries : [];
state.selected.clear();
state.activeFileId = null;
render();
emitSelectionChange();
},
setView(view) {
state.view = view === 'grid' ? 'grid' : 'list';
render();
},
setSort(key, dir) {
state.sortKey = key;
state.sortDir = dir;
render();
},
setFilter(value) {
state.filter = value ?? '';
render();
},
setActiveFile(fileId) {
state.activeFileId = fileId;
render();
},
selectAll(selected) {
if (selected) {
for (const entry of fileEntries()) {
state.selected.add(entry.id);
}
} else {
state.selected.clear();
}
render();
emitSelectionChange();
},
clearSelection() {
state.selected.clear();
state.activeFileId = null;
render();
emitSelectionChange();
},
getSelectedIds() {
return [...state.selected];
},
getFileCount() {
return fileEntries().length;
},
getEntries() {
return state.entries.slice();
}
};
}