feat(files): 新增独立文件管理页面 / add dedicated file management page

This commit is contained in:
gdz66601 committed 2026-09-23 01:01:19 +08:00
1 parent 8505da5262
commit 574643a2e9
15 files changed
+760 -11

No files matched your search

+6
View File
@@ -59,6 +59,12 @@ jobs:
- name: Check project
run: npm run check
# 在模拟协议上回归共享文件组件,防止页面改动破坏终端侧栏或误操作真实主机。
- name: Check file manager in desktop and mobile browsers
run: |
npx playwright install --with-deps chromium
npm run test:browser -- --workers=2
- name: Configure selected login, provision D1, migrate and deploy Worker
run: npm run deploy
env:
+2
View File
@@ -12,3 +12,5 @@ deliverables/
frontend/dist-*
scripts-tmp/
.wrangler.generated.toml
test-results/
playwright-report/
+2
View File
@@ -134,3 +134,5 @@ Cloudflare 模式仍可用 `ACCESS_IDP_IDS` 为新 Access 应用选择现成 IdP
- 登录后 `/api/auth/me` 与主机列表可用;无 JWT、伪造/过期 JWT 不可访问主机、票据或 WebSocket。
- 加密资料跨部署保持可解密;测试不得清空生产 D1。
- 真实 SSH、SFTP 与进程面板必须使用已获授权目标;未提供目标和登录会话时不声称验收完成。
- 文件管理浏览器回归:`npx playwright install chromium` 后运行 `npm run test:browser`,覆盖桌面/375px 手机的入口、目录导航、上传/下载字节、新建/重命名/删除、主机切换、指纹确认与传输期间导航。测试只模拟 API 和 WebSocket,不替代真实服务器验收。
- 线上登录后,从左侧「文件管理」选择授权主机,在专用临时目录验证上传、下载、新建、重命名与删除空目录,再切换到终端确认同一会话仍可用。不得用既有生产文件验证删除或覆盖。
+8 -1
View File
@@ -52,7 +52,7 @@
</td>
<td valign="top">
<h3>📁 SFTP 文件管理</h3>
<p>在终端旁浏览远程目录,上传与下载文件、新建目录、重命名,以及删除文件或空目录。</p>
<p>从左侧「文件管理」进入专用页面,选择已保存主机,浏览目录、上传与下载、新建文件夹、重命名、删除文件或空目录;与终端侧栏共用同一文件组件与 SSH 会话。</p>
</td>
</tr>
<tr>
@@ -186,6 +186,7 @@ npm run dev
| `npm run build:web` | 构建前端到 `dist/` |
| `npm run typecheck` | 检查 Worker 与前端类型 |
| `npm test` | 运行测试 |
| `npm run test:browser` | 文件管理浏览器回归(首次运行前执行 `npx playwright install chromium`) |
| `npm run check` | 类型检查、测试与部署 dry-run(包含前端构建) |
| `npm run deploy` | 构建前端并部署 Worker |
@@ -228,6 +229,12 @@ EdgeSSH/
暂不支持加密私钥、PEM/PKCS#8 私钥、SSH Agent、多因素键盘交互认证、SCP、端口转发、ProxyJump、SSH 压缩与会话内 rekey;不支持出站 TCP 25 端口。
### 独立文件管理
左侧点击 **文件管理**,选择云端保存的主机并连接。首次连接或指纹变化时仍须确认主机身份。双击目录进入,或在路径栏输入绝对路径后按 Enter;选中文件后可下载、重命名、删除,也可上传文件和新建文件夹。
文件页继续使用原有完整 SSH 会话,不要求服务器提供独立的 SFTP-only 入口;不会执行主机配置中的初始命令。点击 **打开 SSH 终端** 可继续使用同一会话,文件传输不会因视图切换而中断。切换主机前先断开连接;离开到总览会关闭会话,正在传输时会要求确认。
## 参与贡献
欢迎通过 Issue 反馈问题或通过 Pull Request 提交改进。提交前请运行 `npm run check`;涉及连接、文件传输或进程面板的改动,请使用真实且已获授权的 SSH 目标验证。
+1 -1
View File
@@ -480,7 +480,7 @@
<span class="file-action-separator" aria-hidden="true"></span>
<button id="file-upload" class="file-action file-action-wide" type="button" disabled data-i18n-zh="上传" data-i18n-en="Upload">上传</button>
<button id="file-download" class="file-action file-action-wide" type="button" disabled data-i18n-zh="下载" data-i18n-en="Download">下载</button>
<button id="file-mkdir" class="file-action file-action-wide" type="button" disabled data-i18n-zh="新建" data-i18n-en="New folder">新建</button>
<button id="file-mkdir" class="file-action file-action-wide" type="button" disabled data-i18n-zh="新建文件夹" data-i18n-en="New folder">新建文件夹</button>
<button id="file-rename" class="file-action file-action-wide" type="button" disabled data-i18n-zh="重命名" data-i18n-en="Rename">重命名</button>
<button id="file-delete" class="file-action file-action-wide danger" type="button" disabled data-i18n-zh="删除" data-i18n-en="Delete">删除</button>
<input id="file-upload-input" class="sr-only" type="file" tabindex="-1" />
+47 -4
View File
@@ -1,10 +1,12 @@
import { api, saveHost, removeHost, refreshHostLocation, type CloudHost, type HostInput } from './cloud-api';
import type { HostGlobe } from './globe';
import type { FilePage } from './file-page';
import { countryFlag } from './flags';
import { systemIcon } from './os-icons';
import './dashboard.css';
interface DashboardActions {
files: FilePage;
refresh(): Promise<CloudHost[]>;
connect(host: CloudHost): Promise<void>;
quickConnect(): void;
@@ -15,6 +17,7 @@ const icons = {
home: '<path d="m3 10 9-7 9 7v10H3Z"/><path d="M9 20v-7h6v7"/>',
server: '<rect x="4" y="3" width="16" height="7" rx="2"/><rect x="4" y="14" width="16" height="7" rx="2"/><path d="M8 6.5h.01M8 17.5h.01M15 6.5h2M15 17.5h2"/>',
terminal: '<path d="m5 6 6 6-6 6m8 0h6"/>',
folder: '<path d="M3 7V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M3 9h18"/>',
shield: '<path d="m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6Z"/><path d="m8 12 3 3 5-6"/>',
search: '<circle cx="10" cy="10" r="6"/><path d="m15 15 5 5"/>',
refresh: '<path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 4v7h-7"/>',
@@ -46,8 +49,9 @@ export class Dashboard {
</header>
<div class="home-layout">
<nav class="home-rail" aria-label="主导航">
<button class="rail-item selected" aria-current="page">${icon('home')}<span>总览</span></button>
<button class="rail-item selected" id="rail-overview" aria-current="page">${icon('home')}<span>总览</span></button>
<button class="rail-item" id="rail-hosts">${icon('server')}<span>主机</span></button>
<button class="rail-item" id="rail-files">${icon('folder')}<span>文件管理</span></button>
<button class="rail-item" id="quick-connect">${icon('terminal')}<span>快速连接</span></button>
<span class="rail-security" title="管理员身份认证">${icon('shield')}<span id="auth-provider-label">身份<br>保护</span></span>
</nav>
@@ -100,6 +104,7 @@ export class Dashboard {
</form>
</dialog>`;
document.body.prepend(this.root);
this.get('.home-layout').append(this.actions.files.root);
this.dialog = this.get<HTMLDialogElement>('.host-dialog');
this.form = this.get<HTMLFormElement>('#cloud-host-form');
this.get('#account-action').addEventListener('click', async (event) => {
@@ -117,9 +122,16 @@ export class Dashboard {
this.dialog.addEventListener('close', () => { this.form.reset(); this.editing = undefined; this.returnFocus?.focus(); });
this.get('#host-search').addEventListener('input', () => this.renderList());
this.get('#refresh-hosts').addEventListener('click', () => void this.refresh());
this.get('#rail-hosts').addEventListener('click', () => this.get<HTMLInputElement>('#host-search').focus());
for (const id of ['#rail-overview', '#rail-hosts']) this.get(id).addEventListener('click', () => {
if (!this.actions.files.confirmLeave()) return;
if (!this.isHome) this.actions.leaveWorkspace();
this.show();
if (id === '#rail-hosts') this.get<HTMLInputElement>('#host-search').focus();
});
this.get('#rail-files').addEventListener('click', () => this.showFiles());
for (const id of ['#quick-connect', '#bottom-quick']) this.get(id).addEventListener('click', () => {
if (this.busy) return;
if (this.busy || !this.actions.files.confirmLeave()) return;
this.actions.leaveWorkspace();
this.openWorkspace(); this.actions.quickConnect();
});
this.field('authMethod').addEventListener('change', () => this.updateCredentialFields());
@@ -137,8 +149,15 @@ export class Dashboard {
});
const back = document.createElement('button');
back.className = 'icon-button home-back'; back.textContent = '← 主机总览'; back.type = 'button';
back.addEventListener('click', () => { this.actions.leaveWorkspace(); this.show(); void this.refresh(); });
back.addEventListener('click', () => {
if (!this.actions.files.confirmLeave()) return;
this.actions.leaveWorkspace(); this.show(); void this.refresh();
});
document.querySelector('#app .topbar-actions')!.prepend(back);
const files = document.createElement('button');
files.className = 'icon-button home-back'; files.textContent = '文件管理'; files.type = 'button';
files.addEventListener('click', () => this.showFiles());
document.querySelector('#app .topbar-actions')!.prepend(files);
this.show();
}
@@ -171,6 +190,7 @@ export class Dashboard {
setHosts(hosts: CloudHost[]): void {
this.hosts = hosts;
this.actions.files.setHosts(hosts);
if (this.group && !hosts.some((host) => host.group === this.group)) this.group = '';
this.renderFilters(); this.renderList();
this.globe?.setHosts(hosts);
@@ -187,13 +207,17 @@ export class Dashboard {
}
show(): void {
this.actions.files.hide();
this.isHome = true; this.root.hidden = false;
this.get('.home-content').hidden = false;
this.selectNavigation('rail-overview');
document.getElementById('app')!.hidden = true;
document.body.dataset.view = 'dashboard';
this.globe?.setActive(!this.paused);
}
openWorkspace(): void {
this.actions.files.hide();
this.isHome = false; this.root.hidden = true;
document.getElementById('app')!.hidden = false;
document.body.dataset.view = 'workspace';
@@ -201,11 +225,30 @@ export class Dashboard {
window.dispatchEvent(new Event('resize'));
}
showFiles(): void {
this.isHome = false; this.root.hidden = false;
this.get('.home-content').hidden = true;
document.getElementById('app')!.hidden = true;
document.body.dataset.view = 'files';
this.selectNavigation('rail-files');
this.globe?.setActive(false);
this.actions.files.show();
}
private selectNavigation(id: string): void {
this.root.querySelectorAll<HTMLElement>('.rail-item').forEach((button) => {
button.classList.toggle('selected', button.id === id);
if (button.id === id) button.setAttribute('aria-current', 'page');
else button.removeAttribute('aria-current');
});
}
private notice(message: string): void {
const notice = this.get('#home-notice'); notice.textContent = message; notice.hidden = false;
}
private signedOut(): void {
this.actions.leaveWorkspace();
this.authenticated = false;
this.get('#account-label').textContent = '未登录';
const action = this.get<HTMLAnchorElement>('#account-action');
+2 -1
View File
@@ -1180,7 +1180,8 @@ export class FileManager {
this.elements.path.disabled = !this.ready;
this.elements.back.disabled = !this.ready || this.historyIndex <= 0;
this.elements.up.disabled = !this.ready || this.cwd === '/';
this.elements.home.disabled = !this.ready || this.cwd === this.homePath;
// 列目录失败时 cwd 仍是上次成功路径,允许用主目录按钮恢复,而不把错误页锁在原处。
this.elements.home.disabled = !this.ready || (this.cwd === this.homePath && this.elements.error.hidden);
this.elements.refresh.disabled = !this.ready;
this.elements.upload.disabled = !idle;
this.elements.mkdir.disabled = !idle;
+115
View File
@@ -0,0 +1,115 @@
/* 文件页沿用总览的白橙主题;局部映射共享面板令牌,不改变终端侧栏的紧凑布局。 */
body[data-view='files'] { height: auto; min-height: 100%; overflow-y: auto; background: var(--home-bg); }
.files-page {
--surface: var(--home-surface);
--surface-2: var(--home-bg);
--surface-3: var(--home-line);
--panel: var(--home-surface);
--border: var(--home-line);
--border-soft: var(--home-line);
--text: var(--home-ink);
--muted: var(--home-muted);
--muted-2: var(--home-muted);
--accent: var(--home-accent-hover);
--accent-dim: var(--home-accent-soft);
--danger: var(--home-danger);
--danger-dim: var(--home-accent-soft);
--warning: var(--home-accent-hover);
--sans: var(--home-font);
--mono: var(--home-mono);
min-width: 0; width: 100%; max-width: 1700px; margin-inline: auto;
padding: 32px; display: flex; flex-direction: column; gap: 20px;
}
.files-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.files-heading h1 { margin: 0; font-size: 29px; line-height: 1.3; letter-spacing: -.8px; }
.files-heading p:last-child { margin: 8px 0 0; color: var(--home-muted); }
.files-connection { display: flex; align-items: center; gap: 16px; padding: 18px 20px; border: 1px solid var(--home-line); border-radius: 10px; background: var(--home-surface); }
.files-host-icon { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 9px; background: var(--home-accent-soft); color: var(--home-accent-hover); flex-shrink: 0; }
.files-host-field { display: flex; flex-direction: column; gap: 6px; flex: 1; max-width: 510px; min-width: 0; color: var(--home-muted); font-size: 12px; }
#dashboard .files-host-field select { width: 100%; min-width: 0; height: 42px; padding: 0 32px 0 12px; color: var(--home-ink); background: var(--home-bg); border: 1px solid var(--home-line); border-radius: 6px; font: 13px var(--home-mono); }
#files-connect { margin-top: 24px; min-width: 104px; }
.files-connection-meta { margin-left: auto; display: flex; flex-direction: column; align-items: flex-end; gap: 8px; font-size: 12px; color: var(--home-muted); text-align: right; }
.files-state { display: inline-flex; align-items: center; gap: 7px; color: var(--home-muted); font-weight: 600; }
.files-state::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.files-state[data-state='connected'] { color: var(--home-success); }
.files-state[data-state='error'] { color: var(--home-danger); }
.files-state[data-state='connecting'] { color: var(--home-accent-hover); }
.files-notice { margin: 0; padding: 10px 14px; border-left: 3px solid var(--home-accent); background: var(--home-accent-soft); color: var(--home-ink); font-size: 13px; overflow-wrap: anywhere; }
.files-notice.error { color: var(--home-danger); border-color: var(--home-danger); }
.files-browser { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--home-line); border-radius: 10px; overflow: hidden; background: var(--home-surface); }
.files-browser-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 20px; border-bottom: 1px solid var(--home-line); }
.files-browser-heading strong { display: flex; align-items: center; gap: 10px; font-size: 14px; font-weight: 600; }
.files-browser-heading strong svg { color: var(--home-accent-hover); }
.files-browser-heading > span { color: var(--home-muted); font: 11px var(--home-mono); }
.files-panel-slot { display: grid; min-width: 0; height: clamp(390px, calc(100dvh - 490px), 850px); }
.files-page .file-manager { grid-template-rows: auto minmax(0, 1fr) auto; }
.files-page .file-toolbar { flex-wrap: wrap; gap: 12px; padding: 14px 18px; }
.files-page .file-path-field { flex-basis: 100%; }
.files-page .file-path-field input { height: 40px; font-size: 13px; padding-inline: 12px; }
.files-page .file-actions { flex: 1; flex-wrap: wrap; gap: 6px; overflow: visible; }
#dashboard .files-page .file-action { min-width: 38px; height: 38px; border: 1px solid var(--home-line); background: var(--home-surface); font-size: 14px; }
#dashboard .files-page .file-action-wide { padding-inline: 14px; font-size: 13px; font-weight: 500; }
#dashboard .files-page #file-upload { background: var(--home-accent-hover); color: var(--home-surface); border-color: var(--home-accent-hover); }
#dashboard .files-page #file-delete { color: var(--home-danger); margin-left: auto; }
#dashboard .files-page button:disabled { cursor: not-allowed; }
.files-page .file-tree { background: var(--home-bg); }
.files-page .file-tree-header { height: 42px; padding-inline: 14px; background: var(--home-bg); font-size: 12px; text-transform: none; }
#dashboard .files-page .file-tree-collapse { width: 30px; height: 30px; font-size: 14px; }
.files-page .file-manager:has(> .file-tree.is-collapsed) { grid-template-columns: 40px minmax(0, 1fr); }
.files-page .file-tree-scroller { padding-block: 8px; }
.files-page .file-tree-row { min-height: 34px; gap: 8px; font-size: 12px; }
.files-page .file-tree-icon { font-size: 14px; }
.files-page .file-table { font-size: 12px; }
.files-page .file-table th { height: 42px; padding-inline: 12px; font-size: 12px; font-weight: 500; border-right: 0; }
.files-page .file-table td { height: 42px; padding-inline: 12px; }
.files-page .file-table th:first-child { width: 32%; }
.files-page .file-table th:nth-child(6), .files-page .file-table th:nth-child(7) { width: 8.5%; }
.files-page .file-kind-icon { width: 20px; flex-basis: 20px; font-size: 16px; }
.files-page .file-manager-state { font-size: 14px; line-height: 1.8; }
.files-page .file-statusbar { min-height: 42px; padding: 8px 16px; font-size: 11px; flex-wrap: wrap; }
.files-page .file-progress { max-width: 100%; }
#dashboard .files-page .file-progress-cancel { height: 30px; color: var(--home-ink); background: var(--home-bg); }
.files-footer { display: flex; justify-content: space-between; gap: 12px; color: var(--home-muted); font-size: 12px; }
body[data-view='files'] #dashboard .home-search, body[data-view='files'] #dashboard .header-add { display: none; }
body[data-view='files'] .home-account { margin-left: auto; }
@media (max-width: 1100px) {
.files-page { padding: 24px; }
.files-connection { flex-wrap: wrap; }
.files-connection-meta { flex-basis: 100%; flex-direction: row; align-items: center; justify-content: space-between; }
.files-page .file-manager { grid-template-columns: 180px minmax(0, 1fr); }
.files-page .file-table :is(th, td):nth-child(n+5) { display: none; }
}
@media (max-width: 600px) {
.files-page { padding: 20px 12px; gap: 16px; }
.files-heading { align-items: flex-start; }
.files-heading h1 { font-size: 25px; }
.files-heading p:last-child { font-size: 12px; }
#dashboard #files-terminal { min-height: 44px; padding-inline: 10px; font-size: 12px; }
.files-connection { padding: 14px; gap: 12px; }
.files-host-icon { display: none; }
.files-host-field { flex-basis: calc(100% - 116px); }
#dashboard .files-host-field select { height: 44px; font-size: 16px; }
#files-connect { min-height: 44px; }
.files-connection-meta { align-items: flex-start; text-align: left; font-size: 11px; }
.files-connection-meta > span:last-child { max-width: 65%; }
.files-browser-heading { padding: 14px 12px; }
.files-browser-heading > span { font-size: 10px; }
.files-page .file-toolbar { padding: 12px; }
.files-page .file-path-field input { font-size: 16px; height: 44px; }
#dashboard .files-page .file-action { min-width: 44px; height: 44px; }
#dashboard .files-page .file-action-wide { padding-inline: 10px; }
.files-page .file-action-separator { display: none; }
.files-page .file-manager { grid-template-columns: minmax(0, 1fr); grid-template-areas: "toolbar" "table" "status"; }
.files-page .file-manager > .file-tree { display: none; }
.files-page .file-manager:has(> .file-tree.is-collapsed) { grid-template-columns: minmax(0, 1fr); }
.files-page .file-table th:first-child { width: 48%; }
.files-page .file-table th:nth-child(2) { width: 20%; }
.files-page .file-table th:nth-child(4) { width: 32%; }
.files-page .file-table :is(th, td):nth-child(3) { display: none; }
.files-page .file-table td { height: 48px; }
.files-panel-slot { height: 560px; }
.files-footer { flex-direction: column; gap: 6px; }
#dashboard .home-rail { gap: 4px; padding-inline: 8px; justify-content: space-between; }
#dashboard .rail-item { padding-inline: 8px; font-size: 11px; }
#dashboard .rail-item svg { width: 18px; height: 18px; }
}
+166
View File
@@ -0,0 +1,166 @@
import type { CloudHost } from './cloud-api';
import './file-page.css';
export type FileConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnecting' | 'error';
interface FilePageActions {
connect(host: CloudHost): Promise<void>;
disconnect(): void;
openTerminal(): void;
}
const folderIcon = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M3 9h18"/></svg>';
/** 页面只负责主机选择和布局;移动同一个面板,避免为同一 SSH 会话重复附着 SFTP。 */
export class FilePage {
readonly root = document.createElement('main');
private readonly dockMarker = document.createComment('shared-file-manager');
private readonly select: HTMLSelectElement;
private readonly connectButton: HTMLButtonElement;
private hosts: CloudHost[] = [];
private state: FileConnectionState = 'idle';
private preparing = false;
private mounted = false;
private activeTarget: { id?: string; label: string } | null = null;
constructor(private readonly panel: HTMLElement, private readonly actions: FilePageActions) {
panel.before(this.dockMarker);
this.root.className = 'files-page';
this.root.hidden = true;
this.root.innerHTML = `
<header class="files-heading">
<div><p class="home-eyebrow">REMOTE FILES</p><h1 id="files-heading" tabindex="-1">文件管理</h1>
<p>在服务器之间切换,让每一份文件井然有序。</p></div>
<button class="home-button" id="files-terminal" type="button">打开 SSH 终端 ↗</button>
</header>
<section class="files-connection" aria-label="文件管理连接">
<span class="files-host-icon">${folderIcon}</span>
<label class="files-host-field" for="files-host"><span>目标主机</span>
<select id="files-host" aria-describedby="files-host-hint"><option value="">请选择主机</option></select>
</label>
<button id="files-connect" class="home-button primary" type="button" disabled>连接主机</button>
<div class="files-connection-meta"><span id="files-connection-state" class="files-state" data-state="idle" role="status">未连接</span>
<span id="files-host-hint">使用已保存的 SSH 凭据安全连接</span></div>
</section>
<p id="files-notice" class="files-notice" role="status" hidden></p>
<section class="files-browser" aria-label="远程文件">
<div class="files-browser-heading"><strong>${folderIcon}远程目录</strong><span>SFTP · 加密传输</span></div>
<div class="files-panel-slot"></div>
</section>
<footer class="files-footer"><span>双击目录进入 · 选中文件后操作 · 路径栏按 Enter 跳转</span>
<span>单文件 ≤ 64 MiB · 仅支持删除空目录</span></footer>`;
this.select = this.get<HTMLSelectElement>('#files-host');
this.connectButton = this.get<HTMLButtonElement>('#files-connect');
this.select.addEventListener('change', () => this.renderConnection());
this.connectButton.addEventListener('click', () => void this.toggleConnection());
this.get('#files-terminal').addEventListener('click', () => this.actions.openTerminal());
}
private get<T extends HTMLElement = HTMLElement>(selector: string): T {
return this.root.querySelector<T>(selector)!;
}
show(): void {
this.mounted = true;
this.root.hidden = false;
this.get('.files-panel-slot').append(this.panel);
this.panel.hidden = false;
this.panel.setAttribute('role', 'region');
this.panel.setAttribute('aria-labelledby', 'files-heading');
this.get('#files-heading').focus();
}
hide(): void {
if (!this.mounted) return;
this.mounted = false;
this.root.hidden = true;
this.dockMarker.after(this.panel);
this.panel.setAttribute('role', 'tabpanel');
this.panel.setAttribute('aria-labelledby', 'file-manager-tab');
this.panel.hidden = document.getElementById('file-manager-tab')!.getAttribute('aria-selected') !== 'true';
}
setHosts(hosts: CloudHost[]): void {
const selected = this.select.value;
this.hosts = hosts;
this.select.replaceChildren(new Option(hosts.length ? '请选择主机' : '暂无主机,请先在总览添加', ''));
for (const host of hosts) {
this.select.add(new Option(`${host.name} · ${host.username}@${host.host}:${host.port}`, host.id));
}
this.select.value = hosts.some((host) => host.id === selected) ? selected : '';
this.selectActiveTarget();
this.renderConnection();
}
setConnection(state: FileConnectionState, hostId?: string, target = ''): void {
this.state = state;
this.activeTarget = state === 'connecting' || state === 'connected' ? { id: hostId, label: target } : null;
this.selectActiveTarget();
this.renderConnection();
}
private selectActiveTarget(): void {
this.select.querySelector('[data-current-session]')?.remove();
if (!this.activeTarget) return;
if (this.hosts.some((host) => host.id === this.activeTarget!.id)) {
this.select.value = this.activeTarget.id!;
} else {
// 临时连接也能进入文件页,但不能把旧的下拉选项误标成当前服务器。
const option = new Option(`当前会话 · ${this.activeTarget.label}`, '');
option.dataset.currentSession = 'true';
this.select.add(option);
option.selected = true;
}
}
setMessage(message: string, error = false): void {
const notice = this.get('#files-notice');
notice.textContent = message;
notice.classList.toggle('error', error);
notice.hidden = !message;
}
private renderConnection(): void {
const active = this.state === 'connecting' || this.state === 'connected';
const busy = this.preparing || this.state === 'disconnecting';
this.select.disabled = active || busy || !this.hosts.length;
this.connectButton.disabled = busy || (!active && !this.select.value);
this.get<HTMLButtonElement>('#files-terminal').disabled = this.preparing;
this.connectButton.classList.toggle('primary', !active);
this.connectButton.textContent = this.preparing ? '读取凭据中…'
: this.state === 'connecting' ? '取消连接'
: this.state === 'connected' ? '断开连接'
: this.state === 'disconnecting' ? '正在断开…' : '连接主机';
const labels: Record<FileConnectionState, string> = {
idle: '未连接', connecting: '正在连接', connected: 'SSH 已连接', disconnecting: '正在断开', error: '连接失败',
};
const badge = this.get('#files-connection-state');
badge.textContent = labels[this.state];
badge.dataset.state = this.state;
this.get('#files-host-hint').textContent = active
? '与终端共用会话 · 切换主机前请先断开'
: '使用已保存的 SSH 凭据安全连接';
}
private async toggleConnection(): Promise<void> {
if (this.state === 'connecting' || this.state === 'connected') {
if (!this.confirmLeave()) return;
this.actions.disconnect();
return;
}
const host = this.hosts.find((item) => item.id === this.select.value);
if (!host || this.preparing || this.state === 'disconnecting') return;
this.preparing = true;
this.renderConnection();
this.setMessage(`正在读取 ${host.name} 的连接凭据…`);
try { await this.actions.connect(host); }
catch (error) { this.setMessage(error instanceof Error ? error.message : '连接准备失败,请重试。', true); }
finally { this.preparing = false; this.renderConnection(); }
}
confirmLeave(): boolean {
// 只有正在传输时才打断导航,避免用户无意中取消上传或下载。
const progress = this.panel.querySelector<HTMLElement>('#file-manager-progress')!;
return progress.hidden || confirm('文件正在传输,离开或断开连接将取消传输。仍要继续吗?');
}
}
+28 -4
View File
@@ -4,6 +4,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links';
import { historyKey, historyLabel } from './history';
import { listHosts, hostCredentials, saveHost, removeHost, updateHostSystem, type CloudHost, type Credentials, type HostSystemInfo } from './cloud-api';
import { Dashboard } from './dashboard';
import { FilePage } from './file-page';
import { resolveConnectionControl, resolveConnectionPanel } from './ui-state';
import { classifyHostKey, SSH_FINGERPRINT_RE, type HostKeyPrompt } from './host-key';
import { FileManager, collectFileManagerElements } from './file-manager';
@@ -370,6 +371,7 @@ function applyLanguage(language: Language, persist = false): void {
let profiles: SavedProfile[] = [];
let dashboard: Dashboard | undefined;
let filePage: FilePage | undefined;
let hostKeys: Record<string, string> = {};
let socket: WebSocket | null = null;
let connectionState: ConnectionState = 'idle';
@@ -1065,6 +1067,7 @@ function showFormError(message: string, alternate?: string): void {
currentFormError = messageTranslation(message, alternate);
ui.formError.textContent = localize(currentFormError);
ui.formError.hidden = false;
if (document.body.dataset.view === 'files') filePage?.setMessage(localize(currentFormError), true);
}
function toast(message: string, kind: 'info' | 'error' = 'info'): void {
@@ -1079,6 +1082,7 @@ function updateConnectionStatus(message: LocalizedMessage): void {
currentSessionSubtitle = message;
const text = localize(message);
ui.sessionSubtitle.textContent = text;
filePage?.setMessage(text);
if (connectionState === 'connecting') {
const btnSpan = ui.connect.querySelector<HTMLElement>('span:last-child');
if (btnSpan) btnSpan.textContent = text;
@@ -1087,6 +1091,7 @@ function updateConnectionStatus(message: LocalizedMessage): void {
function setState(state: ConnectionState, label?: string): void {
connectionState = state;
filePage?.setConnection(state, profiles.find((profile) => passwordContext(profile) === currentTargetKey)?.id, currentTargetLabel);
const stateLabel = label ?? ({
idle: bilingual('离线', 'Offline'),
connecting: bilingual('连接中', 'Connecting'),
@@ -1213,6 +1218,7 @@ function markReady(message = bilingual('交互式 Shell 已就绪', 'Interactive
});
startTimers();
updateConnectionStatus(messageTranslation(message));
filePage?.setMessage('');
event(message, 'ready');
if (currentInitialCommand && !initialCommandSent) {
initialCommandSent = true;
@@ -1224,7 +1230,7 @@ function markReady(message = bilingual('交互式 Shell 已就绪', 'Interactive
sendTerminalData(`${command}\r`);
}, 120);
}
terminal.focus();
if (document.body.dataset.view === 'workspace') terminal.focus();
}
function sendHostKeyDecision(accept: boolean): void {
@@ -1611,7 +1617,7 @@ async function connect(): Promise<void> {
showFormError(validationError);
return;
}
dashboard?.openWorkspace();
if (document.body.dataset.view !== 'files') dashboard?.openWorkspace();
if (location.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(location.hostname)) {
showFormError(bilingual('发送 SSH 凭据前必须使用 HTTPS。', 'HTTPS is required before SSH credentials can be sent.'));
return;
@@ -1622,7 +1628,6 @@ async function connect(): Promise<void> {
const generation = ++connectGeneration;
const abortController = new AbortController();
authorizationAbort = abortController;
setState('connecting');
ui.terminalEmpty.hidden = true;
resetTerminalForConnection(terminal);
fitTerminal(false);
@@ -1632,11 +1637,13 @@ async function connect(): Promise<void> {
const username = ui.username.value.trim();
currentTargetKey = targetKey(host, port, username);
currentTargetLabel = targetLabel(host, port, username);
setState('connecting');
const pinnedKey = ui.fingerprint.value.trim() || hostKeys[currentTargetKey] || '';
currentExpectedFingerprint = pinnedKey;
currentRememberedFingerprint = '';
if (pinnedKey) ui.fingerprint.value = pinnedKey;
currentInitialCommand = ui.initialCommand.value;
// 文件管理不应在隐藏终端里执行用户的初始命令;保存的主机配置仍保持原样。
currentInitialCommand = document.body.dataset.view === 'files' ? '' : ui.initialCommand.value;
initialCommandSent = false;
pendingHostKey = null;
awaitingHostKeyDecision = false;
@@ -2217,7 +2224,24 @@ async function initialize(): Promise<void> {
initializeCompatibilityAPI();
initMobileToolbar();
applyURLParameters();
// 两个视图共用指纹确认与通知,不能随隐藏的终端容器一起消失。
document.body.append(ui.hostKeyDialog, ui.toastRegion);
filePage = new FilePage(ui.fileManagerPanel, {
connect: async (host) => {
const loading = applyProfile(host);
const selectionGeneration = historyPasswordLoadGeneration;
await loading;
// 返回总览会作废凭据读取;即使用户立刻回到文件页,也不能启动已经取消的连接。
if (selectionGeneration === historyPasswordLoadGeneration && document.body.dataset.view === 'files') await connect();
},
disconnect: () => disconnect(),
openTerminal: () => {
dashboard?.openWorkspace();
if (connectionState === 'idle' || connectionState === 'error') setPanelOpen(true);
},
});
dashboard = new Dashboard({
files: filePage,
refresh: async () => {
profiles = await loadProfiles();
renderProfiles();
+46
View File
@@ -17,6 +17,7 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260727.1",
"@playwright/test": "^1.63.0",
"@types/node": "^22.20.4",
"flag-icons": "^7.5.0",
"smol-toml": "^1.8.0",
@@ -1167,6 +1168,22 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@poppinss/colors": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
@@ -1823,6 +1840,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/postcss/-/postcss-8.5.23.tgz",
+2
View File
@@ -10,6 +10,7 @@
"build:web": "vite build --config vite.config.ts",
"typecheck": "tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.frontend.json --noEmit && tsc -p tsconfig.scripts.json --noEmit",
"test": "node --test --experimental-strip-types tests/*.test.ts",
"test:browser": "playwright test",
"check": "npm run typecheck && npm run test && wrangler deploy --dry-run --outdir .wrangler/build",
"deploy:validate": "node --experimental-strip-types scripts/deploy.ts --validate-only",
"deploy": "node --experimental-strip-types scripts/deploy.ts"
@@ -24,6 +25,7 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260727.1",
"@playwright/test": "^1.63.0",
"@types/node": "^22.20.4",
"flag-icons": "^7.5.0",
"smol-toml": "^1.8.0",
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/browser',
fullyParallel: true,
use: {
channel: process.env.PLAYWRIGHT_CHANNEL,
baseURL: 'http://127.0.0.1:5173',
locale: 'zh-CN',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
projects: [
{ name: 'desktop', use: { viewport: { width: 1440, height: 1000 } } },
{ name: 'mobile', use: { viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true } },
],
webServer: {
command: 'npm run dev:web -- --host 127.0.0.1',
url: 'http://127.0.0.1:5173',
reuseExistingServer: !process.env.CI,
},
});
+113
View File
@@ -0,0 +1,113 @@
import type { Page, WebSocketRoute } from '@playwright/test';
const fingerprint = `SHA256:${'A'.repeat(43)}`;
const host = (id: string, name: string, address: string) => ({
id, name, host: address, port: 22, username: 'root', group: '测试主机',
authMethod: 'password', initialCommand: 'echo should-not-run-in-files',
termType: 'xterm-256color', encoding: 'utf-8', fingerprint,
location: null, system: null, hasCredential: true, updatedAt: Date.now(),
});
const entry = (name: string, type = 'file', size = 12) => ({
name, type, size, mtime: 1_790_100_000, permissions: type === 'directory' ? '0755' : '0644', owner: 'root', group: 'root',
});
// 只在测试浏览器内模拟 API/协议,不访问真实主机、不增加生产认证绕过。
export async function fileFixture(page: Page, options: { firstSeen?: boolean; credentialError?: boolean; noHosts?: boolean; holdUpload?: boolean } = {}) {
const hosts = options.noHosts ? [] : [host('alpha', 'Tokyo production', '192.0.2.10'), host('beta', 'Singapore backup', '192.0.2.20')];
const directories = new Map([
['/', [entry('root', 'directory')]],
['/root', [entry('backups', 'directory'), entry('deploy', 'directory'), entry('README.md'), entry('nginx.conf', 'file', 2480)]],
['/root/backups', []],
['/root/deploy', []],
]);
const calls: Array<Record<string, any>> = [];
const sshSockets: WebSocketRoute[] = [];
const sftpSockets: WebSocketRoute[] = [];
const uploaded: Buffer[] = [];
await page.route('**/api/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (path === '/api/auth/me') return route.fulfill({ json: { account: { username: 'Administrator' }, provider: 'cloudflare' } });
if (path === '/api/session') return route.fulfill({ json: { ticket: 'test-ticket', sessionId: crypto.randomUUID() } });
if (path.endsWith('/credentials')) {
return route.fulfill(options.credentialError
? { status: 500, json: { error: '读取凭据失败,请重试。' } }
: { json: { password: 'test-only-password' } });
}
if (path === '/api/hosts') return route.fulfill({ json: { hosts } });
return route.fulfill({ json: { host: hosts.find((item) => path.includes(item.id)) ?? hosts[0] } });
});
await page.routeWebSocket('**/api/ssh?*', (ws) => {
sshSockets.push(ws);
const ready = () => {
ws.send(JSON.stringify({ type: 'ready' }));
ws.send(JSON.stringify({ type: 'sftp_attach', url: '/api/sftp?session=test&token=test' }));
};
ws.onMessage((raw) => {
if (typeof raw !== 'string') { calls.push({ type: 'terminal-input', data: raw.toString() }); return; }
const message = JSON.parse(raw);
calls.push(message);
if (message.type === 'connect') {
if (options.firstSeen) {
// 返回变化指纹,确保仍需真实 UI 确认,而不是由测试直接放行。
ws.send(JSON.stringify({ type: 'host_key', fingerprint: `SHA256:${'B'.repeat(43)}`, expectedFingerprint: fingerprint, keyType: 'ssh-ed25519', trusted: false }));
} else ready();
}
if (message.type === 'host_key_decision' && message.accept) ready();
});
});
await page.routeWebSocket('**/api/sftp?*', (ws) => {
sftpSockets.push(ws);
let upload: { requestId: string; path: string; size: number } | undefined;
const send = (data: object) => ws.send(JSON.stringify(data));
const parent = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/';
const name = (path: string) => path.slice(path.lastIndexOf('/') + 1);
ws.onMessage((raw) => {
if (typeof raw !== 'string') {
uploaded.push(Buffer.from(raw));
send({ type: 'sftp_upload_progress', requestId: upload!.requestId, loaded: uploaded.reduce((sum, chunk) => sum + chunk.length, 0) });
return;
}
const message = JSON.parse(raw);
calls.push(message);
const { type, requestId, path } = message;
if (type === 'sftp_init') send({ type: 'sftp_ready', cwd: '/root', version: 3 });
if (type === 'sftp_list') {
if (path === '/forbidden') send({ type: 'sftp_error', requestId, message: 'Permission denied' });
else send({ type: 'sftp_list_result', requestId, path, entries: directories.get(path) ?? [] });
}
if (type === 'sftp_mkdir') {
directories.get(parent(path))!.push(entry(name(path), 'directory'));
directories.set(path, []);
}
if (type === 'sftp_rename') {
directories.get(parent(message.oldPath))!.find((item) => item.name === name(message.oldPath))!.name = name(message.newPath);
}
if (type === 'sftp_delete' || type === 'sftp_rmdir') {
directories.set(parent(path), directories.get(parent(path))!.filter((item) => item.name !== name(path)));
}
if (['sftp_mkdir', 'sftp_rename', 'sftp_delete', 'sftp_rmdir'].includes(type)) send({ type: `${type}_result`, requestId });
if (type === 'sftp_upload_start') {
upload = message;
if (!options.holdUpload) send({ type: 'sftp_upload_ready', requestId });
}
if (type === 'sftp_upload_end') {
directories.get(parent(upload!.path))!.push(entry(name(upload!.path), 'file', upload!.size));
send({ type: 'sftp_upload_complete', requestId });
}
if (type === 'sftp_download') {
const data = Buffer.from('hello files\n');
send({ type: 'sftp_download_start', requestId, size: data.length });
ws.send(data);
send({ type: 'sftp_download_done', requestId, size: data.length });
}
});
});
await page.goto('/');
await page.locator('#rail-files').click();
return { hosts, calls, sshSockets, sftpSockets, uploaded };
}
export async function connectFiles(page: Page, id = 'alpha') {
await page.locator('#files-host').selectOption(id);
await page.locator('#files-connect').click();
}
+200
View File
@@ -0,0 +1,200 @@
import { test, expect } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import { connectFiles, fileFixture } from './file-fixture';
test('独立入口、空状态与移动端布局', async ({ page }, testInfo) => {
await fileFixture(page, { noHosts: true });
await expect(page.locator('#files-heading')).toBeVisible();
await expect(page.locator('#rail-files')).toHaveAttribute('aria-current', 'page');
await expect(page.locator('#files-connect')).toBeDisabled();
await expect(page.locator('#file-upload')).toBeDisabled();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: testInfo.outputPath('files-empty.png'), fullPage: true });
await page.locator('#rail-overview').click();
await expect(page.locator('#hosts-heading')).toBeVisible();
});
test('连接、目录导航与共享终端会话不重复附着', async ({ page }, testInfo) => {
const fixture = await fileFixture(page);
await connectFiles(page);
await expect(page.locator('#file-manager-path')).toHaveValue('/root');
await expect(page.locator('#file-table-body')).toContainText('README.md');
await expect(page.locator('#files-host')).toBeDisabled();
await expect(page.locator('#files-connection-state')).toHaveText('SSH 已连接');
await expect(page.locator('.header-add')).toBeHidden();
await page.locator('.files-footer').scrollIntoViewIfNeeded();
await expect(page.locator('.files-footer')).toBeInViewport();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await expect(page.locator('#toast-region .toast')).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath('files-connected.png'), fullPage: true });
await page.locator('#file-table-body tr').filter({ hasText: 'backups' }).dblclick();
await expect(page.locator('#file-manager-path')).toHaveValue('/root/backups');
await page.locator('#file-up').click();
await expect(page.locator('#file-manager-path')).toHaveValue('/root');
await page.locator('#files-terminal').click();
await expect(page.locator('#terminal-card')).toBeVisible();
await page.locator('#file-manager-tab').click();
await expect(page.locator('#app #file-manager-panel')).toBeVisible();
await page.getByRole('button', { name: '文件管理', exact: true }).click();
await expect(page.locator('.files-page #file-manager-panel')).toBeVisible();
await expect(page.locator('#file-manager-path')).toHaveValue('/root');
expect(fixture.sshSockets).toHaveLength(1);
expect(fixture.sftpSockets).toHaveLength(1);
expect(fixture.calls.filter((call) => call.type === 'terminal-input')).toHaveLength(0);
await page.locator('#files-terminal').click();
await expect(page.locator('#app #file-manager-panel')).toBeVisible();
await expect(page.locator('#file-manager-tab')).toHaveAttribute('aria-selected', 'true');
});
test('复用新建文件夹、重命名和删除协议', async ({ page }) => {
const fixture = await fileFixture(page);
await connectFiles(page);
await expect(page.locator('#file-mkdir')).toBeEnabled();
page.once('dialog', (dialog) => dialog.accept('test-folder'));
await page.locator('#file-mkdir').click();
const folder = page.locator('#file-table-body tr').filter({ hasText: 'test-folder' });
await expect(folder).toBeVisible();
await folder.click();
page.once('dialog', (dialog) => dialog.accept('renamed-folder'));
await page.locator('#file-rename').click();
const renamed = page.locator('#file-table-body tr').filter({ hasText: 'renamed-folder' });
await expect(renamed).toBeVisible();
await renamed.click();
page.once('dialog', (dialog) => dialog.dismiss());
await page.locator('#file-delete').click();
await expect(renamed).toBeVisible();
page.once('dialog', (dialog) => dialog.accept());
await page.locator('#file-delete').click();
await expect(renamed).toHaveCount(0);
await page.locator('#file-table-body tr').filter({ hasText: 'README.md' }).click();
page.once('dialog', (dialog) => dialog.accept());
await page.locator('#file-delete').click();
await expect(page.locator('#file-table-body')).not.toContainText('README.md');
expect(fixture.calls).toEqual(expect.arrayContaining([
expect.objectContaining({ type: 'sftp_mkdir', path: '/root/test-folder' }),
expect.objectContaining({ type: 'sftp_rename', oldPath: '/root/test-folder', newPath: '/root/renamed-folder' }),
expect.objectContaining({ type: 'sftp_rmdir', path: '/root/renamed-folder' }),
expect.objectContaining({ type: 'sftp_delete', path: '/root/README.md' }),
]));
});
test('上传与下载校验实际字节', async ({ page }) => {
const fixture = await fileFixture(page);
await connectFiles(page);
await expect(page.locator('#file-upload')).toBeEnabled();
const payload = Buffer.from('file manager upload regression\n');
await page.locator('#file-upload-input').setInputFiles({ name: 'upload.txt', mimeType: 'text/plain', buffer: payload });
await expect(page.locator('#file-table-body')).toContainText('upload.txt');
expect(Buffer.concat(fixture.uploaded)).toEqual(payload);
await page.locator('#file-table-body tr').filter({ hasText: 'README.md' }).click();
const downloading = page.waitForEvent('download');
await page.locator('#file-download').click();
const download = await downloading;
expect(download.suggestedFilename()).toBe('README.md');
expect(await readFile((await download.path())!, 'utf8')).toBe('hello files\n');
});
test('更换主机清理旧会话,文件页继续可操作', async ({ page }) => {
const fixture = await fileFixture(page);
await connectFiles(page);
await expect(page.locator('#file-upload')).toBeEnabled();
await page.locator('#files-connect').click();
await expect(page.locator('#file-upload')).toBeDisabled();
await expect(page.locator('#files-host')).toBeEnabled();
await connectFiles(page, 'beta');
await expect(page.locator('#file-upload')).toBeEnabled();
expect(fixture.calls.filter((call) => call.type === 'connect').map((call) => call.host)).toEqual(['192.0.2.10', '192.0.2.20']);
expect(fixture.calls.filter((call) => call.type === 'sftp_close')).toHaveLength(1);
});
test('文件页仍需指纹确认', async ({ page }) => {
const fixture = await fileFixture(page, { firstSeen: true });
await connectFiles(page);
await expect(page.locator('#host-key-dialog')).toBeVisible();
await expect(page.locator('#file-upload')).toBeDisabled();
expect(fixture.sftpSockets).toHaveLength(0);
await page.locator('#accept-host-key').click();
await expect(page.locator('#file-upload')).toBeEnabled();
});
test('凭据和目录错误显示在文件页', async ({ page }) => {
await fileFixture(page, { credentialError: true });
await connectFiles(page);
await expect(page.locator('#files-notice')).toContainText('读取凭据失败');
await expect(page.locator('#files-connect')).toBeEnabled();
});
test('目录权限错误可恢复', async ({ page }) => {
await fileFixture(page);
await connectFiles(page);
await expect(page.locator('#file-upload')).toBeEnabled();
await page.locator('#file-manager-path').fill('/forbidden');
await page.locator('#file-manager-path').press('Enter');
await expect(page.locator('#file-manager-error')).toBeVisible();
await page.locator('#file-home').click();
await expect(page.locator('#file-table-body')).toContainText('README.md');
await expect(page.locator('#file-manager-error')).toBeHidden();
});
test('传输时离开需确认,切换终端不打断传输', async ({ page }) => {
const fixture = await fileFixture(page, { holdUpload: true });
await connectFiles(page);
await expect(page.locator('#file-upload')).toBeEnabled();
await page.locator('#file-upload-input').setInputFiles({ name: 'pending.txt', mimeType: 'text/plain', buffer: Buffer.from('pending') });
await expect(page.locator('#file-manager-progress')).toBeVisible();
page.once('dialog', (dialog) => dialog.dismiss());
await page.locator('#rail-overview').click();
await expect(page.locator('#files-heading')).toBeVisible();
await page.locator('#files-terminal').click();
await page.getByRole('button', { name: '文件管理', exact: true }).click();
await expect(page.locator('#file-manager-progress')).toBeVisible();
expect(fixture.sftpSockets).toHaveLength(1);
page.once('dialog', (dialog) => dialog.accept());
await page.locator('#files-connect').click();
await expect(page.locator('#file-manager-progress')).toBeHidden();
});
test('读取凭据期间禁止切换终端,避免无反馈取消连接', async ({ page }) => {
await fileFixture(page);
let finish!: () => void;
const pending = new Promise<void>((resolve) => { finish = resolve; });
await page.route('**/api/hosts/alpha/credentials', async (route) => {
await pending;
await route.fulfill({ json: { password: 'test-only' } });
});
await connectFiles(page);
await expect(page.locator('#files-connect')).toHaveText('读取凭据中…');
await expect(page.locator('#files-terminal')).toBeDisabled();
finish();
await expect(page.locator('#file-upload')).toBeEnabled();
await expect(page.locator('#files-terminal')).toBeEnabled();
});
test('离开再返回文件页,迟到的凭据不会启动已取消会话', async ({ page }) => {
const fixture = await fileFixture(page);
let finish!: () => void;
const pending = new Promise<void>((resolve) => { finish = resolve; });
await page.route('**/api/hosts/alpha/credentials', async (route) => {
await pending;
await route.fulfill({ json: { password: 'test-only' } });
});
await connectFiles(page);
await expect(page.locator('#files-connect')).toHaveText('读取凭据中…');
await page.locator('#rail-overview').click();
await expect(page.locator('#hosts-heading')).toBeVisible();
await page.locator('#rail-files').click();
finish();
await expect(page.locator('#files-connect')).toHaveText('连接主机');
expect(fixture.sshSockets).toHaveLength(0);
});
test('会话授权失败后可重试,文件操作保持禁用', async ({ page }) => {
await fileFixture(page);
await page.route('**/api/session', (route) => route.fulfill({ status: 403, json: { error: '会话授权失败,请重新登录。' } }));
await connectFiles(page);
await expect(page.locator('#files-notice')).toContainText('会话授权失败');
await expect(page.locator('#files-connection-state')).toHaveText('连接失败');
await expect(page.locator('#files-connect')).toBeEnabled();
await expect(page.locator('#file-upload')).toBeDisabled();
await expect(page.locator('#files-host')).toBeEnabled();
});