9de094ea54
- init_service: 删除_load_fmfuncs方法和fmfuncs目录创建(已迁移到sdk/) - init_service: 清理未使用的importlib.util和sys导入 - 文件管理器: 全部emoji替换为MD3 SVG矢量图标(文件夹/文件类型/右键菜单) - 示例插件: dashboard.html改为MD3风格 CSS变量自动适配日夜主题 - 面包屑: 加底色+模糊+左右外边距 - CSS: .btn::after加pointer-events:none - 插件开发指南: 更新2.6节MD3主题同步和完善的CSS变量/组件类参考表 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
420 lines
20 KiB
JavaScript
420 lines
20 KiB
JavaScript
/* ── File Manager Module ── */
|
|
window.FilesModule = {
|
|
currentPath: '',
|
|
contextTarget: null,
|
|
toDelete: null,
|
|
pickerMode: false,
|
|
pickerCallback: null,
|
|
|
|
init: function() {
|
|
var self = this;
|
|
var params = new URLSearchParams(window.location.search);
|
|
if (params.get('picker') === '1') {
|
|
self.pickerMode = true;
|
|
self.pickerMode = params.get('mode') || 'dir';
|
|
self.pickerCallback = params.get('cb') || null;
|
|
}
|
|
window.addEventListener('message', function(e) {
|
|
try {
|
|
var d = JSON.parse(e.data);
|
|
if (d.action === 'fm-picker-open') {
|
|
self.pickerMode = true;
|
|
self.pickerMode = d.mode || 'dir';
|
|
self.pickerCallback = d.callback_id || null;
|
|
self.refresh();
|
|
}
|
|
} catch(ex) {}
|
|
});
|
|
|
|
// Path input: Enter to jump
|
|
var input = FilesModule._$('fm-path-input');
|
|
input.onkeydown = function(e) {
|
|
if (e.key === 'Enter') {
|
|
var p = input.value.trim();
|
|
if (p) self._jumpTo(p);
|
|
}
|
|
};
|
|
|
|
self.refresh();
|
|
},
|
|
|
|
/* ── API helpers ── */
|
|
_apiBase: '',
|
|
_scope: null,
|
|
_$: function(id){ return this._scope ? this._scope.querySelector('#'+id) : document.getElementById(id); },
|
|
_apiUrl: function(path) {
|
|
// Use explicit base if set (standalone/iframe mode), otherwise relative
|
|
if (this._apiBase) return this._apiBase + '/api/files' + path;
|
|
return './api/files' + path;
|
|
},
|
|
_api: function(url, opts) {
|
|
opts = opts || {};
|
|
opts.credentials = 'include';
|
|
return fetch(url, opts).then(function(r) {
|
|
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
|
|
return r.json();
|
|
});
|
|
},
|
|
_get: function(url) { return this._api(this._apiUrl(url)); },
|
|
_post: function(url, body) {
|
|
return this._api(this._apiUrl(url), {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
|
|
},
|
|
|
|
/* ── Jump to path (with existence check) ── */
|
|
_jumpTo: function(path) {
|
|
var self = this;
|
|
// Probe: try to list the target path
|
|
this._get('/info?path=' + encodeURIComponent(path))
|
|
.then(function(info) {
|
|
if (info.error) { self._pathError(info.error); return; }
|
|
if (info.is_dir) {
|
|
self.currentPath = info.path;
|
|
self.refresh();
|
|
} else {
|
|
self._pathError('路径是文件,不是目录');
|
|
}
|
|
}).catch(function(e) {
|
|
self._pathError('目录不存在或无权限访问: ' + path);
|
|
});
|
|
},
|
|
|
|
_pathError: function(msg) {
|
|
var list = FilesModule._$('fm-list');
|
|
list.innerHTML = '<div style="text-align:center;color:var(--error);padding:40px">' +
|
|
'⚠ ' + this._esc(msg) + '</div>';
|
|
// Still update the input and breadcrumb to show what was attempted
|
|
FilesModule._$('fm-breadcrumb').innerHTML =
|
|
'<span class="fm-crumb" style="color:var(--error)">' + this._esc(msg) + '</span>';
|
|
},
|
|
|
|
navigate: function(path) {
|
|
this.currentPath = path || '';
|
|
this.refresh();
|
|
},
|
|
|
|
refresh: function() {
|
|
var self = this;
|
|
var showHidden = FilesModule._$('fm-hidden')?.checked ? '1' : '0';
|
|
self._get('/list?path=' + encodeURIComponent(self.currentPath) + '&show_hidden=' + showHidden)
|
|
.then(function(d) {
|
|
self._render(d);
|
|
}).catch(function(e) {
|
|
FilesModule._$('fm-list').innerHTML =
|
|
'<div style="text-align:center;color:var(--error);padding:40px">加载失败: ' + self._esc(e.message) + '</div>';
|
|
});
|
|
},
|
|
|
|
/* ── Render ── */
|
|
_render: function(d) {
|
|
var self = this;
|
|
self.currentPath = d.current || '';
|
|
|
|
// Update editable path input
|
|
var input = FilesModule._$('fm-path-input');
|
|
input.value = self.currentPath;
|
|
|
|
// Breadcrumbs (logical path)
|
|
var bc = FilesModule._$('fm-breadcrumb');
|
|
var html = self._buildBreadcrumbHTML(d.breadcrumbs);
|
|
// Resolved breadcrumbs — show as second row when symlink redirect
|
|
if (d.resolved_breadcrumbs) {
|
|
html += '<div class="fm-resolved-crumbs">' +
|
|
'<span class="fm-crumb-sep" style="color:var(--primary)">↳</span>' +
|
|
self._buildBreadcrumbHTML(d.resolved_breadcrumbs) + '</div>';
|
|
}
|
|
bc.innerHTML = html;
|
|
bc.querySelectorAll('.fm-crumb').forEach(function(el) {
|
|
el.onclick = function() { self.navigate(this.dataset.path); };
|
|
});
|
|
|
|
// File list
|
|
var list = FilesModule._$('fm-list');
|
|
var rows = '';
|
|
|
|
// ".." row for parent (unless at filesystem root)
|
|
if (d.parent && d.parent !== d.current) {
|
|
rows += '<div class="fm-row fm-dir fm-parent" data-path="' + self._escAttr(d.parent) + '" data-is-dir="1">' +
|
|
'<span class="fm-icon"><svg width="18" height="18" viewBox="0 0 24 24"><path fill="var(--text-dim)" d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-1 4H5V8h14v2z"/></svg></span>' +
|
|
'<span class="fm-name" style="font-weight:600">..</span>' +
|
|
'<span class="fm-size"></span>' +
|
|
'<span class="fm-date"></span>' +
|
|
'</div>';
|
|
}
|
|
|
|
if (d.items && d.items.length) {
|
|
d.items.forEach(function(item) {
|
|
if (item.broken) {
|
|
// Broken symlink or unreadable entry — show as disabled
|
|
rows += '<div class="fm-row fm-broken" data-path="">' +
|
|
'<span class="fm-icon"><svg width="18" height="18" viewBox="0 0 24 24"><path fill="var(--error)" d="M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"/></svg></span>' +
|
|
'<span class="fm-name" style="color:var(--error)" title="损坏的链接或无法访问">' + self._esc(item.name) + '</span>' +
|
|
'<span class="fm-size">—</span>' +
|
|
'<span class="fm-date">—</span></div>';
|
|
return;
|
|
}
|
|
var icon = item.is_dir ? FilesModule._dirIcon() : FilesModule._fileIcon(item.ext);
|
|
var sizeStr = item.is_dir ? '—' : FilesModule._fmtSize(item.size);
|
|
rows += '<div class="fm-row' + (item.is_dir ? ' fm-dir' : '') +
|
|
'" data-path="' + self._escAttr(item.path) +
|
|
'" data-is-dir="' + (item.is_dir?'1':'0') + '">' +
|
|
'<span class="fm-icon">' + icon + '</span>' +
|
|
'<span class="fm-name">' + self._esc(item.name) + '</span>' +
|
|
'<span class="fm-size">' + sizeStr + '</span>' +
|
|
'<span class="fm-date">' + (item.mtime_str || '') + '</span>' +
|
|
'</div>';
|
|
});
|
|
}
|
|
if (!rows) {
|
|
list.innerHTML = '<div style="text-align:center;color:var(--text-dim);padding:40px">空目录</div>';
|
|
} else {
|
|
list.innerHTML = rows;
|
|
}
|
|
|
|
// Row click handlers
|
|
list.querySelectorAll('.fm-row').forEach(function(row) {
|
|
row.onclick = function(e) {
|
|
var p = this.dataset.path;
|
|
var isDir = this.dataset.isDir === '1';
|
|
if (isDir) {
|
|
self.navigate(p);
|
|
} else if (self.pickerMode) {
|
|
self._pickResult(p);
|
|
}
|
|
};
|
|
row.oncontextmenu = function(e) {
|
|
e.preventDefault();
|
|
self._showContext(e, {
|
|
path: this.dataset.path,
|
|
isDir: this.dataset.isDir === '1',
|
|
name: this.querySelector('.fm-name').textContent
|
|
});
|
|
};
|
|
});
|
|
|
|
// Close context on outside click
|
|
document.onclick = function() {
|
|
FilesModule._$('fm-context').style.display = 'none';
|
|
};
|
|
|
|
// Picker banner — at bottom
|
|
if (self.pickerMode) {
|
|
var banner = FilesModule._$('fm-picker-banner');
|
|
if (!banner) {
|
|
banner = document.createElement('div');
|
|
banner.id = 'fm-picker-banner';
|
|
banner.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--primary-container);color:var(--md-sys-color-on-primary-container);border-radius:var(--shape-xs);font-size:.82rem">' +
|
|
'<span><svg width="16" height="16" viewBox="0 0 24 24" style="vertical-align:-3px"><path fill="currentColor" d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2z"/></svg> 选择' + (self.pickerMode === 'file' ? '文件' : '目录') + '模式</span>' +
|
|
'<button type="button" style="margin-left:auto;padding:6px 18px;border:none;border-radius:20px;background:var(--primary);color:var(--on-primary);font-weight:500;cursor:pointer;font-size:.82rem" onclick="_fmPick()">选择当前目录</button>' +
|
|
'<button type="button" style="padding:6px 18px;border:1px solid;border-radius:20px;background:transparent;color:var(--md-sys-color-on-primary-container);font-weight:500;cursor:pointer;font-size:.82rem" onclick="_fmCancel()">取消</button>' +
|
|
'</div>';
|
|
list.parentNode.appendChild(banner);
|
|
list.style.flex = '';
|
|
banner.style.marginTop = 'auto';
|
|
banner.style.paddingBottom = '0';
|
|
} else {
|
|
banner.style.display = 'block';
|
|
}
|
|
} else {
|
|
var oldBanner = FilesModule._$('fm-picker-banner');
|
|
if (oldBanner) oldBanner.style.display = 'none';
|
|
}
|
|
},
|
|
|
|
/* ── Picker ── */
|
|
_pickResult: function(path) {
|
|
// In iframe (picker mode): post to parent, parent closes overlay
|
|
if (window.parent !== window) {
|
|
// Ensure path is never empty — use current path as fallback
|
|
var p = path || this.currentPath || '/';
|
|
window.parent.postMessage(JSON.stringify({action:'fm-picked', path: p, callback_id: this.pickerCallback}), '*');
|
|
return;
|
|
}
|
|
// Standalone clipboard fallback
|
|
navigator.clipboard?.writeText(path);
|
|
alert('已选择: ' + path);
|
|
this.pickerMode = false;
|
|
this.refresh();
|
|
},
|
|
_cancelPicker: function() {
|
|
if (window.parent !== window) {
|
|
window.parent.postMessage(JSON.stringify({action:'fm-picked', path: null, callback_id: this.pickerCallback}), '*');
|
|
return;
|
|
}
|
|
this.pickerMode = false;
|
|
this.refresh();
|
|
},
|
|
|
|
/* ── Create ── */
|
|
createFile: function() {
|
|
var self = this;
|
|
var name = prompt('新建文件名:');
|
|
if (!name) return;
|
|
this._post('/touch', {path: this.currentPath, name: name})
|
|
.then(function() { self.refresh(); })
|
|
.catch(function(e) { alert('创建失败: ' + e.message); });
|
|
},
|
|
createDir: function() {
|
|
var self = this;
|
|
var name = prompt('新建文件夹名:');
|
|
if (!name) return;
|
|
this._post('/mkdir', {path: this.currentPath, name: name})
|
|
.then(function() { self.refresh(); })
|
|
.catch(function(e) { alert('创建失败: ' + e.message); });
|
|
},
|
|
upload: function(files) {
|
|
var self = this;
|
|
if (!files || !files.length) return;
|
|
var form = new FormData();
|
|
for (var i = 0; i < files.length; i++) form.append('file', files[i]);
|
|
fetch(self._apiUrl('/upload?path=') + encodeURIComponent(self.currentPath), {method:'POST', credentials:'include', body:form})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(d) {
|
|
if (d.ok) self.refresh();
|
|
else alert('上传失败: ' + (d.error || 'unknown'));
|
|
}).catch(function(e) { alert('上传失败: ' + e.message); });
|
|
},
|
|
|
|
/* ── Editor ── */
|
|
openEditor: function(path, name) {
|
|
var self = this;
|
|
this._get('/read?path=' + encodeURIComponent(path))
|
|
.then(function(d) {
|
|
FilesModule._$('fm-editor-title').innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" style="vertical-align:-3px;margin-right:4px"><path fill="var(--primary)" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 14h8v2H8v-2zm0-4h8v2H8v-2z"/></svg>' + (name || d.name);
|
|
FilesModule._$('fm-editor-textarea').value = d.content;
|
|
FilesModule._$('fm-editor-textarea').dataset.path = path;
|
|
FilesModule._$('fm-editor').style.display = 'flex';
|
|
}).catch(function(e) { alert('无法读取: ' + e.message); });
|
|
},
|
|
closeEditor: function() {
|
|
FilesModule._$('fm-editor').style.display = 'none';
|
|
},
|
|
saveFile: function() {
|
|
var self = this;
|
|
var ta = FilesModule._$('fm-editor-textarea');
|
|
this._post('/write', {path: ta.dataset.path, content: ta.value})
|
|
.then(function() { self.closeEditor(); self.refresh(); })
|
|
.catch(function(e) { alert('保存失败: ' + e.message); });
|
|
},
|
|
|
|
/* ── Context menu ── */
|
|
_showContext: function(e, item) {
|
|
this.contextTarget = item;
|
|
var ctx = FilesModule._$('fm-context');
|
|
ctx.style.display = 'block';
|
|
ctx.style.left = e.pageX + 'px';
|
|
ctx.style.top = e.pageY + 'px';
|
|
var items = ctx.querySelectorAll('.fm-context-item');
|
|
items[2].style.display = item.isDir ? 'none' : 'block';
|
|
},
|
|
ctxDownload: function() {
|
|
var t = this.contextTarget;
|
|
if (t) window.open(FilesModule._apiUrl('/download?path=') + encodeURIComponent(t.path), '_blank');
|
|
FilesModule._$('fm-context').style.display = 'none';
|
|
},
|
|
ctxRename: function() {
|
|
var self = this;
|
|
var t = this.contextTarget;
|
|
if (!t) return;
|
|
var nn = prompt('新名称:', t.name);
|
|
if (!nn || nn === t.name) return;
|
|
this._post('/rename', {path: t.path, new_name: nn})
|
|
.then(function() { self.refresh(); })
|
|
.catch(function(e) { alert('重命名失败: ' + e.message); });
|
|
FilesModule._$('fm-context').style.display = 'none';
|
|
},
|
|
ctxEdit: function() {
|
|
var t = this.contextTarget;
|
|
if (t && !t.isDir) this.openEditor(t.path, t.name);
|
|
FilesModule._$('fm-context').style.display = 'none';
|
|
},
|
|
ctxDelete: function() {
|
|
var t = this.contextTarget;
|
|
if (!t) return;
|
|
this.toDelete = t;
|
|
FilesModule._$('fm-dialog-msg').textContent = '确认删除 "' + t.name + '"?此操作不可撤销。';
|
|
FilesModule._$('fm-dialog').style.display = 'flex';
|
|
FilesModule._$('fm-context').style.display = 'none';
|
|
},
|
|
confirmDelete: function() {
|
|
var self = this;
|
|
if (!this.toDelete) return;
|
|
this._post('/delete', {path: this.toDelete.path})
|
|
.then(function() { self.toDelete = null; self.closeDialog(); self.refresh(); })
|
|
.catch(function(e) { alert('删除失败: ' + e.message); });
|
|
},
|
|
closeDialog: function() {
|
|
FilesModule._$('fm-dialog').style.display = 'none';
|
|
this.toDelete = null;
|
|
},
|
|
|
|
/* ── Helpers ── */
|
|
_buildBreadcrumbHTML: function(crumbs) {
|
|
if (!crumbs || !crumbs.length) return '';
|
|
var h = '';
|
|
for (var i = 0; i < crumbs.length; i++) {
|
|
var c = crumbs[i];
|
|
h += '<span class="fm-crumb" data-path="' + this._escAttr(c.path || '') + '">' + this._esc(c.label) + '</span>';
|
|
if (i < crumbs.length - 1) h += '<span class="fm-crumb-sep">▸</span>';
|
|
}
|
|
return h;
|
|
},
|
|
_esc: function(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
},
|
|
_escAttr: function(s) {
|
|
return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
|
},
|
|
_fmtSize: function(bytes) {
|
|
if (bytes === null || bytes === undefined) return '—';
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
return (bytes / 1073741824).toFixed(2) + ' GB';
|
|
},
|
|
_dirIcon: function() {
|
|
return '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="var(--primary)" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>';
|
|
},
|
|
_fileIcon: function(ext) {
|
|
// Group by category → single SVG per category
|
|
var code = '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="var(--text-dim)" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 14h4v-2H8v2zm0 3h2v-2H8v2z"/></svg>';
|
|
var img = '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="var(--text-dim)" d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>';
|
|
var arch = '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="var(--text-dim)" d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-2 10H6v-2h12v2zm0-4H6V8h12v4z"/></svg>';
|
|
var media = '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="var(--text-dim)" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>';
|
|
|
|
var codeExts = ['.py','.js','.ts','.jsx','.tsx','.html','.css','.scss','.less','.json','.yaml','.yml','.xml','.toml','.ini','.cfg','.conf','.sql','.sh','.bat','.c','.h','.cpp','.hpp','.java','.kt','.rs','.go','.rb','.php','.swift','.r','.lua','.pl','.scala','.dart','.Makefile','.gitignore','.dockerfile','.env'];
|
|
var imgExts = ['.png','.jpg','.jpeg','.gif','.svg','.ico','.bmp','.webp','.tiff'];
|
|
var mediaExts = ['.mp3','.wav','.ogg','.flac','.aac','.mp4','.avi','.mkv','.mov','.webm'];
|
|
var archExts = ['.zip','.tar','.gz','.7z','.rar','.bz2','.xz'];
|
|
|
|
if (codeExts.indexOf(ext) >= 0) return code;
|
|
if (imgExts.indexOf(ext) >= 0) return img;
|
|
if (mediaExts.indexOf(ext) >= 0) return media;
|
|
if (archExts.indexOf(ext) >= 0) return arch;
|
|
return code; // default: document icon
|
|
},
|
|
|
|
destroy: function() {
|
|
FilesModule._$('fm-context').style.display = 'none';
|
|
FilesModule._$('fm-dialog').style.display = 'none';
|
|
FilesModule._$('fm-editor').style.display = 'none';
|
|
}
|
|
};
|
|
|
|
/* ── Global picker functions (used by onclick in banner HTML) ── */
|
|
window._fmPick = function() {
|
|
var p = window.FilesModule.currentPath || '/';
|
|
// Visual feedback to confirm execution
|
|
var banner = FilesModule._$('fm-picker-banner');
|
|
if(banner) banner.style.background = '#4caf50';
|
|
if (window.parent !== window) {
|
|
window.parent.postMessage(JSON.stringify({action:'fm-picked', path:p, callback_id:window.FilesModule.pickerCallback}), '*');
|
|
}
|
|
};
|
|
window._fmCancel = function() {
|
|
var banner = FilesModule._$('fm-picker-banner');
|
|
if(banner) banner.style.background = '#f44336';
|
|
if (window.parent !== window) {
|
|
window.parent.postMessage(JSON.stringify({action:'fm-picked', path:null, callback_id:window.FilesModule.pickerCallback}), '*');
|
|
}
|
|
};
|