596 lines
24 KiB
Markdown
596 lines
24 KiB
Markdown
|
|
# 文件管理器 v2 实现计划
|
|||
|
|
|
|||
|
|
> ⚠️ **已归档** — 2026-05-22
|
|||
|
|
> 本文档引用的文件结构已过时:`web/files.php` 已迁移到 `web/app/files.html` (Tailwind CSS v4 + Alpine.js + SFTP API);`web/file_actions.php` 和 `web/browse_dirs.php` 已不存在,文件操作通过 `server/api/sync_v2.py` SFTP 端点实现。功能已实现,保留本文档供历史参考。 实现计划
|
|||
|
|
|
|||
|
|
> **面向 AI 代理的工作者:** 使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
|||
|
|
|
|||
|
|
**目标:** 将 files.php 重构为专业文件管理器:目录树 + 文件列表 + 右键菜单 + 快捷键 + Monaco Editor + 拖拽上传
|
|||
|
|
|
|||
|
|
**架构:** 左侧目录树(懒加载),右侧表格文件列表,工具栏在顶部,底部状态栏。右键菜单和快捷键操作文件。Monaco Editor CDN 全屏编辑代码。
|
|||
|
|
|
|||
|
|
**技术栈:** PHP + JS + Monaco Editor CDN + file_actions.php
|
|||
|
|
|
|||
|
|
**规格**: [docs/superpowers/specs/2026-05-18-file-manager-v2-design.md](../specs/2026-05-18-file-manager-v2-design.md)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 任务 1:browse_dirs.php 支持目录树懒加载
|
|||
|
|
|
|||
|
|
**文件:**
|
|||
|
|
- 修改:`web/browse_dirs.php`
|
|||
|
|
- 验证:`curl "http://localhost/browse_dirs.php?path=/www/wwwroot&tree=1"`
|
|||
|
|
|
|||
|
|
- [ ] **步骤 1:加 tree 参数,只返回目录**
|
|||
|
|
|
|||
|
|
在现有代码中加 `action=tree` 分支:
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
// 目录树 API(只返回目录,用于左侧树)
|
|||
|
|
if (($_GET['tree'] ?? '') === '1') {
|
|||
|
|
$entries = [];
|
|||
|
|
$handle = opendir($path);
|
|||
|
|
if ($handle) {
|
|||
|
|
while (($entry = readdir($handle)) !== false) {
|
|||
|
|
if ($entry === '.' || $entry === '..') continue;
|
|||
|
|
$full = $path . $entry;
|
|||
|
|
if (is_dir($full)) {
|
|||
|
|
$hasSub = false;
|
|||
|
|
$subHandle = @opendir($full);
|
|||
|
|
if ($subHandle) {
|
|||
|
|
while (($sub = readdir($subHandle)) !== false) {
|
|||
|
|
if ($sub !== '.' && $sub !== '..' && is_dir($full . '/' . $sub)) { $hasSub = true; break; }
|
|||
|
|
}
|
|||
|
|
closedir($subHandle);
|
|||
|
|
}
|
|||
|
|
$entries[] = ['name' => $entry, 'path' => $full, 'hasSub' => $hasSub];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
closedir($handle);
|
|||
|
|
}
|
|||
|
|
usort($entries, fn($a,$b) => strcasecmp($a['name'], $b['name']));
|
|||
|
|
header('Content-Type: application/json');
|
|||
|
|
echo json_encode(['path' => $path, 'entries' => $entries], JSON_UNESCAPED_UNICODE);
|
|||
|
|
exit;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **步骤 2:验证**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
curl -s "http://127.0.0.1/browse_dirs.php?path=/www/wwwroot&tree=1" | python3 -m json.tool | head -20
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
预期:只返回目录列表,含 `hasSub` 字段
|
|||
|
|
|
|||
|
|
- [ ] **步骤 3:Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add web/browse_dirs.php
|
|||
|
|
git commit -m "feat: browse_dirs 支持 tree 参数返回目录树"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 任务 2:重写 files.php 布局——目录树 + 文件列表
|
|||
|
|
|
|||
|
|
**文件:**
|
|||
|
|
- 修改:`web/files.php`(完全重写)
|
|||
|
|
|
|||
|
|
- [ ] **步骤 1:写新布局 HTML**
|
|||
|
|
|
|||
|
|
两栏布局:左侧目录树 `#fmTree`(200px),右侧表格 `#fmTable`。顶部工具栏。
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<div class="fm-container">
|
|||
|
|
<div class="fm-toolbar">
|
|||
|
|
<button onclick="navUp()" title="上级 Backspace"><i class="fas fa-arrow-up"></i></button>
|
|||
|
|
<button onclick="reloadFm()" title="刷新 F5"><i class="fas fa-sync-alt"></i></button>
|
|||
|
|
<span class="fm-sep"></span>
|
|||
|
|
<button onclick="showMkdir()" title="建文件夹"><i class="fas fa-folder-plus"></i></button>
|
|||
|
|
<button onclick="showMkfile()" title="建文件"><i class="fas fa-file-plus"></i></button>
|
|||
|
|
<span class="fm-sep"></span>
|
|||
|
|
<button onclick="cutSelected()" title="剪切 Ctrl+X"><i class="fas fa-cut"></i></button>
|
|||
|
|
<button onclick="copySelected()" title="复制 Ctrl+C"><i class="fas fa-copy"></i></button>
|
|||
|
|
<button onclick="pasteFiles()" title="粘贴 Ctrl+V"><i class="fas fa-paste"></i></button>
|
|||
|
|
<span class="fm-sep"></span>
|
|||
|
|
<input type="text" id="fmSearch" placeholder="搜索..." oninput="doSearch()" style="width:180px;padding:4px 8px;border:1px solid var(--border);border-radius:4px;font-size:12px;">
|
|||
|
|
<span class="fm-sep"></span>
|
|||
|
|
<label class="btn btn-sm btn-info" style="cursor:pointer;"><i class="fas fa-upload"></i><input type="file" id="fmUpload" style="display:none;" onchange="doUpload(this)" multiple></label>
|
|||
|
|
<div id="fmCrumbs" style="margin-left:8px;font-size:12px;"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="fm-main">
|
|||
|
|
<div class="fm-tree" id="fmTree"></div>
|
|||
|
|
<div class="fm-list" id="fmList">
|
|||
|
|
<div class="fm-table-wrap">
|
|||
|
|
<table class="fm-table"><thead><tr>
|
|||
|
|
<th style="width:30px;"><input type="checkbox" onchange="toggleAll(this)"></th>
|
|||
|
|
<th onclick="sortBy('name')">名称</th>
|
|||
|
|
<th style="width:90px;" onclick="sortBy('size')">大小</th>
|
|||
|
|
<th style="width:70px;">权限</th>
|
|||
|
|
<th style="width:150px;" onclick="sortBy('mtime')">修改时间</th>
|
|||
|
|
</tr></thead></table>
|
|||
|
|
<div class="fm-table-body" id="fmBody" ondrop="onDrop(event)" ondragover="event.preventDefault()">
|
|||
|
|
<div style="text-align:center;padding:60px;color:#9ca3af;">加载中...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="fm-status">
|
|||
|
|
<span id="fmStatusLeft"></span>
|
|||
|
|
<span id="fmStatusRight" style="color:#9ca3af;font-size:11px;">拖拽文件到此处上传</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **步骤 2:CSS 样式**
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
.fm-container{display:flex;flex-direction:column;height:calc(100vh - 120px);}
|
|||
|
|
.fm-toolbar{display:flex;gap:4px;align-items:center;padding:8px 12px;background:var(--bg-card);border:1px solid var(--border);border-radius:6px;margin-bottom:8px;flex-wrap:wrap;}
|
|||
|
|
.fm-sep{width:1px;height:20px;background:var(--border);margin:0 4px;}
|
|||
|
|
.fm-main{display:flex;flex:1;overflow:hidden;border:1px solid var(--border);border-radius:6px;background:var(--bg-card);}
|
|||
|
|
.fm-tree{width:240px;overflow-y:auto;border-right:1px solid var(--border);padding:4px 0;}
|
|||
|
|
.fm-tree-item{padding:4px 8px 4px 16px;cursor:pointer;font-size:12px;display:flex;align-items:center;gap:4px;white-space:nowrap;}
|
|||
|
|
.fm-tree-item:hover{background:#f3f4f6;}
|
|||
|
|
.fm-tree-item.active{background:#eff6ff;color:#3b82f6;}
|
|||
|
|
.fm-tree-item .fa-chevron-right{font-size:8px;width:12px;transition:transform 0.15s;}
|
|||
|
|
.fm-tree-item .fa-chevron-right.open{transform:rotate(90deg);}
|
|||
|
|
.fm-list{flex:1;display:flex;flex-direction:column;overflow:hidden;}
|
|||
|
|
.fm-table-wrap{flex:1;overflow-y:auto;}
|
|||
|
|
.fm-table{width:100%;border-collapse:collapse;table-layout:fixed;}
|
|||
|
|
.fm-table th{position:sticky;top:0;background:var(--bg-card);text-align:left;padding:6px 10px;font-size:12px;color:#6b7280;border-bottom:2px solid var(--border);cursor:pointer;z-index:1;}
|
|||
|
|
.fm-table th:hover{color:#3b82f6;}
|
|||
|
|
.fm-table td{padding:4px 10px;font-size:13px;border-bottom:1px solid var(--border-light);user-select:none;}
|
|||
|
|
.fm-table tr:hover{background:#f9fafb;}
|
|||
|
|
.fm-table tr.selected{background:#eff6ff;}
|
|||
|
|
.fm-table tr.cut{opacity:0.5;}
|
|||
|
|
.fm-name{cursor:pointer;display:flex;align-items:center;gap:6px;}
|
|||
|
|
.fm-name i.fa-folder{color:#f59e0b;}
|
|||
|
|
.fm-name i.fa-file{color:#9ca3af;}
|
|||
|
|
.fm-size{color:#6b7280;font-size:12px;white-space:nowrap;}
|
|||
|
|
.fm-status{display:flex;justify-content:space-between;padding:6px 12px;font-size:12px;border-top:1px solid var(--border);background:#f9fafb;}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **步骤 3:Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add web/files.php
|
|||
|
|
git commit -m "feat: files.php v2 布局——目录树+文件列表+状态栏"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 任务 3:文件列表渲染 + 导航逻辑
|
|||
|
|
|
|||
|
|
**文件:**
|
|||
|
|
- 修改:`web/files.php`
|
|||
|
|
|
|||
|
|
- [ ] **步骤 1:全局状态 + 加载函数**
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
let fmPath = '/www/wwwroot/';
|
|||
|
|
let fmEntries = [];
|
|||
|
|
let fmSortField = 'name';
|
|||
|
|
let fmSortDir = 1;
|
|||
|
|
let fmClipboard = null; // {action:'copy'|'cut', paths:[...]}
|
|||
|
|
let fmSelectedPaths = new Set();
|
|||
|
|
|
|||
|
|
function loadFm(path) {
|
|||
|
|
fmPath = path;
|
|||
|
|
fetch('browse_dirs.php?path=' + encodeURIComponent(path))
|
|||
|
|
.then(r => r.json()).then(d => {
|
|||
|
|
fmEntries = d.entries || [];
|
|||
|
|
renderCrumbs(d.path);
|
|||
|
|
renderTable();
|
|||
|
|
renderTree(fmPath);
|
|||
|
|
updateStatus();
|
|||
|
|
document.getElementById('fmSearch').value = '';
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderCrumbs(p) {
|
|||
|
|
const parts = p.replace(/\/+$/,'').split('/').filter(Boolean);
|
|||
|
|
let h = '<a class="fm-crumb" onclick="loadFm(\'/\')">/</a>';
|
|||
|
|
let built = '/';
|
|||
|
|
parts.forEach(x => {
|
|||
|
|
built += x + '/';
|
|||
|
|
h += ' <a class="fm-crumb" onclick="loadFm(\'' + built + '\')">' + x + '</a> / ';
|
|||
|
|
});
|
|||
|
|
document.getElementById('fmCrumbs').innerHTML = h;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **步骤 2:渲染文件表格 + 排序**
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
function renderTable(filter) {
|
|||
|
|
let list = [...fmEntries];
|
|||
|
|
if (filter) list = list.filter(e => e.name.toLowerCase().includes(filter.toLowerCase()));
|
|||
|
|
list.sort((a,b) => {
|
|||
|
|
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|||
|
|
const va = a[fmSortField] || '', vb = b[fmSortField] || '';
|
|||
|
|
return (typeof va === 'number' ? va - vb : va.localeCompare(vb)) * fmSortDir;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let html = '';
|
|||
|
|
list.forEach((e,i) => {
|
|||
|
|
const sel = fmSelectedPaths.has(e.path) ? ' selected' : '';
|
|||
|
|
const icon = e.type === 'dir' ? '<i class="fas fa-folder"></i>' : '<i class="fas fa-file"></i>';
|
|||
|
|
const name = e.type === 'dir'
|
|||
|
|
? '<a class="fm-name" onclick="loadFm(\'' + esc(e.path) + '\')">' + icon + ' ' + esc(e.name) + '</a>'
|
|||
|
|
: '<span class="fm-name" ondblclick="openEditor(\'' + esc(e.path) + '\',\'' + esc(e.name) + '\')">' + icon + ' ' + esc(e.name) + '</span>';
|
|||
|
|
html += '<tr class="' + sel + (fmClipboard && fmClipboard.paths.includes(e.path) && fmClipboard.action==='cut' ? ' cut' : '') + '" ' +
|
|||
|
|
'onclick="selectRow(event,\'' + esc(e.path) + '\',\'' + e.type + '\')" ' +
|
|||
|
|
'oncontextmenu="showContextMenu(event,\'' + esc(e.path) + '\',\'' + e.type + '\',\'' + esc(e.name) + '\')">' +
|
|||
|
|
'<td><input type="checkbox" class="fm-check" ' + (fmSelectedPaths.has(e.path)?'checked':'') + '></td>' +
|
|||
|
|
'<td>' + name + '</td>' +
|
|||
|
|
'<td class="fm-size">' + (e.type==='dir'?'-':formatSize(e.size)) + '</td>' +
|
|||
|
|
'<td class="fm-size">' + (e.mode||'-') + '</td>' +
|
|||
|
|
'<td class="fm-size">' + (e.mtime||'-') + '</td></tr>';
|
|||
|
|
});
|
|||
|
|
document.getElementById('fmBody').innerHTML = html || '<div style="text-align:center;padding:60px;color:#9ca3af;">空目录</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sortBy(field) {
|
|||
|
|
if (fmSortField === field) fmSortDir *= -1;
|
|||
|
|
else { fmSortField = field; fmSortDir = 1; }
|
|||
|
|
renderTable();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function doSearch() { renderTable(document.getElementById('fmSearch').value); }
|
|||
|
|
function updateStatus() {
|
|||
|
|
const files = fmEntries.filter(e => e.type === 'file').length;
|
|||
|
|
const dirs = fmEntries.filter(e => e.type === 'dir').length;
|
|||
|
|
document.getElementById('fmStatusLeft').textContent = files + ' 个文件,' + dirs + ' 个目录';
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **步骤 3:目录树渲染**
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
function renderTree(currentPath) {
|
|||
|
|
const root = fmPath.split('/').slice(0,3).join('/') + '/'; // /www/wwwroot/
|
|||
|
|
fetch('browse_dirs.php?path=' + encodeURIComponent(root) + '&tree=1')
|
|||
|
|
.then(r => r.json()).then(d => {
|
|||
|
|
let html = '';
|
|||
|
|
(d.entries||[]).forEach(e => {
|
|||
|
|
const active = currentPath.startsWith(e.path) ? ' active' : '';
|
|||
|
|
html += '<div class="fm-tree-item' + active + '" style="padding-left:' + (12) + 'px" ' +
|
|||
|
|
'onclick="loadFm(\''+esc(e.path)+'\')">' +
|
|||
|
|
(e.hasSub?'<i class="fas fa-chevron-right tree-arrow" onclick="toggleTree(event,\''+esc(e.path)+'\')"></i>':'<span style="width:12px;"></span>') +
|
|||
|
|
'<i class="fas fa-folder" style="color:#f59e0b;margin-right:4px;"></i>' + esc(e.name) + '</div>';
|
|||
|
|
if (currentPath.startsWith(e.path) && e.hasSub) html += '<div id="sub_' + escId(e.path) + '" class="fm-tree-children"></div>';
|
|||
|
|
});
|
|||
|
|
document.getElementById('fmTree').innerHTML = html;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toggleTree(e, path) {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
const arrow = e.target;
|
|||
|
|
const sub = document.getElementById('sub_' + escId(path));
|
|||
|
|
if (sub.innerHTML) { sub.innerHTML = ''; arrow.classList.remove('open'); return; }
|
|||
|
|
arrow.classList.add('open');
|
|||
|
|
fetch('browse_dirs.php?path=' + encodeURIComponent(path) + '&tree=1')
|
|||
|
|
.then(r => r.json()).then(d => {
|
|||
|
|
let html = '';
|
|||
|
|
(d.entries||[]).forEach(e => {
|
|||
|
|
html += '<div class="fm-tree-item" style="padding-left:28px" onclick="loadFm(\''+esc(e.path)+'\')">' +
|
|||
|
|
(e.hasSub?'<i class="fas fa-chevron-right tree-arrow" onclick="toggleTree(event,\''+esc(e.path)+'\')"></i>':'<span style="width:12px;"></span>') +
|
|||
|
|
'<i class="fas fa-folder" style="color:#f59e0b;margin-right:4px;"></i>' + esc(e.name) + '</div>';
|
|||
|
|
html += '<div id="sub_' + escId(e.path) + '" class="fm-tree-children"></div>';
|
|||
|
|
});
|
|||
|
|
sub.innerHTML = html;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function escId(s) { return s.replace(/[^a-zA-Z0-9]/g, '_'); }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **步骤 4:Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add web/files.php
|
|||
|
|
git commit -m "feat: 文件列表渲染+排序+搜索+目录树懒加载"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 任务 4:键盘快捷键 + 右键菜单
|
|||
|
|
|
|||
|
|
**文件:**
|
|||
|
|
- 修改:`web/files.php`
|
|||
|
|
|
|||
|
|
- [ ] **步骤 1:右键菜单 DOM**
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<div class="fm-context" id="fmContext" style="display:none;position:fixed;z-index:10000;background:#fff;border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 20px rgba(0,0,0,0.15);min-width:160px;padding:4px 0;">
|
|||
|
|
<div class="fm-context-item" onclick="openDir()"><i class="fas fa-folder-open"></i> 打开</div>
|
|||
|
|
<div class="fm-context-item" onclick="openEditor(fmCtxPath,fmCtxName)"><i class="fas fa-pen"></i> 编辑</div>
|
|||
|
|
<div class="fm-context-sep"></div>
|
|||
|
|
<div class="fm-context-item" onclick="cutSelected()"><i class="fas fa-cut"></i> 剪切</div>
|
|||
|
|
<div class="fm-context-item" onclick="copySelected()"><i class="fas fa-copy"></i> 复制</div>
|
|||
|
|
<div class="fm-context-item" onclick="pasteFiles()"><i class="fas fa-paste"></i> 粘贴</div>
|
|||
|
|
<div class="fm-context-sep"></div>
|
|||
|
|
<div class="fm-context-item" onclick="renameOne(fmCtxPath,fmCtxName)"><i class="fas fa-edit"></i> 重命名</div>
|
|||
|
|
<div class="fm-context-item" onclick="deleteOne(fmCtxPath,fmCtxName)"><i class="fas fa-trash"></i> 删除</div>
|
|||
|
|
<div class="fm-context-sep"></div>
|
|||
|
|
<div class="fm-context-item" onclick="compressOne(fmCtxPath,fmCtxName)"><i class="fas fa-file-archive"></i> 压缩</div>
|
|||
|
|
<div class="fm-context-item" onclick="showChmod()"><i class="fas fa-lock"></i> 权限</div>
|
|||
|
|
</div>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **步骤 2:右键菜单 JS**
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
let fmCtxPath = '', fmCtxName = '', fmCtxType = '';
|
|||
|
|
|
|||
|
|
function showContextMenu(e, path, type, name) {
|
|||
|
|
e.preventDefault();
|
|||
|
|
fmCtxPath = path; fmCtxName = name; fmCtxType = type;
|
|||
|
|
const menu = document.getElementById('fmContext');
|
|||
|
|
menu.style.display = 'block';
|
|||
|
|
menu.style.left = e.clientX + 'px';
|
|||
|
|
menu.style.top = e.clientY + 'px';
|
|||
|
|
// 隐藏不适用的项
|
|||
|
|
menu.querySelectorAll('.fm-context-item').forEach(el => el.style.display = '');
|
|||
|
|
document.querySelector('.fm-context-item[onclick*="openDir"]').style.display = type === 'dir' ? '' : 'none';
|
|||
|
|
document.querySelector('.fm-context-item[onclick*="openEditor"]').style.display = type === 'file' ? '' : 'none';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openDir() { if (fmCtxType === 'dir') loadFm(fmCtxPath); }
|
|||
|
|
document.addEventListener('click', () => document.getElementById('fmContext').style.display = 'none');
|
|||
|
|
|
|||
|
|
function selectRow(e, path, type) {
|
|||
|
|
if (e.ctrlKey) { fmSelectedPaths.has(path) ? fmSelectedPaths.delete(path) : fmSelectedPaths.add(path); }
|
|||
|
|
else { fmSelectedPaths = new Set([path]); }
|
|||
|
|
renderTable(document.getElementById('fmSearch').value);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **步骤 3:键盘快捷键**
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
document.addEventListener('keydown', function(e) {
|
|||
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|||
|
|
if (e.ctrlKey && e.key === 'c') { e.preventDefault(); copySelected(); }
|
|||
|
|
if (e.ctrlKey && e.key === 'x') { e.preventDefault(); cutSelected(); }
|
|||
|
|
if (e.ctrlKey && e.key === 'v') { e.preventDefault(); pasteFiles(); }
|
|||
|
|
if (e.ctrlKey && e.key === 'a') { e.preventDefault(); fmEntries.forEach(e => fmSelectedPaths.add(e.path)); renderTable(); }
|
|||
|
|
if (e.key === 'Delete' && fmSelectedPaths.size > 0) { deleteBtn(); }
|
|||
|
|
if (e.key === 'F2' && fmSelectedPaths.size === 1) { renameOne([...fmSelectedPaths][0], ''); }
|
|||
|
|
if (e.key === 'F5') { e.preventDefault(); reloadFm(); }
|
|||
|
|
if (e.key === 'Backspace' && !e.ctrlKey) { navUp(); }
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let fmCtxPath = '', fmCtxName = '', fmCtxType = '';
|
|||
|
|
|
|||
|
|
function copySelected() {
|
|||
|
|
if (fmSelectedPaths.size === 0) return;
|
|||
|
|
fmClipboard = {action: 'copy', paths: [...fmSelectedPaths]};
|
|||
|
|
renderTable(document.getElementById('fmSearch').value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function cutSelected() {
|
|||
|
|
if (fmSelectedPaths.size === 0) return;
|
|||
|
|
fmClipboard = {action: 'cut', paths: [...fmSelectedPaths]};
|
|||
|
|
renderTable(document.getElementById('fmSearch').value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function pasteFiles() {
|
|||
|
|
if (!fmClipboard || fmClipboard.paths.length === 0) return;
|
|||
|
|
const action = fmClipboard.action;
|
|||
|
|
fmClipboard.paths.forEach(sp => {
|
|||
|
|
const body = 'action=' + (action === 'cut' ? 'move' : 'copy') + '&src=' + encodeURIComponent(sp) + '&dst_dir=' + encodeURIComponent(fmPath);
|
|||
|
|
fetch('file_actions.php', {method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'}, body})
|
|||
|
|
.then(r => r.json()).finally(() => { if (sp === fmClipboard.paths[fmClipboard.paths.length-1]) reloadFm(); });
|
|||
|
|
});
|
|||
|
|
if (action === 'cut') fmClipboard = null;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **步骤 4:Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add web/files.php
|
|||
|
|
git commit -m "feat: 右键菜单 + Ctrl+C/V/X/A/Del/F2/F5 快捷键"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 任务 5:Monaco Editor 集成
|
|||
|
|
|
|||
|
|
**文件:**
|
|||
|
|
- 修改:`web/files.php`
|
|||
|
|
- 无需新建文件(CDN 引入)
|
|||
|
|
|
|||
|
|
- [ ] **步骤 1:加 CDN loader + 编辑器容器**
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<!-- Monaco Editor -->
|
|||
|
|
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
|
|||
|
|
|
|||
|
|
<div class="fm-dialog" id="dlgEditor">
|
|||
|
|
<div class="fm-dialog-body" style="max-width:95vw;width:95vw;height:90vh;padding:0;display:flex;flex-direction:column;">
|
|||
|
|
<div style="display:flex;align-items:center;padding:8px 16px;border-bottom:1px solid var(--border);">
|
|||
|
|
<span id="dlgEditorTitle" style="font-weight:600;flex:1;">编辑文件</span>
|
|||
|
|
<button class="btn btn-sm btn-primary" onclick="saveEditor()" style="margin-right:8px;"><i class="fas fa-save"></i> 保存</button>
|
|||
|
|
<button class="btn btn-sm btn-secondary" onclick="closeDlg('dlgEditor')">关闭</button>
|
|||
|
|
</div>
|
|||
|
|
<div id="fmEditorContainer" style="flex:1;"></div>
|
|||
|
|
<div style="padding:4px 12px;font-size:11px;color:#6b7280;border-top:1px solid var(--border);background:#f9fafb;">
|
|||
|
|
<span id="editorStatus"></span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **步骤 2:编辑器 JS**
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
let monacoEditor = null;
|
|||
|
|
let fmEditPath = '', fmEditEnc = 'UTF-8';
|
|||
|
|
|
|||
|
|
function openEditor(path, name) {
|
|||
|
|
fmEditPath = path;
|
|||
|
|
fetch('file_actions.php?action=read&path=' + encodeURIComponent(path))
|
|||
|
|
.then(r => r.json()).then(d => {
|
|||
|
|
if (!d.ok) { alert(d.error); return; }
|
|||
|
|
fmEditEnc = d.encoding || 'UTF-8';
|
|||
|
|
document.getElementById('dlgEditorTitle').textContent = '编辑: ' + name;
|
|||
|
|
openDlg('dlgEditor');
|
|||
|
|
initMonaco(d.content || '');
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function initMonaco(content) {
|
|||
|
|
if (monacoEditor) { monacoEditor.setValue(content); return; }
|
|||
|
|
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
|
|||
|
|
require(['vs/editor/editor.main'], function() {
|
|||
|
|
monaco.editor.defineTheme('multisync', {
|
|||
|
|
base: 'vs', inherit: true,
|
|||
|
|
rules: [], colors: { 'editor.background': '#fafbfc' }
|
|||
|
|
});
|
|||
|
|
monacoEditor = monaco.editor.create(document.getElementById('fmEditorContainer'), {
|
|||
|
|
value: content, language: detectLang(fmEditPath), theme: 'multisync',
|
|||
|
|
fontSize: 13, minimap: { enabled: false }, automaticLayout: true,
|
|||
|
|
scrollBeyondLastLine: false, wordWrap: 'on',
|
|||
|
|
});
|
|||
|
|
monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveEditor);
|
|||
|
|
monacoEditor.onDidChangeCursorPosition(e => {
|
|||
|
|
document.getElementById('editorStatus').textContent =
|
|||
|
|
'行 ' + (e.position.lineNumber) + ', 列 ' + (e.position.column) + ' | ' + fmEditEnc;
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function detectLang(fp) {
|
|||
|
|
const ext = (fp||'').split('.').pop().toLowerCase();
|
|||
|
|
const map = { py:'python', js:'javascript', ts:'typescript', html:'html', css:'css',
|
|||
|
|
php:'php', json:'json', yml:'yaml', yaml:'yaml', xml:'xml', md:'markdown',
|
|||
|
|
sh:'shell', bash:'shell', sql:'sql', ini:'ini', conf:'ini', env:'ini',
|
|||
|
|
java:'java', c:'c', cpp:'cpp', h:'c', go:'go', rs:'rust', rb:'ruby', lua:'lua', toml:'ini' };
|
|||
|
|
return map[ext] || 'plaintext';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveEditor() {
|
|||
|
|
if (!monacoEditor || !fmEditPath) return;
|
|||
|
|
const content = monacoEditor.getValue();
|
|||
|
|
const body = 'action=write&path='+encodeURIComponent(fmEditPath)
|
|||
|
|
+'&encoding='+encodeURIComponent(fmEditEnc)+'&content='+encodeURIComponent(content);
|
|||
|
|
fetch('file_actions.php', {method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'}, body})
|
|||
|
|
.then(r => r.json()).then(d => { closeDlg('dlgEditor'); reloadFm(); });
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **步骤 3:Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add web/files.php
|
|||
|
|
git commit -m "feat: Monaco Editor 集成 + Ctrl+S 保存"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 任务 6:拖拽上传 + 状态栏上传进度
|
|||
|
|
|
|||
|
|
**文件:**
|
|||
|
|
- 修改:`web/files.php`
|
|||
|
|
|
|||
|
|
- [ ] **步骤 1:拖拽上传 JS**
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
function onDrop(e) {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const files = e.dataTransfer.files;
|
|||
|
|
for (const f of files) {
|
|||
|
|
const fd = new FormData();
|
|||
|
|
fd.append('action', 'upload');
|
|||
|
|
fd.append('dir', fmPath);
|
|||
|
|
fd.append('file', f);
|
|||
|
|
fetch('file_actions.php', {method:'POST', body: fd})
|
|||
|
|
.then(r => r.json())
|
|||
|
|
.finally(() => { if (f === files[files.length-1]) reloadFm(); });
|
|||
|
|
}
|
|||
|
|
document.getElementById('fmStatusRight').textContent = '上传中...';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function doUpload(input) {
|
|||
|
|
const files = input.files;
|
|||
|
|
let done = 0;
|
|||
|
|
for (const f of files) {
|
|||
|
|
const fd = new FormData();
|
|||
|
|
fd.append('action', 'upload');
|
|||
|
|
fd.append('dir', fmPath);
|
|||
|
|
fd.append('file', f);
|
|||
|
|
fetch('file_actions.php', {method:'POST', body: fd})
|
|||
|
|
.then(r => r.json())
|
|||
|
|
.finally(() => { done++; if (done === files.length) { input.value = ''; reloadFm(); } });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **步骤 2:Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add web/files.php
|
|||
|
|
git commit -m "feat: 拖拽上传 + 多文件上传"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 任务 7:文件操作对话框精简 + 压缩解压完善
|
|||
|
|
|
|||
|
|
**文件:**
|
|||
|
|
- 修改:`web/file_actions.php` — 加解压进度
|
|||
|
|
- 修改:`web/files.php` — 简化对话框
|
|||
|
|
|
|||
|
|
- [ ] **步骤 1:file_actions.php 解压支持更多格式**
|
|||
|
|
|
|||
|
|
在 `extract` action 中,已有 zip/tar.gz 支持,加强错误信息输出。
|
|||
|
|
|
|||
|
|
- [ ] **步骤 2:files.php 单元格内重命名 + 确认删除**
|
|||
|
|
|
|||
|
|
简化为只保留 3 个对话框:输入(新建/重命名)、确认(删除)、编辑器。去掉单独的权限对话框,改为右键菜单入口。
|
|||
|
|
|
|||
|
|
- [ ] **步骤 3:Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add web/file_actions.php web/files.php
|
|||
|
|
git commit -m "feat: 解压完善 + 对话框精简"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 任务 8:端到端验证
|
|||
|
|
|
|||
|
|
- [ ] **步骤 1:编译检查 Python**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
python -m py_compile server/api/servers.py
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **步骤 2:部署到服务器**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git push origin master
|
|||
|
|
ssh root@<SERVER_IP> "cd ${NEXUS_DEPLOY_PATH} && git pull origin master"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **步骤 3:浏览器验证**
|
|||
|
|
|
|||
|
|
打开 `https://${NEXUS_DOMAIN}/files.html`
|
|||
|
|
- 左侧目录树可展开/折叠
|
|||
|
|
- 右键菜单弹出
|
|||
|
|
- Ctrl+C/V/X/A 操作
|
|||
|
|
- 双击文件打开 Monaco Editor
|
|||
|
|
- 拖拽文件上传
|
|||
|
|
- 压缩/解压
|
|||
|
|
|
|||
|
|
- [ ] **步骤 4:Commit 所有验证通过**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git commit -m "chore: v2 端到端验证通过" --allow-empty
|
|||
|
|
```
|