# 文件管理器 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
```
