24 KiB
文件管理器 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.pySFTP 端点实现。功能已实现,保留本文档供历史参考。 实现计划
面向 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
任务 1:browse_dirs.php 支持目录树懒加载
文件:
-
修改:
web/browse_dirs.php -
验证:
curl "http://localhost/browse_dirs.php?path=/www/wwwroot&tree=1" -
步骤 1:加 tree 参数,只返回目录
在现有代码中加 action=tree 分支:
// 目录树 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:验证
curl -s "http://127.0.0.1/browse_dirs.php?path=/www/wwwroot&tree=1" | python3 -m json.tool | head -20
预期:只返回目录列表,含 hasSub 字段
- 步骤 3:Commit
git add web/browse_dirs.php
git commit -m "feat: browse_dirs 支持 tree 参数返回目录树"
任务 2:重写 files.php 布局——目录树 + 文件列表
文件:
-
修改:
web/files.php(完全重写) -
步骤 1:写新布局 HTML
两栏布局:左侧目录树 #fmTree(200px),右侧表格 #fmTable。顶部工具栏。
<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 样式
.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
git add web/files.php
git commit -m "feat: files.php v2 布局——目录树+文件列表+状态栏"
任务 3:文件列表渲染 + 导航逻辑
文件:
-
修改:
web/files.php -
步骤 1:全局状态 + 加载函数
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:渲染文件表格 + 排序
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:目录树渲染
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
git add web/files.php
git commit -m "feat: 文件列表渲染+排序+搜索+目录树懒加载"
任务 4:键盘快捷键 + 右键菜单
文件:
-
修改:
web/files.php -
步骤 1:右键菜单 DOM
<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
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:键盘快捷键
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
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 + 编辑器容器
<!-- 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
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
git add web/files.php
git commit -m "feat: Monaco Editor 集成 + Ctrl+S 保存"
任务 6:拖拽上传 + 状态栏上传进度
文件:
-
修改:
web/files.php -
步骤 1:拖拽上传 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
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
git add web/file_actions.php web/files.php
git commit -m "feat: 解压完善 + 对话框精简"
任务 8:端到端验证
- 步骤 1:编译检查 Python
python -m py_compile server/api/servers.py
- 步骤 2:部署到服务器
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 所有验证通过
git commit -m "chore: v2 端到端验证通过" --allow-empty