feat: 文件管理器 + Windows兼容 + SVG主题适配 (v0.6.0)
新增: - services/web_panel/routes/files.py — 全功能文件管理后端API (11个端点) - pages/files.html + files.js — 文件管理前端 (浏览/编辑/上传/删除/右键菜单) - 插件Picker API: window.open+postMessage唤出文件选择器 - 符号链接目录双层面包屑 (逻辑路径+物理路径) - 文件系统全访问+跨平台 (Linux/Windows/macOS) - permission_rules.yaml新增4个filemanager.*权限 修复: - SVG fill=currentColor 日夜模式自适应 - 特殊目录容错 (/dev/fd损坏符号链接/proc) - 面包屑每层级独立可点击+可编辑路径跳转 - ..行返回上级+data-is-dir补全 - rmlint→lstat回退 损坏符号链接不炸页面 文档: - 插件开发指南新增第八章(文件管理器集成) - 开发踩坑记录新增4条(特殊目录/双面包屑/SVG颜色/Windows) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
/* ── 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';
|
||||
}
|
||||
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 = document.getElementById('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 ── */
|
||||
_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(url); },
|
||||
_post: function(url, body) {
|
||||
return this._api(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('./api/files/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 = document.getElementById('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
|
||||
document.getElementById('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 = document.getElementById('fm-hidden')?.checked ? '1' : '0';
|
||||
self._get('./api/files/list?path=' + encodeURIComponent(self.currentPath) + '&show_hidden=' + showHidden)
|
||||
.then(function(d) {
|
||||
self._render(d);
|
||||
}).catch(function(e) {
|
||||
document.getElementById('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;
|
||||
|
||||
// Update editable path input
|
||||
var input = document.getElementById('fm-path-input');
|
||||
input.value = d.current || '';
|
||||
|
||||
// Breadcrumbs (logical path)
|
||||
var bc = document.getElementById('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 = document.getElementById('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">📂</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">❓</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._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() {
|
||||
document.getElementById('fm-context').style.display = 'none';
|
||||
};
|
||||
|
||||
// Picker banner
|
||||
if (self.pickerMode) {
|
||||
var banner = document.getElementById('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 16px;background:var(--primary-container);color:var(--md-sys-color-on-primary-container);border-radius:var(--shape-xs);margin-bottom:8px;font-size:.85rem">' +
|
||||
'<span>📂 选择' + (self.pickerMode === 'file' ? '文件' : '目录') + '模式</span>' +
|
||||
'<button class="btn btn-sm btn-filled" onclick="FilesModule._pickResult(FilesModule.currentPath)" style="margin-left:auto">选择当前目录</button>' +
|
||||
'<button class="btn btn-sm btn-outlined" onclick="FilesModule._cancelPicker()">取消</button>' +
|
||||
'</div>';
|
||||
list.parentNode.insertBefore(banner, list);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/* ── Picker ── */
|
||||
_pickResult: function(path) {
|
||||
if (window.opener) {
|
||||
window.opener.postMessage(JSON.stringify({action:'fm-picked', path: path, callback_id: this.pickerCallback}), '*');
|
||||
window.close();
|
||||
} else if (window.parent !== window) {
|
||||
window.parent.postMessage(JSON.stringify({action:'fm-picked', path: path, callback_id: this.pickerCallback}), '*');
|
||||
} else {
|
||||
navigator.clipboard?.writeText(path);
|
||||
alert('已选择: ' + path + '\n(路径已复制到剪贴板)');
|
||||
this.pickerMode = false;
|
||||
this.refresh();
|
||||
}
|
||||
},
|
||||
_cancelPicker: function() {
|
||||
this.pickerMode = false;
|
||||
this.refresh();
|
||||
},
|
||||
|
||||
/* ── Create ── */
|
||||
createFile: function() {
|
||||
var self = this;
|
||||
var name = prompt('新建文件名:');
|
||||
if (!name) return;
|
||||
this._post('./api/files/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('./api/files/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('./api/files/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('./api/files/read?path=' + encodeURIComponent(path))
|
||||
.then(function(d) {
|
||||
document.getElementById('fm-editor-title').textContent = '📝 ' + (name || d.name);
|
||||
document.getElementById('fm-editor-textarea').value = d.content;
|
||||
document.getElementById('fm-editor-textarea').dataset.path = path;
|
||||
document.getElementById('fm-editor').style.display = 'flex';
|
||||
}).catch(function(e) { alert('无法读取: ' + e.message); });
|
||||
},
|
||||
closeEditor: function() {
|
||||
document.getElementById('fm-editor').style.display = 'none';
|
||||
},
|
||||
saveFile: function() {
|
||||
var self = this;
|
||||
var ta = document.getElementById('fm-editor-textarea');
|
||||
this._post('./api/files/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 = document.getElementById('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('./api/files/download?path=' + encodeURIComponent(t.path), '_blank');
|
||||
document.getElementById('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('./api/files/rename', {path: t.path, new_name: nn})
|
||||
.then(function() { self.refresh(); })
|
||||
.catch(function(e) { alert('重命名失败: ' + e.message); });
|
||||
document.getElementById('fm-context').style.display = 'none';
|
||||
},
|
||||
ctxEdit: function() {
|
||||
var t = this.contextTarget;
|
||||
if (t && !t.isDir) this.openEditor(t.path, t.name);
|
||||
document.getElementById('fm-context').style.display = 'none';
|
||||
},
|
||||
ctxDelete: function() {
|
||||
var t = this.contextTarget;
|
||||
if (!t) return;
|
||||
this.toDelete = t;
|
||||
document.getElementById('fm-dialog-msg').textContent = '确认删除 "' + t.name + '"?此操作不可撤销。';
|
||||
document.getElementById('fm-dialog').style.display = 'flex';
|
||||
document.getElementById('fm-context').style.display = 'none';
|
||||
},
|
||||
confirmDelete: function() {
|
||||
var self = this;
|
||||
if (!this.toDelete) return;
|
||||
this._post('./api/files/delete', {path: this.toDelete.path})
|
||||
.then(function() { self.toDelete = null; self.closeDialog(); self.refresh(); })
|
||||
.catch(function(e) { alert('删除失败: ' + e.message); });
|
||||
},
|
||||
closeDialog: function() {
|
||||
document.getElementById('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';
|
||||
},
|
||||
_fileIcon: function(ext) {
|
||||
var map = {
|
||||
'.py':'🐍','.js':'📜','.ts':'📘','.html':'🌐','.css':'🎨',
|
||||
'.json':'📋','.yaml':'⚙','.yml':'⚙','.md':'📝','.txt':'📄',
|
||||
'.log':'📊','.sh':'💻','.bat':'💻','.xml':'📰','.toml':'⚙',
|
||||
'.cfg':'⚙','.ini':'⚙','.conf':'⚙','.sql':'🗄','.csv':'📊',
|
||||
'.zip':'📦','.tar':'📦','.gz':'📦','.7z':'📦',
|
||||
'.png':'🖼','.jpg':'🖼','.jpeg':'🖼','.gif':'🖼','.svg':'🖼','.ico':'🖼',
|
||||
'.mp3':'🎵','.wav':'🎵','.ogg':'🎵','.mp4':'🎬','.avi':'🎬',
|
||||
'.pdf':'📕','.doc':'📃','.docx':'📃','.xls':'📊','.xlsx':'📊',
|
||||
'.c':'⚡','.h':'⚡','.cpp':'⚡','.java':'☕','.rs':'🦀','.go':'🔵',
|
||||
};
|
||||
return map[ext] || '📄';
|
||||
},
|
||||
|
||||
destroy: function() {
|
||||
document.getElementById('fm-context').style.display = 'none';
|
||||
document.getElementById('fm-dialog').style.display = 'none';
|
||||
document.getElementById('fm-editor').style.display = 'none';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user