将原来的三页跳转结构(控制台/工作台/文件页)重构为单页资源管理器: 左侧数据源盘符树、主区列表/网格双视图、右侧内嵌播放器与详情面板, 管理功能收进侧栏对话框。媒体引擎(media-core + media-sw)原样复用, 仅把 file.js 的播放器逻辑抽成参数驱动组件。默认路由指向 explorer。 - 新增 explorer.html/js 与 modules/explorer/ 六个组件模块 - 删除旧的 index/source/file 页面及其控制器 - styles.css 新增 explorer 布局与三档响应式断点
342 lines
11 KiB
JavaScript
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();
|
|
}
|
|
};
|
|
}
|