20 Commits

Author SHA1 Message Date
Your Name be633f3e58 feat: IDE编辑器重构 — 多标签+文件树+最小化+状态栏
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
- 全屏IDE面板替代模态框编辑器
- 多文件标签页: 独立Monaco实例, 切换保持状态
- 左侧文件树: 基于当前目录, 点击文件打开, 点击目录导航
- 最小化/恢复: 底部最小化栏显示所有打开的文件标签
- 状态栏: 光标位置+语言+编码+未保存指示器
- Ctrl+S保存 + 未保存关闭确认
- 标签栏: 点击切换, ×关闭, amber色未保存标记
- 文件树同步: 浏览新目录时自动更新文件树
- 模态框保留用于非编辑操作(重命名/删除/新建等)
2026-05-31 01:50:34 +08:00
Your Name 00a3309005 feat: 服务器列表加「文件」按钮直达文件管理
- servers.html: renderActions 加「文件」链接(琥珀色) → /app/files.html?server_id=X
- files.html: 支持 server_id URL参数, 自动选中服务器并浏览 /www/wwwroot
2026-05-31 01:33:52 +08:00
Your Name 3d15200cfa chore: 移除Monaco debug日志 2026-05-31 01:28:57 +08:00
Your Name d8aca5bf18 fix: Monaco预加载移入files.html(解决api.js缓存问题) 2026-05-31 01:26:59 +08:00
Your Name cb60af4483 debug: Monaco preload 加日志诊断 2026-05-31 01:22:45 +08:00
Your Name 5a5adafb39 feat: Monaco编辑器+右键菜单+排序筛选+chmod+压缩解压+新建文件
编辑器增强:
- E-2 Monaco集成: 70vh全屏编辑器, 暗色主题, bracket着色
- E-3 Ctrl+S保存快捷键 + 未保存指示器
- E-4 语言自动检测(30+扩展名映射)
- Monaco预加载: 登录后CDN后台加载, 打开编辑器秒开
- fallback: Monaco未就绪时退回textarea

文件管理增强:
- F-1 右键菜单: 预览/下载/重命名/权限/复制路径/终端/压缩/解压/删除
- F-2 新建文件按钮(📄+)
- F-3 chmod权限修改(右键→输入权限如755)
- F-4 排序切换(名称/大小/时间)
- F-5 文件类型筛选下拉(.conf/.log/.sh/.py等)
- F-6 压缩(tar.gz)
- F-7 解压(tar.gz/zip)
2026-05-31 01:10:22 +08:00
Your Name c0296b9d8e fix: 预加载文件大小 30KB → 1MB 2026-05-31 01:04:44 +08:00
Your Name ca4aceba3e fix: 面包屑点击事件委托 — 作用域不在fileListEl内
面包屑(#breadcrumbPath)和文件列表(#fileList)是兄弟节点, 不是父子关系
事件委托绑在fileListEl上无法捕获面包屑点击
修复: 给#breadcrumbPath加独立click事件委托
2026-05-31 00:57:58 +08:00
Your Name b5dfa8a870 feat: 文件管理器集成SSH终端
- files.html: 工具栏加「🖥 终端」按钮(绿色),点击在新标签页打开SSH终端
- files.html: openTerminal() 传递 server_id + 当前路径到 terminal.html
- terminal.html: 支持 path URL参数,连接成功后自动 cd 到指定目录
- 打开方式: /app/terminal.html?server_id=8&path=/www/wwwroot
- 右键菜单也会集成「打开终端」选项
2026-05-31 00:53:00 +08:00
Your Name 17cdc96bb0 feat: 文件预加载+缓存 — 双击秒开编辑器
- api.js: Monaco编辑器CDN预加载(登录后后台加载)
- files.html: 文件内容预加载(目录浏览后自动预读≤30KB文本文件)
- files.html: 文件缓存(_fileCache), 双击已缓存文件直接打开
- files.html: 切换服务器时清空缓存
- 预加载策略: 最多10个文件, 仅文本类型, ≤30KB
2026-05-31 00:46:04 +08:00
Your Name 6012af0b4d fix: 双击编辑器冲突 — 延迟区分单击/双击 2026-05-31 00:25:02 +08:00
Your Name 9bdf7e420f feat: 文件管理器4项改进
1. 切换服务器自动重置路径为 /www/wwwroot
2. 默认目录 /www/wwwroot(而非根目录 /)
3. 双击文件打开编辑器(doPreview textarea模态框)
4. 双击目录进入目录(browseDir)
2026-05-31 00:17:22 +08:00
Your Name 40d672fdbb fix: 面包屑双斜杠bug (/tmp显示为//tmp) 2026-05-31 00:11:47 +08:00
Your Name 557c8d28c1 docs: files迭代审计报告 2026-05-30 23:46:12 +08:00
Your Name 9f7be66356 fix: 审计5个FINDING修复 (files迭代)
- H-15 [MEDIUM] download端点加100MB大小限制
- H-02 [LOW] Content-Disposition filename sanitize(替换特殊字符)
- H-12 [LOW] FileDownload/FileRead/FileWrite path加max_length=4096
- H-27 [LOW] doPreview保存失败解析错误详情显示detail
- H-29 [INFO] esc()函数加注释说明用途
- H-01 [HIGH] 远程路径限制 — ACCEPTED_RISK(用户明确决策)
2026-05-30 23:44:35 +08:00
Your Name ac38442383 docs: files.html 全面迭代 changelog 2026-05-30 23:36:49 +08:00
Your Name 14131f98f0 feat: files.html 全面迭代 — 15项改进
安全+后端:
- P2-1 符号链接解析(l权限检测+name/target分离)
- P2-7 新增 /api/sync/download 端点(SFTP流式下载)
- P2-8 新增 /api/sync/read-file + /write-file 端点(base64编码避免shell注入)

前端全面重写:
- P1-1 目录删除二次确认(自定义模态框+输入目录名)
- P2-2/2-3 事件委托替代内联onclick(data-action+data-path)
- P2-4 上传进度条(XHR.upload.onprogress)
- P2-5 文件大小可读化(B/KB/MB/GB)
- P2-6 文件类型图标(30+扩展名映射)
- P2-7 下载按钮+逻辑
- P2-8 文件预览/编辑弹窗(读取+修改+保存)
- P2-9 自定义模态框替代prompt/confirm
- P2-10 批量选择+批量删除(checkbox+全选+底部操作栏)
- P3-1 文件名搜索框(前端实时过滤)
- P3-2 符号链接🔗图标+target tooltip
- P3-3 空目录状态(图标+上传引导按钮)
- P3-4 服务器刷新按钮
- P3-5 操作loading状态(进度条) 2>&1
2026-05-30 23:34:24 +08:00
Your Name 64503c0260 fix: IP校验改用endpoint级HTTPException替代Pydantic validator
model_validator/field_validator在自定义RequestValidationError handler下
无法正确序列化为JSON,导致500。改用endpoint级_validate_ip_list()辅助函数
抛HTTPException(400),避免Pydantic→FastAPI序列化链路问题。
2026-05-30 22:48:41 +08:00
Your Name d19f5af807 fix: IP格式校验用field_validator替代model_validator
model_validator(mode='after')抛出ValueError时FastAPI不能正确序列化为422
→ 导致500 TypeError: Object of type ValueError is not JSON serializable
改用field_validator(mode='before')由Pydantic正常捕获返回422
2026-05-30 22:44:26 +08:00
Your Name 463841a740 docs: settings安全加固审计报告 2026-05-30 22:35:40 +08:00
10 changed files with 1287 additions and 128 deletions
+93
View File
@@ -0,0 +1,93 @@
# 审计记录 — files.html + sync_v2.py 全面迭代
## 审计信息
- **日期**: 2026-05-30
- **审计人**: Claude (AI) + 用户确认
- **触发原因**: 文件管理器全面迭代(15项改进)
## 审计范围
| 文件 | 行数 | 状态 |
|------|------|------|
| web/app/files.html | 439 | ☑ 已审 |
| server/api/sync_v2.py | 1200 | ☑ 已审 |
| server/api/schemas.py | 215 | ☑ 已审 |
## 审计8步结果
### Step 1: 登记 ✅
### Step 2: 全文Read ✅
### Step 3: 规则扫描H — 30 项
### Step 4: Closure表
| H-xx | 严重度 | Closure | 说明 |
|------|--------|---------|------|
| H-01 | HIGH | **ACCEPTED_RISK** | 远程路径无限制 — 用户明确决策"不加限制" |
| H-02 | LOW | **FINDING→已修** | Content-Disposition filename sanitize |
| H-03 | LOW | ACCEPTED_RISK | base64 echo 单引号,RFC 4648 字母表安全 |
| H-04 | LOW | FALSE_POSITIVE | upload 路径穿越检查充分 |
| H-05 | N/A | FALSE_POSITIVE | 无 SQL 操作 |
| H-06 | INFO | FALSE_POSITIVE | esc() 覆盖完整,无 XSS |
| H-07 | N/A | FALSE_POSITIVE | 无 HTTP 客户端 |
| H-08 | N/A | FALSE_POSITIVE | 无明文密码 |
| H-09 | N/A | FALSE_POSITIVE | 无硬编码密钥 |
| H-10 | INFO | FALSE_POSITIVE | 审计失败降级合理 |
| H-11 | N/A | FALSE_POSITIVE | JWT 全覆盖 |
| H-12 | LOW | **FINDING→已修** | path 字段加 max_length=4096 |
| H-13 | N/A | FALSE_POSITIVE | 全部有审计日志 |
| H-14 | INFO | FALSE_POSITIVE | Bearer token 免疫 CSRF |
| H-15 | MEDIUM | **FINDING→已修** | download 加 100MB 大小限制 |
| H-16 | LOW | ACCEPTED_RISK | batchDelete is_dir 不影响结果 |
| H-17 | LOW | ACCEPTED_RISK | 竞态概率极低 |
| H-18 | INFO | FALSE_POSITIVE | parseInt 源数据安全 |
| H-19 | N/A | FALSE_POSITIVE | Pydantic 自动校验 |
| H-20 | N/A | FALSE_POSITIVE | JS pop() 安全 |
| H-21 | N/A | FALSE_POSITIVE | 线性遍历无循环风险 |
| H-22 | INFO | FALSE_POSITIVE | async with + finally 双重保护 |
| H-23 | LOW | **FINDING→已修** | 同 H-15,合并修复 |
| H-24 | INFO | FALSE_POSITIVE | async 架构 |
| H-25 | N/A | FALSE_POSITIVE | 无 DB 密集操作 |
| H-26 | INFO | ACCEPTED_RISK | ls 截断 10000 字符足够 |
| H-27 | LOW | **FINDING→已修** | doPreview 保存失败解析错误详情 |
| H-28 | INFO | ACCEPTED_RISK | 错误处理整体一致 |
| H-29 | INFO | **FINDING→已修** | esc() 加注释 |
| H-30 | INFO | ACCEPTED_RISK | ls -la 解析器正确 |
### Step 5: 入口表 ✅
全部 API 端点已检查(download/read-file/write-file/browse/file-ops/upload)。
### Step 6: 输入→Sink ✅
所有用户输入路径已追踪:
- download: path → sftp.stat → StreamingResponse
- read-file: path → stat -c%s → cat
- write-file: path + content → base64 + tee
- file-ops: path → shlex.quote → SSH exec
- upload: remote_path + filename → SFTP put
### Step 7: 归类
```
web/app/files.html 439行 10H 2FINDING → 0FINDING
server/api/sync_v2.py 1200行 12H 3FINDING → 0FINDING (1 ACCEPTED_RISK)
server/api/schemas.py 215行 8H 1FINDING → 0FINDING
──────────────────────────────────────────────────────────────────
总计 6 FINDING → 0FINDING (全部已修或 ACCEPTED_RISK)
```
### Step 8: DoD ✅
## FINDING 列表
| 编号 | 严重度 | 描述 | 修复方式 |
|------|--------|------|----------|
| H-01 | HIGH | 远程端点无路径限制 | ACCEPTED_RISK — 用户决策 |
| H-15 | MEDIUM | download 无大小限制 | 加 100MB stat.size 检查 → 413 |
| H-02 | LOW | Content-Disposition 头注入 | re.sub 替换特殊字符 |
| H-12 | LOW | path 无 max_length | 三个 schema 加 max_length=4096 |
| H-27 | LOW | doPreview 保存错误丢失详情 | 解析 wr.json() 显示 detail |
| H-29 | INFO | esc() 缺注释 | 加 /** HTML-escape */ 注释 |
## 结论
☑ 审计通过,6 FINDING 全部已处理(5 修复 + 1 ACCEPTED_RISK),0 遗留,可部署
@@ -0,0 +1,115 @@
# 审计记录 — Settings 安全加固 + UX 迭代
## 审计信息
- **日期**: 2026-05-30
- **审计人**: Claude (AI) + 用户确认
- **触发原因**: 安全加固 + UX 迭代
## 审计范围
| 文件 | 行数 | 状态 |
|------|------|------|
| server/api/settings.py | 1278 | ☑ 已审 |
| server/api/auth.py | 372 | ☑ 已审 |
| server/application/services/auth_service.py | 576 | ☑ 已审 |
| web/app/settings.html | 938 | ☑ 已审 |
| web/app/login.html | 347 | ☑ 已审 |
## 审计8步结果
### Step 1: 登记 ✅
### Step 2: 全文Read ✅
### Step 3: 规则扫描H
30 项基础检查 + 1 项额外检查 = 31 项
### Step 4: Closure表
| 编号 | 项目 | 文件 | 严重度 | 判定 | 依据 |
|------|------|------|--------|------|------|
| H-01 | SQL注入 | 全部 | - | 未命中 | SQLAlchemy ORM,无f-string拼SQL |
| H-02 | 命令注入 | 全部 | - | 未命中 | 无os.system/subprocess |
| H-03 | XSS | settings.html | - | 未命中 | esc()/escAttr()标准转义 |
| H-04 | SSRF TOCTOU | settings.py | LOW | ACCEPTED_RISK | 管理员接口、窗口极短、需控制DNS |
| H-05 | 路径穿越 | 全部 | - | 未命中 | 无文件系统操作 |
| H-06 | 明文密码 | settings.py | - | 未命中 | SENSITIVE_KEYS脱敏+reveal需密码 |
| H-07 | 硬编码密钥 | 全部 | - | 未命中 | 来自settings对象(.env+MySQL) |
| H-08 | 静默吞错 | auth.py | - | FALSE_POSITIVE | logger.warning+exc_info=True |
| H-09 | 静默吞错(hot-reload) | settings.py | LOW | FINDING→已修 | 改为logger.warning+跳过setattr |
| H-10 | 权限检查 | 全部 | - | 未命中 | JWT+Depends(get_current_admin) |
| H-11 | IP格式校验 | settings.py | MEDIUM | FINDING→已修 | IpAllowlistSaveRequest加model_validator |
| H-12 | 竞态条件 | settings.py | LOW | ACCEPTED_RISK | 管理员单人操作,概率极低 |
| H-13 | CSRF | 全部 | - | 未命中 | JWT Bearer不自动附加+SameSite cookie |
| H-14 | 边界条件 | settings.py | - | 未命中 | min_length/max_length保护 |
| H-15 | 类型转换 | auth_service.py | - | 未命中 | try/except包裹int() |
| H-16 | None检查 | 全部 | - | 未命中 | 关键None检查均已覆盖 |
| H-17 | 索引越界 | auth_service.py | - | 未命中 | HMAC-SHA1 digest长度足够 |
| H-18 | 递归/循环 | 全部 | - | 未命中 | MAX_REDIRECTS=3+range(-1,2) |
| H-19 | 资源泄漏 | 全部 | - | 未命中 | async with上下文管理 |
| H-20 | 时序问题 | settings.py | - | 未命中 | 毫秒级窗口,实际不可利用 |
| H-21 | N+1查询 | 全部 | - | 未命中 | 最多2-3次DB查询/请求 |
| H-22 | 同步阻塞DNS | settings.py | MEDIUM | FINDING→已修 | 改为asyncio.get_event_loop().getaddrinfo() |
| H-23 | 大数据 | 全部 | - | 未命中 | 全部分页+27行settings表 |
| H-24 | 内存使用 | 全部 | - | 未命中 | 无大对象常驻 |
| H-25 | 缓存 | 全部 | - | N/A | 内存热更新,无需额外缓存 |
| H-26 | 错误处理 | 全部 | - | 未命中 | HTTP状态码+detail消息一致 |
| H-27 | 日志 | 全部 | - | 未命中 | AuditLog+logger.error/warning |
| H-28 | 文档 | 全部 | - | 未命中 | 模块/类/函数级docstring |
| H-29 | 重复代码 | settings.py | LOW | FINDING→已修 | 提取_verify_reauth辅助函数 |
| H-30 | 一致性 | settings.py/html | LOW | FINDING→已修 | 统一catch→console.error+toast |
| H-31 | TOTP格式不一致 | auth.py | LOW | FINDING→已修 | totp_code加min_length=6 |
### Step 5: 入口表 ✅
全部API端点已检查(GET/PUT/POST/DELETE路由)。
### Step 6: 输入→Sink ✅
所有用户输入路径已追踪:
- PUT /{key} → IMMUTABLE_KEYS → MUTABLE_KEYS → SETTING_VALIDATORS → repo.set
- POST /manual → IpAllowlistRequest model_validator → repo.set
- DELETE /ip → query param ip → string comparison
- PUT /password → bcrypt.checkpw → TOTP verify → bcrypt.hashpw → DB
### Step 7: 归类
```
server/api/settings.py 1278行 13H 4FINDING → 0FINDING (全部已修)
server/api/auth.py 372行 8H 1FINDING → 0FINDING (已修)
server/application/services/auth_service.py 576行 0H 0FINDING
web/app/settings.html 938行 10H 2FINDING → 0FINDING (全部已修)
web/app/login.html 347行 0H 0FINDING
────────────────────────────────────────────────────────────
总计 7 FINDING → 0FINDING (全部已修)
```
### Step 8: DoD ✅
## FINDING 列表
| 编号 | 严重度 | 描述 | 修复方式 |
|------|--------|------|----------|
| F-1 | MEDIUM | IpAllowlistSaveRequest.manual_ips 无IP格式校验 | 加model_validator复用_IP_RE+_DOMAIN_RE |
| F-2 | MEDIUM | socket.getaddrinfo同步阻塞事件循环 | 改为await asyncio.get_event_loop().getaddrinfo() |
| F-3 | LOW | hot-reload int转换失败静默pass | 改为logger.warning+跳过setattr |
| F-4 | LOW | re-auth模式4处完全重复 | 提取_verify_reauth()辅助函数 |
| F-5 | LOW | totp_code无长度约束 | 加min_length=6,max_length=6 |
| F-6 | LOW | revealTelegramToken空catch | 改为console.error+toast |
| F-7 | LOW | toggleApiKeyVisibility空catch | 改为console.error+toast |
## 结论
☑ 审计通过,7 FINDING 全部已修复,0 遗留,可部署
## 归类统计
| 严重度 | 数量 | 处理 |
|--------|------|------|
| MEDIUM | 2 | 全部FINDING,已修复 |
| LOW | 5 | 全部FINDING,已修复 |
| ACCEPTED_RISK | 2 | H-04 SSRF TOCTOU + H-12 竞态条件 |
| FALSE_POSITIVE | 1 | H-08 auth.py Redis降级 |
| 未命中 | 22 | 安全基线良好 |
@@ -0,0 +1,101 @@
# Changelog — files.html + sync_v2.py 全面迭代
**日期**: 2026-05-30
**Commit**: 14131f9
**类型**: 安全加固 + 功能补全 + UX 全面改进
**涉及文件**:
- `server/api/sync_v2.py` — 符号链接解析 + 新增 3 个端点
- `server/api/schemas.py` — 新增 FileDownload/FileRead/FileWrite schema
- `web/app/files.html` — 前端全面重写
## 后端变更 (sync_v2.py)
### P2-1/P3-2: 符号链接解析
- `ls -la` 解析逻辑新增 `is_link` 字段(检测 `l` 权限)
- 符号链接文件名 `name -> target` 分离为 `name` + `link_target`
- 响应新增 `is_link``link_target` 字段
### P2-7: 新增 `/api/sync/download` 端点
- 通过 SFTP 流式下载远程文件
- 使用 `StreamingResponse` + 64KB chunk 避免内存膨胀
- 审计日志记录
### P2-8: 新增 `/api/sync/read-file` + `/write-file` 端点
- `read-file`: `stat` 检查大小 → `cat` 读取内容,限制 1MB
- `write-file`: base64 编码内容避免 shell 注入,`echo | base64 -d | tee` 写入
- 两个端点均有审计日志
## 前端全面重写 (files.html)
### P1-1: 目录删除二次确认
- 目录删除:自定义模态框 + 要求输入目录名确认
- 文件删除:自定义确认模态框
### P2-2/P2-3: 事件委托
- 移除所有内联 `onclick="..."` 字符串拼接
- 改用 `data-action` + `data-path` + `data-name` 属性
- 统一事件委托 `fileListEl.addEventListener('click', ...)`
- 彻底消除 XSS 向量
### P2-4: 上传进度条
- `XMLHttpRequest.upload.onprogress` 实时进度
- 顶部进度条显示当前文件名 + 百分比 + 进度条动画
### P2-5: 文件大小可读化
- `formatSize()` 函数:字节 → B/KB/MB/GB
- 保留 1 位小数
### P2-6: 文件类型图标
- 30+ 文件扩展名映射
- `.py`→🐍 `.jpg`→🖼️ `.tar.gz`→📦 `.log`→📊 `.env`→🔒 等
### P2-7: 文件下载
- 每行 hover 显示 ⬇ 下载按钮
- 点击触发 SFTP 下载 → 浏览器保存文件
### P2-8: 文件预览/编辑
- 每行 hover 显示 👁 预览按钮
- 点击读取文件内容 → 模态框显示 + textarea 编辑 + 保存
### P2-9: 自定义模态框
- 完全替代原生 `prompt()` / `confirm()`
- Tailwind 样式:暗色主题 + backdrop blur + 圆角
- 支持:文本提示 / 输入框 / 多行编辑 / 危险操作红色样式
### P2-10: 批量操作
- 每行开头 checkbox + 表头全选
- 选中时底部出现批量操作栏(计数 + 批量删除 + 取消)
### P3-1: 文件搜索
- 面包屑右侧搜索框
- 前端实时过滤文件名
### P3-2: 符号链接显示
- 🔗 图标 + cyan 颜色
- hover 显示 `→ target` tooltip
### P3-3: 空目录状态
- 📂 大图标 + 提示文字 + 「上传文件」引导按钮
### P3-4: 服务器刷新
- 🔄 按钮在服务器下拉旁,点击重新加载列表
### P3-5: 操作 loading
- 上传期间顶部进度条持续显示
- 浏览期间文件列表显示 ⏳ 加载动画
## 验证结果
| 测试 | 结果 |
|------|------|
| 健康检查 | ✅ ok |
| 页面加载 | ✅ 0 errors |
| 浏览 /tmp | ✅ 文件列表+图标+大小正确 |
| 符号链接 mysql.sock | ✅ 🔗 图标 |
| 面包屑 | ✅ /tmp 路径 |
| 搜索框 | ✅ 存在 |
| 全选 checkbox | ✅ 存在 |
| 操作按钮 | ✅ 👁 ⬏ ✏ 🗑 |
## 是否需迁移/重启
- 需要重启后端服务(新增 3 个 API 端点)
+20
View File
@@ -192,6 +192,26 @@ class FileOperation(BaseModel):
new_path: Optional[str] = None # required for rename
class FileDownload(BaseModel):
"""Download a file from a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1, max_length=4096)
class FileRead(BaseModel):
"""Read text file content from a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1, max_length=4096)
max_size: int = Field(1_000_000, ge=1, le=5_000_000) # 1MB default, 5MB max
class FileWrite(BaseModel):
"""Write text content to a file on a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1, max_length=4096)
content: str = Field(..., max_length=5_000_000)
# ── Script ──
class ScriptCreate(BaseModel):
+14 -21
View File
@@ -14,7 +14,7 @@ import socket
import bcrypt
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Field
from server.api.dependencies import get_db
from server.api.auth_jwt import get_current_admin
@@ -93,6 +93,16 @@ async def _verify_reauth(db: AsyncSession, admin: Admin, password: str) -> None:
raise HTTPException(status_code=400, detail="当前密码错误")
def _validate_ip_list(ips: list[str]) -> None:
"""Validate IP/CIDR/domain list. Raises 400 on invalid entry."""
for raw in ips:
ip = raw.strip()
if not ip:
continue
if not (_IP_RE.match(ip) or _DOMAIN_RE.match(ip)):
raise HTTPException(status_code=400, detail=f"无效的 IP/CIDR/域名: {ip}")
# ── System Settings ──
@router.get("/", response_model=list)
@@ -841,16 +851,6 @@ _DOMAIN_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-
class IpAllowlistRequest(BaseModel):
ips: list[str] = Field(..., min_length=1, max_length=100, description="IP or hostname list")
@model_validator(mode="after")
def validate_ips(self):
for raw in self.ips:
ip = raw.strip()
if not ip:
continue
if not (_IP_RE.match(ip) or _DOMAIN_RE.match(ip)):
raise ValueError(f"无效的 IP/CIDR/域名: {ip}")
return self
class SubscriptionParseRequest(BaseModel):
url: str = Field(..., min_length=1, description="Proxy subscription URL to fetch and parse")
@@ -938,16 +938,6 @@ class IpAllowlistSaveRequest(BaseModel):
subscription_url: Optional[str] = None # None = don't change; "" = clear
enabled: Optional[bool] = None # None = don't change
@model_validator(mode="after")
def validate_manual_ips(self):
for raw in self.manual_ips:
ip = raw.strip()
if not ip:
continue
if not (_IP_RE.match(ip) or _DOMAIN_RE.match(ip)):
raise ValueError(f"无效的 IP/CIDR/域名: {ip}")
return self
@router.post("/ip-allowlist", response_model=dict)
async def set_ip_allowlist(
@@ -958,6 +948,8 @@ async def set_ip_allowlist(
):
"""Save manual IPs, subscription URL, and/or enabled toggle."""
from server.config import settings as _settings
if payload.manual_ips:
_validate_ip_list(payload.manual_ips)
repo = SettingRepositoryImpl(db)
@@ -1044,6 +1036,7 @@ async def add_manual_ips(
):
"""Append IPs to the manual list and trigger allowlist rebuild."""
from server.config import settings as _settings
_validate_ip_list(payload.ips)
existing = [ip.strip() for ip in (_settings.LOGIN_MANUAL_IPS or "").split(",") if ip.strip()]
new_ips = [ip.strip() for ip in payload.ips if ip.strip()]
+158 -4
View File
@@ -9,7 +9,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, LocalFilePreview, SyncVerify, SyncCancel, ValidateSourcePath, SyncDiagnose, FileSyncDiff
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, LocalFilePreview, SyncVerify, SyncCancel, ValidateSourcePath, SyncDiagnose, FileSyncDiff, FileDownload, FileRead, FileWrite
from server.application.services.sync_engine_v2 import SyncEngineV2
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
@@ -177,12 +177,21 @@ async def browse_directory(
continue
parts = line.split(None, 8)
if len(parts) >= 9:
is_dir = parts[0].startswith("d")
perms = parts[0]
is_dir = perms.startswith("d")
is_link = perms.startswith("l")
name = parts[8]
link_target = None
# P2-1: Parse symlink: "name -> target"
if is_link and " -> " in name:
name, link_target = name.split(" -> ", 1)
entries.append({
"name": parts[8], # Last field after split(None, 8) — handles spaces in names
"name": name,
"is_dir": is_dir,
"is_link": is_link,
"link_target": link_target,
"size": parts[4],
"perms": parts[0],
"perms": perms,
"owner": parts[2],
"modified": " ".join(parts[5:8]),
})
@@ -1043,3 +1052,148 @@ async def verify_sync(
)
return {"results": results, "total_local_files": len(local_files)}
# ── File Download / Read / Write ──
MAX_READ_FILE_SIZE = 1_000_000 # 1MB
@router.post("/download")
async def download_file(
payload: FileDownload,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""P2-7: Download a file from a remote server via SFTP."""
import os
from fastapi.responses import StreamingResponse
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
session = request.state.db
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
if not server:
raise HTTPException(status_code=404, detail="服务器不存在")
conn = await ssh_pool.acquire(server)
try:
async with conn.create_sftp_client() as sftp:
# Check file exists and size
try:
stat = await sftp.stat(payload.path)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="文件不存在") from None
except Exception as e:
raise HTTPException(status_code=502, detail=f"无法访问文件: {e}") from e
if stat.type not in ("regular file", "unknown"):
raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录")
# H-15: Download file size limit (100MB)
MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024
if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE:
raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB")
# H-02: Sanitize filename for Content-Disposition (RFC 6266)
import re as _re
filename = _re.sub(r'[^\w.\-]', '_', os.path.basename(payload.path)) or "download"
async def file_generator():
async with sftp.open(payload.path, "rb") as f:
while chunk := await f.read(65536):
yield chunk
await _audit_sync(
"file_download", "server", payload.server_id,
f"下载文件: {payload.path}",
admin.username, request,
)
return StreamingResponse(
file_generator(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
finally:
await ssh_pool.release(server.id)
@router.post("/read-file")
async def read_file(
payload: FileRead,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""P2-8: Read text file content from a remote server."""
import shlex
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
session = request.state.db
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
if not server:
raise HTTPException(status_code=404, detail="服务器不存在")
# Check file size first
size_result = await exec_ssh_command(server, f"stat -c%s {shlex.quote(payload.path)}", timeout=5)
if size_result["exit_code"] != 0:
raise HTTPException(status_code=404, detail="文件不存在或无法访问")
try:
file_size = int(size_result["stdout"].strip())
except ValueError:
raise HTTPException(status_code=502, detail="无法获取文件大小") from None
if file_size > payload.max_size:
raise HTTPException(
status_code=413,
detail=f"文件大小 {file_size} 字节超过限制 {payload.max_size} 字节",
)
# Read file content
result = await exec_ssh_command(server, f"cat {shlex.quote(payload.path)}", timeout=15)
if result["exit_code"] != 0:
raise HTTPException(status_code=502, detail=f"读取失败: {result['stderr'][:200]}")
await _audit_sync(
"file_read", "server", payload.server_id,
f"读取文件: {payload.path} ({file_size} bytes)",
admin.username, request,
)
return {"content": result["stdout"], "size": file_size, "path": payload.path}
@router.post("/write-file")
async def write_file(
payload: FileWrite,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""P2-8: Write text content to a file on a remote server."""
import shlex
import base64
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
session = request.state.db
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
if not server:
raise HTTPException(status_code=404, detail="服务器不存在")
# Use base64 to avoid shell escaping issues with file content
encoded = base64.b64encode(payload.content.encode("utf-8")).decode("ascii")
cmd = f"echo '{encoded}' | base64 -d | tee {shlex.quote(payload.path)} > /dev/null"
result = await exec_ssh_command(server, cmd, timeout=30)
if result["exit_code"] != 0:
raise HTTPException(status_code=502, detail=f"写入失败: {result['stderr'][:200]}")
await _audit_sync(
"file_write", "server", payload.server_id,
f"写入文件: {payload.path} ({len(payload.content)} chars)",
admin.username, request,
)
return {"success": True, "path": payload.path, "size": len(payload.content)}
+16
View File
@@ -132,6 +132,22 @@ if (!token) {
_recordActivity();
}
// ── Monaco Editor 预加载(后台,不阻塞页面) ──
(function preloadMonaco(){
if(window._monacoLoading||window._monacoReady)return;
window._monacoLoading=true;
const s=document.createElement('script');
s.src='https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js';
s.onload=function(){
require.config({paths:{vs:'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs'}});
require(['vs/editor/editor.main'],function(){
window.monaco=monaco;
window._monacoReady=true;
});
};
document.head.appendChild(s);
})();
// ── Global Toast Notification System ──
// Call toast('success', '操作成功') or toast('error', '操作失败') from any page.
+762 -103
View File
@@ -2,126 +2,526 @@
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-4">
<button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<h1 class="text-white font-semibold">文件管理</h1>
</div>
<div class="flex items-center gap-3">
<select id="serverSelect" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<div class="flex items-center gap-2 flex-wrap">
<select id="serverSelect" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand" onchange="onServerChange()">
<option value="">-- 选择服务器 --</option>
</select>
<input id="dirPath" type="text" value="/" placeholder="/ 路径" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
<button onclick="browseDir()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">浏览</button>
<button id="refreshServersBtn" class="px-2 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="刷新服务器列表" onclick="loadServers()">🔄</button>
<input id="dirPath" type="text" value="/www/wwwroot" placeholder="/www/wwwroot" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
<button id="browseBtn" onclick="browseDir()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">浏览</button>
<button onclick="doMkdir()" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="新建目录">📁+</button>
<button onclick="pickUploadFile()" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="上传文件">↑ 上传</button>
<button onclick="doNewFile()" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="新建文件">📄+</button>
<button id="uploadBtn" onclick="pickUploadFile()" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="上传文件">↑ 上传</button>
<button onclick="openTerminal()" class="px-3 py-1.5 bg-green-700 hover:bg-green-600 text-white text-sm rounded-lg transition" title="打开SSH终端">🖥 终端</button>
<input id="uploadFileInput" type="file" multiple class="hidden" onchange="doUploadFiles(this.files)">
</div>
</header>
<!-- Breadcrumb -->
<div id="breadcrumb" class="bg-slate-900/50 border-b border-slate-800 px-6 py-2 flex items-center gap-1 text-sm hidden">
<span class="text-slate-500">路径:</span>
<div id="breadcrumbPath" class="flex items-center gap-1"></div>
<!-- Upload Progress Bar -->
<div id="uploadProgress" class="hidden bg-slate-900/80 border-b border-slate-800 px-6 py-2">
<div class="flex items-center gap-3 text-sm">
<span id="uploadFileName" class="text-slate-300 truncate flex-1"></span>
<span id="uploadPercent" class="text-brand-light tabular-nums w-12 text-right"></span>
</div>
<div class="mt-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div id="uploadBar" class="h-full bg-brand rounded-full transition-all" style="width:0%"></div>
</div>
</div>
<!-- Breadcrumb + Search -->
<div id="breadcrumb" class="bg-slate-900/50 border-b border-slate-800 px-6 py-2 flex items-center gap-2 hidden">
<span class="text-slate-500 text-sm shrink-0">路径:</span>
<div id="breadcrumbPath" class="flex items-center gap-1 flex-1 overflow-x-auto"></div>
<input id="fileSearch" placeholder="🔍 搜索..." oninput="filterFiles(this.value)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded text-white text-xs w-40 focus:outline-none focus:ring-1 focus:ring-brand">
<select onchange="setSort(this.value)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded text-white text-xs focus:outline-none">
<option value="name">名称↑</option><option value="size">大小</option><option value="time">时间</option>
</select>
<select onchange="filterByExt(this.value)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded text-white text-xs focus:outline-none">
<option value="">全部</option><option value="conf">.conf</option><option value="log">.log</option><option value="sh">.sh</option><option value="py">.py</option><option value="json">.json</option><option value="yml">.yml</option><option value="txt">.txt</option>
</select>
</div>
<!-- Batch Action Bar -->
<div id="batchBar" class="hidden bg-slate-800 border-b border-slate-700 px-6 py-2 flex items-center gap-3">
<span id="batchCount" class="text-slate-300 text-sm"></span>
<button onclick="batchDelete()" class="px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-xs rounded-lg transition">🗑 批量删除</button>
<button onclick="clearSelection()" class="px-3 py-1 bg-slate-600 hover:bg-slate-500 text-white text-xs rounded-lg transition">取消选择</button>
</div>
<main class="flex-1 overflow-y-auto p-6">
<div id="fileList" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="px-4 py-8 text-center text-slate-500">选择服务器并输入目录路径开始浏览</div>
<div class="px-4 py-12 text-center text-slate-500">
<div class="text-4xl mb-3">📂</div>
<p>选择服务器并输入目录路径开始浏览</p>
</div>
</div>
</main>
</div>
<!-- IDE Editor Panel -->
<div id="editorPanel" class="hidden fixed inset-0 z-40 bg-slate-950 flex">
<!-- File tree sidebar -->
<div id="editorTree" class="w-56 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
<div class="px-3 py-2 border-b border-slate-800 flex items-center justify-between">
<span class="text-xs text-slate-500 font-medium">📁 文件列表</span>
<button onclick="toggleEditorTree()" class="text-slate-600 hover:text-slate-400 text-xs"></button>
</div>
<div id="editorTreeList" class="flex-1 overflow-y-auto text-sm"></div>
</div>
<!-- Editor area -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Tab bar -->
<div id="editorTabBar" class="flex bg-slate-900 border-b border-slate-800 overflow-x-auto shrink-0"></div>
<!-- Monaco container (each tab gets its own child div) -->
<div id="editorMonacoWrap" class="flex-1 relative"></div>
<!-- Status bar -->
<div class="flex items-center gap-4 px-4 py-1.5 bg-slate-900 border-t border-slate-800 text-xs text-slate-500 shrink-0">
<span id="editorPos">Ln 1, Col 1</span>
<span id="editorLang">plaintext</span>
<span>UTF-8</span>
<span id="editorMod" class="text-amber-400 hidden">● 未保存</span>
<div class="flex-1"></div>
<span id="editorFileName" class="text-slate-400"></span>
<button onclick="minimizeEditor()" class="px-2 py-0.5 hover:text-white hover:bg-slate-700 rounded transition" title="最小化"></button>
<button onclick="closeAllTabs()" class="px-2 py-0.5 hover:text-red-400 hover:bg-red-900/30 rounded transition" title="关闭全部"></button>
</div>
</div>
</div>
<!-- Minimized editor bar -->
<div id="editorMinBar" class="hidden fixed bottom-0 left-0 right-0 z-30 bg-slate-900 border-t border-slate-700 px-4 py-1.5 flex items-center gap-2">
<div id="editorMinTabs" class="flex-1 flex items-center gap-1 overflow-x-auto"></div>
<button onclick="restoreEditor()" class="px-2 py-0.5 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded">▲ 展开</button>
<button onclick="closeAllTabs()" class="px-2 py-0.5 text-xs text-slate-400 hover:text-red-400 hover:bg-red-900/30 rounded"></button>
</div>
<!-- Modal (for non-editor operations: rename/delete/mkdir/chmod/compress) -->
<div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div id="modalDialog" class="bg-slate-800 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl border border-slate-700">
<h3 id="modalTitle" class="text-white font-medium mb-4 text-lg"></h3>
<p id="modalText" class="text-slate-300 text-sm mb-4 hidden whitespace-pre-line"></p>
<input id="modalInput" class="hidden w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white mb-4 focus:outline-none focus:ring-2 focus:ring-brand text-sm" autocomplete="off">
<div class="flex gap-2 justify-end">
<button onclick="closeModal()" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition">取消</button>
<button id="modalConfirm" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">确认</button>
</div>
</div>
</div>
<!-- F-1: Right-click context menu -->
<div id="contextMenu" class="hidden fixed z-50 bg-slate-800 border border-slate-700 rounded-lg shadow-xl py-1 min-w-[180px]">
<button data-ctx="preview" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">👁 预览/编辑</button>
<button data-ctx="download" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">⬇ 下载</button>
<button data-ctx="rename" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">✏ 重命名</button>
<button data-ctx="chmod" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">🔐 修改权限</button>
<button data-ctx="copyPath" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">📋 复制路径</button>
<button data-ctx="terminal" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">🖥 在此打开终端</button>
<div class="border-t border-slate-700 my-1"></div>
<button data-ctx="compress" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">📦 压缩为 tar.gz</button>
<button data-ctx="decompress" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">📂 解压到当前目录</button>
<div class="border-t border-slate-700 my-1"></div>
<button data-ctx="delete" class="ctx-item w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-900/30 transition">🗑 删除</button>
</div>
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script>
// Monaco Editor preload (inline to avoid api.js caching issues)
if(!window._monacoLoading&&!window._monacoReady){
window._monacoLoading=true;
const _ms=document.createElement('script');
_ms.src='https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js';
_ms.onload=function(){
require.config({paths:{vs:'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs'}});
require(['vs/editor/editor.main'],function(){window.monaco=monaco;window._monacoReady=true});
};
document.head.appendChild(_ms);
}
initLayout('files');
let _currentPath='/';
let _currentServerId=null;
let _allEntries=[];
let _selectedPaths=new Set();
let _fileCache={}; // {path: {content, size, loaded_at}}
// ── P2-5: File size formatting ──
function formatSize(bytes){
if(!bytes||bytes==='--')return'--';
const b=parseInt(bytes);if(isNaN(b))return bytes;
if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';
if(b<1073741824)return(b/1048576).toFixed(1)+' MB';return(b/1073741824).toFixed(1)+' GB';
}
// ── P2-6: File type icons ──
function fileIcon(name,isDir,isLink){
if(isDir)return{icon:'📁',color:'text-yellow-400'};
if(isLink)return{icon:'🔗',color:'text-cyan-400'};
const ext=name.includes('.')?name.split('.').pop().toLowerCase():'';
const m={py:'🐍',js:'📜',ts:'📜',html:'🌐',htm:'🌐',css:'🎨',json:'📋',yml:'📋',yaml:'📋',xml:'📋',conf:'📋',cfg:'📋',ini:'📋',toml:'📋',
sh:'⚙️',bash:'⚙️',zsh:'⚙️',
jpg:'🖼️',jpeg:'🖼️',png:'🖼️',gif:'🖼️',svg:'🖼️',ico:'🖼️',webp:'🖼️',
tar:'📦',gz:'📦',zip:'📦',rar:'📦',tgz:'📦',bz2:'📦',xz:'📦',
log:'📊',txt:'📝',md:'📝',csv:'📊',tsv:'📊',
sql:'🗄️',db:'🗄️',sqlite:'🗄️',
pdf:'📕',doc:'📘',docx:'📘',xls:'📗',xlsx:'📗',
mp3:'🎵',mp4:'🎬',avi:'🎬',mkv:'🎬',
env:'🔒',key:'🔒',pem:'🔒',crt:'🔒',cert:'🔒'};
return{icon:m[ext]||'📄',color:'text-slate-400'};
}
// ── P2-2/P2-3: Event delegation ──
let _clickTimer=null;
const fileListEl=document.getElementById('fileList');
fileListEl.addEventListener('click',e=>{
const btn=e.target.closest('[data-action]');
if(!btn)return;
// Delay single-click to allow dblclick to cancel it
clearTimeout(_clickTimer);
_clickTimer=setTimeout(()=>{
const action=btn.dataset.action,path=btn.dataset.path||'',name=btn.dataset.name||'',isDir=btn.dataset.isDir==='true';
if(action==='browse')browseDir(path);
else if(action==='rename')doRename(path,name);
else if(action==='delete')doDelete(path,isDir);
else if(action==='download')doDownload(path,name);
else if(action==='preview')doPreview(path);
else if(action==='select')toggleSelect(btn,path);
else if(action==='select-all')toggleSelectAll(btn);
},250);
});
// ── Double-click: open editor for files, browse for directories ──
fileListEl.addEventListener('dblclick',e=>{
clearTimeout(_clickTimer); // Cancel single-click action
const row=e.target.closest('.file-row');
if(!row)return;
const pathBtn=row.querySelector('[data-action="rename"]');
if(!pathBtn)return;
const path=pathBtn.dataset.path;
const isDir=!!row.querySelector('[data-action="browse"]');
if(isDir){browseDir(path)}else{doPreview(path)}
});
// ── F-1: Right-click context menu ──
let _ctxPath='',_ctxIsDir=false,_ctxName='';
const ctxMenu=document.getElementById('contextMenu');
fileListEl.addEventListener('contextmenu',e=>{
e.preventDefault();
const row=e.target.closest('.file-row');
if(!row){ctxMenu.classList.add('hidden');return}
const renameBtn=row.querySelector('[data-action="rename"]');
if(!renameBtn)return;
_ctxPath=renameBtn.dataset.path;_ctxName=renameBtn.dataset.name||'';
_ctxIsDir=!!row.querySelector('[data-action="browse"]');
// Show/hide items based on file type
ctxMenu.querySelectorAll('[data-ctx="preview"],[data-ctx="download"]').forEach(b=>{b.classList.toggle('hidden',_ctxIsDir)});
ctxMenu.querySelectorAll('[data-ctx="decompress"]').forEach(b=>{b.classList.toggle('hidden',!_ctxName.match(/\.(tar\.gz|tgz|zip)$/))});
// Position
const x=Math.min(e.clientX,window.innerWidth-200);
const y=Math.min(e.clientY,window.innerHeight-350);
ctxMenu.style.left=x+'px';ctxMenu.style.top=y+'px';
ctxMenu.classList.remove('hidden');
});
document.addEventListener('click',()=>ctxMenu.classList.add('hidden'));
ctxMenu.addEventListener('click',async e=>{
const btn=e.target.closest('[data-ctx]');
if(!btn)return;
const action=btn.dataset.ctx;
ctxMenu.classList.add('hidden');
if(action==='preview')doPreview(_ctxPath);
else if(action==='download')doDownload(_ctxPath,_ctxName);
else if(action==='rename')doRename(_ctxPath,_ctxName);
else if(action==='delete')doDelete(_ctxPath,_ctxIsDir);
else if(action==='copyPath'){navigator.clipboard.writeText(_ctxPath);toast('success','已复制路径')}
else if(action==='chmod')doChmod(_ctxPath,_ctxName);
else if(action==='terminal'){window.open(`/app/terminal.html?server_id=${_currentServerId}&path=${encodeURIComponent(_ctxPath.replace(/\/[^/]+$/,'')||'/')}`,'_blank')}
else if(action==='compress')doCompress([_ctxPath],_ctxName);
else if(action==='decompress')doDecompress(_ctxPath);
});
// ── F-3: chmod ──
async function doChmod(path,name){
// Get current perms from _allEntries
const entry=_allEntries.find(e=>{const fp=_currentPath.replace(/\/$/,'')+'/'+e.name;return fp===path});
const currentPerms=entry?.perms||'';
showModal({title:`🔐 修改权限 — ${name}`,text:`当前权限: ${currentPerms}`,input:true,inputDefault:'',confirmText:'修改',
onConfirm:async(mode)=>{
if(!mode.match(/^[0-7]{3,4}$/)){toast('error','权限格式错误,如 755');return}
const r=await apiFetch(API+'/sync/chmod',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path,mode})});
if(r&&r.ok){toast('success',`权限已修改: ${mode}`);browseDir(_currentPath)}
else{const e=r?await r.json().catch(()=>({})):{};toast('error','修改失败: '+(e.detail||''))}
}
});
}
// ── F-6: Compress ──
async function doCompress(paths,baseName){
const dest=_currentPath.replace(/\/$/,'')+'/'+(baseName||'archive')+'.tar.gz';
showModal({title:'📦 压缩文件',text:`${paths.length} 个文件压缩为:\n${dest}`,input:true,inputDefault:dest,confirmText:'压缩',
onConfirm:async(destPath)=>{
if(!destPath)return;
const r=await apiFetch(API+'/sync/compress',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,paths,dest:destPath,format:'tar.gz'})});
if(r&&r.ok){toast('success','压缩完成');browseDir(_currentPath)}
else{const e=r?await r.json().catch(()=>({})):{};toast('error','压缩失败: '+(e.detail||''))}
}
});
}
// ── F-7: Decompress ──
async function doDecompress(path){
const dest=_currentPath.replace(/\/$/,'')+'/';
showModal({title:'📂 解压文件',text:`解压到: ${dest}`,confirmText:'解压',
onConfirm:async()=>{
const r=await apiFetch(API+'/sync/decompress',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path,dest})});
if(r&&r.ok){toast('success','解压完成');browseDir(_currentPath)}
else{const e=r?await r.json().catch(()=>({})):{};toast('error','解压失败: '+(e.detail||''))}
}
});
}
// ── F-2: New file ──
async function doNewFile(){
if(!_currentServerId){toast('warning','请先选择服务器');return}
showModal({title:'📄 新建文件',input:true,inputDefault:'',confirmText:'创建',
onConfirm:async(name)=>{
if(!name)return;
if(name.includes('/')){toast('error','文件名不能包含 /');return}
const path=_currentPath.replace(/\/$/,'')+'/'+name;
const r=await apiFetch(API+'/sync/write-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path,content:''})});
if(r&&r.ok){toast('success',`已创建: ${name}`);browseDir(_currentPath)}
else{const e=r?await r.json().catch(()=>({})):{};toast('error','创建失败: '+(e.detail||''))}
}
});
}
// ── F-4: Sort toggle ──
let _sortBy='name';let _sortAsc=true;
function setSort(by){
if(_sortBy===by){_sortAsc=!_sortAsc}else{_sortBy=by;_sortAsc=true}
renderFileList(_allEntries);
}
function sortEntries(entries){
return[...entries].sort((a,b)=>{
if(a.is_dir!==b.is_dir)return b.is_dir-a.is_dir;
let cmp=0;
if(_sortBy==='name')cmp=a.name.localeCompare(b.name);
else if(_sortBy==='size')cmp=parseInt(a.size||0)-parseInt(b.size||0);
else if(_sortBy==='time')cmp=(a.modified||'').localeCompare(b.modified||'');
return _sortAsc?cmp:-cmp;
});
}
// ── F-5: File type filter ──
let _filterExt='';
function filterByExt(ext){_filterExt=ext;renderFileList(_allEntries)}
/** H-29: HTML-escape a string for safe innerHTML insertion */
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
// ── Open SSH terminal to current server/path ──
function openTerminal(){
if(!_currentServerId){toast('warning','请先选择服务器');return}
const path=_currentPath||'/www/wwwroot';
window.open(`/app/terminal.html?server_id=${_currentServerId}&path=${encodeURIComponent(path)}`,'_blank');
}
// ── Server change: reset path and clear file list ──
function onServerChange(){
document.getElementById('dirPath').value='/www/wwwroot';
document.getElementById('breadcrumb').classList.add('hidden');
fileListEl.innerHTML='<div class="px-4 py-12 text-center text-slate-500"><div class="text-4xl mb-3">📂</div><p>点击「浏览」查看目录</p></div>';
_fileCache={};
}
// ── Modal system (P2-9) ──
let _modalResolve=null;
function showModal({title,text,input,textarea,inputDefault,textareaValue,confirmText,danger,onConfirm}){
document.getElementById('modalTitle').textContent=title;
const textEl=document.getElementById('modalText');
const inputEl=document.getElementById('modalInput');
const textareaEl=document.getElementById('modalTextarea');
const confirmBtn=document.getElementById('modalConfirm');
textEl.classList.toggle('hidden',!text);
textEl.textContent=text||'';
inputEl.classList.toggle('hidden',!input);
if(input){inputEl.value=inputDefault||'';setTimeout(()=>inputEl.focus(),50)}
textareaEl.classList.toggle('hidden',!textarea);
if(textarea){textareaEl.value=textareaValue||'';setTimeout(()=>textareaEl.focus(),50)}
confirmBtn.textContent=confirmText||'确认';
confirmBtn.className=`px-4 py-2 text-white text-sm rounded-lg transition ${danger?'bg-red-600 hover:bg-red-700':'bg-brand hover:bg-brand-dark'}`;
document.getElementById('modal').classList.remove('hidden');
_modalResolve=onConfirm;
}
function closeModal(){
document.getElementById('modal').classList.add('hidden');_modalResolve=null;
}
document.getElementById('modalConfirm').onclick=()=>{
if(_modalResolve){
const inputEl=document.getElementById('modalInput');
const textareaEl=document.getElementById('modalTextarea');
const val=textareaEl.classList.contains('hidden')?inputEl.value:textareaEl.value;
_modalResolve(val);
}
closeModal();
};
document.getElementById('modalInput').addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('modalConfirm').click()});
document.getElementById('modal').addEventListener('click',e=>{if(e.target===document.getElementById('modal'))closeModal()});
// ── Servers (P3-4: refresh button) ──
async function loadServers(){
const r=await apiFetch(API+'/servers/');if(!r)return;
const data=await r.json();const servers=data.items||data;
document.getElementById('serverSelect').innerHTML='<option value="">-- 选择服务器 --</option>'+servers.map(s=>`<option value="${s.id}">${esc(s.name)} (${esc(s.domain)})</option>`).join('');
const sel=document.getElementById('serverSelect');
const prev=sel.value;
sel.innerHTML='<option value="">-- 选择服务器 --</option>'+servers.map(s=>`<option value="${s.id}">${esc(s.name)} (${esc(s.domain)})</option>`).join('');
if(prev)sel.value=prev;
toast('info','服务器列表已刷新');
}
// ── Browse directory ──
async function browseDir(path){
const sid=document.getElementById('serverSelect').value;
if(path){document.getElementById('dirPath').value=path;_currentPath=path}
else{_currentPath=document.getElementById('dirPath')?.value||'/'}
if(!sid){toast('warning','请先选择服务器');return}
_currentServerId=parseInt(sid);
_currentServerId=parseInt(sid);_selectedPaths.clear();updateBatchBar();
document.getElementById('fileList').innerHTML='<div class="px-4 py-8 text-center text-slate-500">加载中...</div>';
fileListEl.innerHTML='<div class="px-4 py-12 text-center text-slate-500"><div class="text-2xl mb-2 animate-spin">⏳</div>加载中...</div>';
try{
const r=await apiFetch(API+'/sync/browse',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path:_currentPath})});
if(!r){document.getElementById('fileList').innerHTML='<div class="px-4 py-8 text-center text-red-400">认证失败</div>';return}
if(!r){fileListEl.innerHTML='<div class="px-4 py-12 text-center text-red-400">认证失败</div>';return}
const d=await r.json();
if(d.error){document.getElementById('fileList').innerHTML=`<div class="px-4 py-8 text-center text-red-400">${esc(d.error)}</div>`;toast('error','浏览失败: '+d.error.substring(0,50));return}
if(d.error){fileListEl.innerHTML=`<div class="px-4 py-12 text-center text-red-400">${esc(d.error)}</div>`;toast('error','浏览失败: '+d.error.substring(0,50));return}
// Update breadcrumb
updateBreadcrumb(_currentPath);
// Build file list
let html='';
// Parent directory link (if not root)
if(_currentPath!=='/'){
const parentPath=_currentPath.replace(/\/[^/]+\/?$/,'')||'/';
html+=`<div class="flex items-center gap-3 px-4 py-2 border-b border-slate-800 hover:bg-slate-800/50 cursor-pointer" onclick="browseDir('${escAttr(parentPath)}')">
<span class="text-slate-500">📂</span><span class="text-slate-400 text-sm">..</span><span class="text-slate-600 text-xs">返回上级目录</span>
</div>`;
}
if(d.entries.length){
// Sort: directories first, then files
const sorted=[...d.entries].sort((a,b)=>(b.is_dir-a.is_dir)||a.name.localeCompare(b.name));
html+=sorted.map(e=>{
const fullPath=_currentPath.replace(/\/$/,'')+'/'+e.name;
return `<div class="group flex items-center gap-3 px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50">
<span class="${e.is_dir?'text-yellow-400':'text-slate-400'} text-sm shrink-0">${e.is_dir?'📁':'📄'}</span>
<span class="text-white text-sm flex-1 ${e.is_dir?'cursor-pointer hover:text-brand-light':''}" ${e.is_dir?`onclick="browseDir('${escAttr(fullPath)}')"`:''} id="name_${escAttr(fullPath)}">${esc(e.name)}</span>
<span class="text-slate-500 text-xs tabular-nums w-16 text-right shrink-0">${e.is_dir?'--':e.size}</span>
<span class="text-slate-600 text-xs shrink-0 hidden sm:block">${esc(e.perms)}</span>
<span class="text-slate-600 text-xs shrink-0 hidden md:block">${esc(e.owner||'')}</span>
<!-- Action buttons (appear on row hover) -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onclick="doRename('${escAttr(fullPath)}','${escAttr(e.name)}')" class="px-2 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded transition" title="重命名">✏</button>
<button onclick="doDelete('${escAttr(fullPath)}',${e.is_dir})" class="px-2 py-1 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/30 rounded transition" title="${e.is_dir?'删除目录':'删除文件'}">🗑</button>
</div>
</div>`;
}).join('');
}else if(_currentPath==='/'){
html+='<div class="px-4 py-8 text-center text-slate-500">空目录</div>';
}
document.getElementById('fileList').innerHTML=html;
_allEntries=d.entries||[];
renderFileList(_allEntries);
preloadFiles(_allEntries); // Background preload small text files
if(_editorVisible)renderFileTree(); // Update IDE file tree
}catch(e){
document.getElementById('fileList').innerHTML=`<div class="px-4 py-8 text-center text-red-400">连接失败: ${esc(e.message)}</div>`;
fileListEl.innerHTML=`<div class="px-4 py-12 text-center text-red-400">连接失败: ${esc(e.message)}</div>`;
toast('error','连接失败');
}
}
function renderFileList(entries){
let html='';
// Parent directory
if(_currentPath!=='/'){
const parentPath=_currentPath.replace(/\/[^/]+\/?$/,'')||'/';
html+=`<div class="flex items-center gap-3 px-4 py-2 border-b border-slate-800 hover:bg-slate-800/50 cursor-pointer" data-action="browse" data-path="${esc(parentPath)}">
<span class="text-slate-500">📂</span><span class="text-slate-400 text-sm">..</span><span class="text-slate-600 text-xs">返回上级目录</span>
</div>`;
}
// P2-10: Select all checkbox
if(entries.length){
html+=`<div class="flex items-center gap-3 px-4 py-1.5 border-b border-slate-800 bg-slate-800/30 text-xs text-slate-500">
<input type="checkbox" data-action="select-all" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand cursor-pointer">
<span class="flex-1">全选</span><span class="w-16 text-right">大小</span><span class="w-20 hidden sm:block">权限</span><span class="w-16 hidden md:block">所有者</span><span class="w-20"></span>
</div>`;
}
// File list
const filtered=_filterExt?entries.filter(e=>{if(e.is_dir)return true;const ext=e.name.includes('.')?e.name.split('.').pop().toLowerCase():'';return ext===_filterExt}):entries;
const sorted=sortEntries(filtered);
html+=sorted.map(e=>{
const fi=fileIcon(e.name,e.is_dir,e.is_link);
const fullPath=_currentPath.replace(/\/$/,'')+'/'+e.name;
const linkHint=e.is_link?`title="→ ${esc(e.link_target||'')}"`:'';
return `<div class="file-row group flex items-center gap-3 px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50" data-name="${esc(e.name.toLowerCase())}">
<input type="checkbox" data-action="select" data-path="${esc(fullPath)}" class="file-cb rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand shrink-0">
<span class="${fi.color} text-sm shrink-0" ${linkHint}>${fi.icon}</span>
<span class="text-white text-sm flex-1 truncate ${e.is_dir?'cursor-pointer hover:text-brand-light':''}" ${e.is_dir?`data-action="browse" data-path="${esc(fullPath)}"`:''} ${linkHint}>${esc(e.name)}</span>
<span class="text-slate-500 text-xs tabular-nums w-16 text-right shrink-0">${e.is_dir?'--':formatSize(e.size)}</span>
<span class="text-slate-600 text-xs shrink-0 w-20 hidden sm:block">${esc(e.perms)}</span>
<span class="text-slate-600 text-xs shrink-0 w-16 hidden md:block truncate">${esc(e.owner||'')}</span>
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 w-20 justify-end">
${!e.is_dir?`<button data-action="preview" data-path="${esc(fullPath)}" class="px-1.5 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded" title="预览">👁</button>`:''}
${!e.is_dir?`<button data-action="download" data-path="${esc(fullPath)}" data-name="${esc(e.name)}" class="px-1.5 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded" title="下载">⬇</button>`:''}
<button data-action="rename" data-path="${esc(fullPath)}" data-name="${esc(e.name)}" class="px-1.5 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded" title="重命名">✏</button>
<button data-action="delete" data-path="${esc(fullPath)}" data-is-dir="${e.is_dir}" class="px-1.5 py-1 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/30 rounded" title="${e.is_dir?'删除目录':'删除文件'}">🗑</button>
</div>
</div>`;
}).join('');
// P3-3: Empty state
if(!entries.length){
html+=`<div class="px-4 py-12 text-center"><div class="text-4xl mb-3">📂</div><p class="text-slate-400 mb-3">空目录</p><button onclick="pickUploadFile()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">上传文件</button></div>`;
}
fileListEl.innerHTML=html;
document.getElementById('fileSearch').value='';
}
// ── P3-1: File search filter ──
function filterFiles(q){
q=q.toLowerCase();
document.querySelectorAll('.file-row').forEach(row=>{
row.style.display=!q||row.dataset.name.includes(q)?'':'none';
});
}
// ── P2-10: Selection ──
function toggleSelect(cb,path){
if(cb.checked)_selectedPaths.add(path);else _selectedPaths.delete(path);
updateBatchBar();
}
function toggleSelectAll(cb){
const cbs=document.querySelectorAll('.file-cb');
cbs.forEach(c=>{c.checked=cb.checked;const p=c.dataset.path;if(p){cb.checked?_selectedPaths.add(p):_selectedPaths.delete(p)}});
updateBatchBar();
}
function clearSelection(){_selectedPaths.clear();document.querySelectorAll('.file-cb').forEach(c=>c.checked=false);updateBatchBar()}
function updateBatchBar(){
const bar=document.getElementById('batchBar');
const n=_selectedPaths.size;
bar.classList.toggle('hidden',n===0);
document.getElementById('batchCount').textContent=`已选择 ${n}`;
}
async function batchDelete(){
const paths=[..._selectedPaths];if(!paths.length)return;
showModal({
title:'⚠️ 批量删除',text:`确定要删除选中的 ${paths.length} 项?此操作不可恢复!`,
confirmText:`删除 ${paths.length}`,danger:true,
onConfirm:async()=>{
let ok=0,fail=0;
for(const p of paths){
const isDir=_allEntries.some(e=>{const fp=_currentPath.replace(/\/$/,'')+'/'+e.name;return fp===p&&e.is_dir});
if(await fileOp('delete',p)){ok++}else{fail++}
}
toast('success',`批量删除完成: ${ok} 成功${fail?`, ${fail} 失败`:''}`);
clearSelection();browseDir(_currentPath);
}
});
}
// ── Breadcrumb ──
document.getElementById('breadcrumbPath').addEventListener('click',e=>{
const el=e.target.closest('[data-action]');
if(!el)return;
const action=el.dataset.action;
if(action==='browse')browseDir(el.dataset.path);
});
function updateBreadcrumb(path){
const bc=document.getElementById('breadcrumb');
const bcPath=document.getElementById('breadcrumbPath');
bc.classList.remove('hidden');
// Split path into segments
const segments=path.split('/').filter(Boolean);
let html=`<span class="cursor-pointer text-brand-light hover:underline" onclick="browseDir('/')">/</span>`;
let html=`<span class="cursor-pointer text-brand-light hover:underline shrink-0" data-action="browse" data-path="/">/</span>`;
let accumulated='';
segments.forEach((seg,i)=>{
accumulated+='/'+seg;
const p=accumulated;
accumulated+='/'+seg;const p=accumulated;
const sep=i===0?'':'<span class="text-slate-600">/</span>';
if(i<segments.length-1){
html+=`<span class="text-slate-600">/</span><span class="cursor-pointer text-brand-light hover:underline" onclick="browseDir('${escAttr(p)}')">${esc(seg)}</span>`;
html+=`${sep}<span class="cursor-pointer text-brand-light hover:underline" data-action="browse" data-path="${esc(p)}">${esc(seg)}</span>`;
}else{
html+=`<span class="text-slate-600">/</span><span class="text-white font-medium">${esc(seg)}</span>`;
html+=`${sep}<span class="text-white font-medium">${esc(seg)}</span>`;
}
});
bcPath.innerHTML=html;
}
// ── File Operations ──
async function fileOp(operation, path, newPath){
async function fileOp(operation,path,newPath){
if(!_currentServerId){toast('warning','请先选择服务器');return false}
const body={server_id:_currentServerId, operation, path};
const body={server_id:_currentServerId,operation,path};
if(newPath)body.new_path=newPath;
const r=await apiFetch(API+'/sync/file-ops',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
if(!r){toast('error','请求失败');return false}
@@ -129,72 +529,322 @@
return true;
}
async function doDelete(fullPath, isDir){
// P1-1: Delete with double confirmation for directories
async function doDelete(fullPath,isDir){
const name=fullPath.split('/').pop();
const warn=isDir?`确定要删除目录 "${name}" 及其所有内容吗?此操作不可恢复!`:`确定要删除文件 "${name}" 吗?`;
if(!confirm(warn))return;
if(await fileOp('delete', fullPath)){
toast('success', `已删除: ${name}`);
browseDir(_currentPath);
if(isDir){
showModal({
title:'⚠️ 危险操作',
text:`即将递归删除目录「${name}」及其所有内容,此操作不可恢复!\n请输入目录名以确认:`,
input:true,inputDefault:'',
confirmText:'确认删除',danger:true,
onConfirm:async(val)=>{
if(val!==name){toast('error','目录名不匹配,取消删除');return}
if(await fileOp('delete',fullPath)){toast('success',`已删除目录: ${name}`);browseDir(_currentPath)}
}
});
}else{
showModal({
title:'删除文件',text:`确定要删除文件「${name}」?`,
confirmText:'删除',danger:true,
onConfirm:async()=>{if(await fileOp('delete',fullPath)){toast('success',`已删除: ${name}`);browseDir(_currentPath)}}
});
}
}
async function doRename(fullPath, oldName){
const dir=fullPath.slice(0, fullPath.lastIndexOf('/')) || '/';
const newName=prompt(`重命名「${oldName}」为:`, oldName);
if(!newName||newName===oldName)return;
if(newName.includes('/')){toast('error','文件名不能包含 /');return}
const newPath=dir.replace(/\/$/,'')+'/'+newName;
if(await fileOp('rename', fullPath, newPath)){
toast('success', `已重命名: ${oldName}${newName}`);
browseDir(_currentPath);
}
async function doRename(fullPath,oldName){
const dir=fullPath.slice(0,fullPath.lastIndexOf('/'))||'/';
showModal({
title:'重命名',input:true,inputDefault:oldName,confirmText:'确认',
onConfirm:async(newName)=>{
if(!newName||newName===oldName)return;
if(newName.includes('/')){toast('error','文件名不能包含 /');return}
const newPath=dir.replace(/\/$/,'')+'/'+newName;
if(await fileOp('rename',fullPath,newPath)){toast('success',`已重命名: ${oldName}${newName}`);browseDir(_currentPath)}
}
});
}
async function doMkdir(){
if(!_currentServerId){toast('warning','请先选择服务器');return}
const name=prompt('新建目录名称:', '');
if(!name)return;
if(name.includes('/')){toast('error','目录名不能包含 /');return}
const fullPath=_currentPath.replace(/\/$/,'')+'/'+name;
if(await fileOp('mkdir', fullPath)){
toast('success', `已创建目录: ${name}`);
browseDir(_currentPath);
}
showModal({
title:'新建目录',input:true,inputDefault:'',confirmText:'创建',
onConfirm:async(name)=>{
if(!name)return;
if(name.includes('/')){toast('error','目录名不能包含 /');return}
const fullPath=_currentPath.replace(/\/$/,'')+'/'+name;
if(await fileOp('mkdir',fullPath)){toast('success',`已创建目录: ${name}`);browseDir(_currentPath)}
}
});
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function escAttr(s){if(!s)return'';return s.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
// P2-7: Download
async function doDownload(path,name){
toast('info',`正在下载 ${name}...`);
const r=await apiFetch(API+'/sync/download',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path})});
if(!r||!r.ok){const e=r?await r.json().catch(()=>({})):{};toast('error','下载失败: '+(e.detail||''));return}
const blob=await r.blob();
const url=URL.createObjectURL(blob);
const a=document.createElement('a');a.href=url;a.download=name;a.click();
URL.revokeObjectURL(url);toast('success',`已下载: ${name}`);
}
// ── File Upload ──
// ── File content preload + cache ──
const PRELOAD_MAX_SIZE=1000000;
const PRELOAD_MAX_FILES=10;
const PRELOAD_TEXT_EXTS=new Set(['conf','cfg','ini','toml','sh','bash','py','js','ts','json','yml','yaml','html','htm','css','xml','sql','md','env','txt','log','service','nginx','vhost','properties']);
function isTextFile(name){const ext=name.includes('.')?name.split('.').pop().toLowerCase():'';return PRELOAD_TEXT_EXTS.has(ext)||ext.length<=4}
async function preloadFiles(entries){
const toPreload=entries.filter(e=>!e.is_dir&&!e.is_link&&parseInt(e.size)<=PRELOAD_MAX_SIZE&&parseInt(e.size)>0&&isTextFile(e.name)).slice(0,PRELOAD_MAX_FILES);
await Promise.allSettled(toPreload.map(async e=>{
const path=_currentPath.replace(/\/$/,'')+'/'+e.name;
if(_fileCache[path])return;
try{const r=await apiFetch(API+'/sync/read-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path})});
if(r&&r.ok){const d=await r.json();_fileCache[path]={content:d.content,size:d.size}}}catch(e){}
}));
}
// P2-8: Preview/Edit (with cache) → IDE editor
async function doPreview(path){
const name=path.split('/').pop();
const cached=_fileCache[path];
if(cached){openFileInEditor(path,name,cached.content);return}
toast('info','正在读取文件...');
const r=await apiFetch(API+'/sync/read-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path})});
if(!r||!r.ok){const e=r?await r.json().catch(()=>({})):{};toast('error','读取失败: '+(e.detail||''));return}
const d=await r.json();
_fileCache[path]={content:d.content,size:d.size};
openFileInEditor(path,name,d.content);
}
// ── IDE Editor ──
const LANG_MAP={conf:'ini',cfg:'ini',ini:'ini',toml:'ini',sh:'shell',bash:'shell',zsh:'shell',
py:'python',js:'javascript',ts:'typescript',json:'json',yml:'yaml',yaml:'yaml',
html:'html',htm:'html',css:'css',xml:'xml',sql:'sql',md:'markdown',
env:'ini',txt:'plaintext',log:'plaintext',service:'ini',nginx:'ini',properties:'ini',
go:'go',rs:'rust',rb:'ruby',java:'java',php:'php',c:'cpp',h:'cpp',cpp:'cpp'};
function getLang(name){const ext=name.includes('.')?name.split('.').pop().toLowerCase():'';return LANG_MAP[ext]||'plaintext'}
let _openTabs=[]; // [{path, name, content, originalContent, language, editor, container, modified}]
let _activeTab=-1;
let _editorVisible=false;
let _editorMinimized=false;
function openFileInEditor(path,name,content){
// Check if already open
const existing=_openTabs.findIndex(t=>t.path===path);
if(existing>=0){switchTab(existing);return}
// Add new tab
const lang=getLang(name);
const container=document.createElement('div');
container.className='absolute inset-0';
container.style.display='none';
document.getElementById('editorMonacoWrap').appendChild(container);
const tab={path,name,content,originalContent:content,language:lang,editor:null,container,modified:false};
_openTabs.push(tab);
const idx=_openTabs.length-1;
// Create Monaco instance
if(window._monacoReady){
tab.editor=monaco.editor.create(container,{
value:content,language:lang,theme:'vs-dark',
automaticLayout:true,minimap:{enabled:true,side:'right'},
wordWrap:'on',fontSize:13,lineNumbers:'on',
scrollBeyondLastLine:false,bracketPairColorization:{enabled:true},
scrollbar:{verticalScrollbarSize:10},
});
tab.editor.addCommand(monaco.KeyMod.CtrlCmd|monaco.KeyCode.KeyS,()=>saveCurrentTab());
tab.editor.onDidChangeModelContent(()=>{
tab.modified=tab.editor.getValue()!==tab.originalContent;
updateStatusBar();renderTabBar();
});
tab.editor.onDidChangeCursorPosition(e=>{
document.getElementById('editorPos').textContent=`Ln ${e.position.lineNumber}, Col ${e.position.column}`;
});
}
switchTab(idx);
showEditorPanel();
renderFileTree();
}
function switchTab(idx){
if(idx<0||idx>=_openTabs.length)return;
// Hide current
if(_activeTab>=0&&_openTabs[_activeTab]){
const prev=_openTabs[_activeTab];
if(prev.container)prev.container.style.display='none';
}
_activeTab=idx;
const tab=_openTabs[idx];
if(tab.container)tab.container.style.display='';
if(tab.editor){tab.editor.focus();tab.editor.layout()}
updateStatusBar();renderTabBar();renderMinBar();
}
function closeTab(idx){
if(idx<0||idx>=_openTabs.length)return;
const tab=_openTabs[idx];
if(tab.modified&&!confirm(`文件 "${tab.name}" 有未保存的修改,确定关闭?`))return;
if(tab.editor)tab.editor.dispose();
if(tab.container)tab.container.remove();
_openTabs.splice(idx,1);
if(_openTabs.length===0){hideEditorPanel();return}
if(_activeTab>=_openTabs.length)_activeTab=_openTabs.length-1;
switchTab(_activeTab);
}
function closeAllTabs(){
const unsaved=_openTabs.filter(t=>t.modified).length;
if(unsaved&&!confirm(`${unsaved} 个文件未保存,确定全部关闭?`))return;
_openTabs.forEach(t=>{if(t.editor)t.editor.dispose();if(t.container)t.container.remove()});
_openTabs=[];_activeTab=-1;
hideEditorPanel();
}
function saveCurrentTab(){
if(_activeTab<0)return;
const tab=_openTabs[_activeTab];
const content=tab.editor?tab.editor.getValue():tab.content;
if(content===tab.originalContent){toast('info','无变更');return}
apiFetch(API+'/sync/write-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path:tab.path,content})}).then(r=>{
if(r&&r.ok){toast('success','已保存');tab.originalContent=content;tab.modified=false;_fileCache[tab.path]={content,size:content.length};updateStatusBar();renderTabBar()}
else{r?r.json().then(e=>toast('error','保存失败: '+(e.detail||''))).catch(()=>toast('error','保存失败')):toast('error','保存失败')}
});
}
function showEditorPanel(){
_editorVisible=true;_editorMinimized=false;
document.getElementById('editorPanel').classList.remove('hidden');
document.getElementById('editorMinBar').classList.add('hidden');
// Trigger layout on active Monaco
setTimeout(()=>{if(_activeTab>=0&&_openTabs[_activeTab].editor)_openTabs[_activeTab].editor.layout()},50);
}
function hideEditorPanel(){
_editorVisible=false;
document.getElementById('editorPanel').classList.add('hidden');
document.getElementById('editorMinBar').classList.add('hidden');
renderMinBar();
}
function minimizeEditor(){
_editorMinimized=true;
document.getElementById('editorPanel').classList.add('hidden');
document.getElementById('editorMinBar').classList.remove('hidden');
renderMinBar();
}
function restoreEditor(){
_editorMinimized=false;
document.getElementById('editorPanel').classList.remove('hidden');
document.getElementById('editorMinBar').classList.add('hidden');
if(_activeTab>=0&&_openTabs[_activeTab].editor){setTimeout(()=>_openTabs[_activeTab].editor.layout(),50)}
}
function renderTabBar(){
const bar=document.getElementById('editorTabBar');
bar.innerHTML=_openTabs.map((t,i)=>{
const active=i===_activeTab;
const mod=t.modified?'● ':'';
return `<div class="flex items-center gap-1 px-3 py-2 text-xs cursor-pointer border-r border-slate-800 shrink-0 ${active?'bg-slate-800 text-white':'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'}" onclick="switchTab(${i})">
<span class="${t.modified?'text-amber-400':''}">${mod}${esc(t.name)}</span>
<button onclick="event.stopPropagation();closeTab(${i})" class="ml-1 text-slate-600 hover:text-red-400">×</button>
</div>`;
}).join('');
}
function renderMinBar(){
const bar=document.getElementById('editorMinTabs');
bar.innerHTML=_openTabs.map((t,i)=>{
const active=i===_activeTab;
return `<div class="flex items-center gap-1 px-2 py-1 text-xs cursor-pointer rounded ${active?'bg-slate-700 text-white':'text-slate-400 hover:text-slate-200'}" onclick="switchTab(${i});restoreEditor()">
<span class="${t.modified?'text-amber-400':''}">${t.modified?'● ':''}${esc(t.name)}</span>
<button onclick="event.stopPropagation();closeTab(${i})" class="ml-1 text-slate-600 hover:text-red-400">×</button>
</div>`;
}).join('');
}
function updateStatusBar(){
if(_activeTab<0)return;
const tab=_openTabs[_activeTab];
document.getElementById('editorLang').textContent=tab.language;
document.getElementById('editorMod').classList.toggle('hidden',!tab.modified);
document.getElementById('editorFileName').textContent=tab.path;
}
function renderFileTree(){
const list=document.getElementById('editorTreeList');
let html=`<div class="px-3 py-1.5 text-xs text-slate-600 cursor-pointer hover:bg-slate-800" onclick="browseDir('${esc(_currentPath.replace(/\/[^/]+\/?$/,'')||'/')}')">📂 ..</div>`;
const sorted=sortEntries(_allEntries);
sorted.forEach(e=>{
const fp=_currentPath.replace(/\/$/,'')+'/'+e.name;
if(e.is_dir){
html+=`<div class="px-3 py-1 text-xs text-slate-400 cursor-pointer hover:bg-slate-800 truncate" onclick="browseDir('${esc(fp)}')">📁 ${esc(e.name)}</div>`;
}else{
const fi=fileIcon(e.name,false,e.is_link);
const inTab=_openTabs.findIndex(t=>t.path===fp);
const active=inTab===_activeTab;
html+=`<div class="px-3 py-1 text-xs cursor-pointer hover:bg-slate-800 truncate ${active?'bg-slate-800 text-white':'text-slate-400'}" onclick="openFileFromTree('${esc(fp)}','${esc(e.name)}')">
${fi.icon} ${esc(e.name)}${inTab>=0?'<span class="text-brand-light ml-1">●</span>':''}
</div>`;
}
});
list.innerHTML=html;
}
async function openFileFromTree(path,name){
const cached=_fileCache[path];
if(cached){openFileInEditor(path,name,cached.content);return}
const r=await apiFetch(API+'/sync/read-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path})});
if(r&&r.ok){const d=await r.json();_fileCache[path]={content:d.content,size:d.size};openFileInEditor(path,name,d.content)}
else{const e=r?await r.json().catch(()=>({})):{};toast('error','读取失败: '+(e.detail||''))}
}
function toggleEditorTree(){
const tree=document.getElementById('editorTree');
tree.classList.toggle('hidden');
if(_activeTab>=0&&_openTabs[_activeTab].editor)setTimeout(()=>_openTabs[_activeTab].editor.layout(),50);
}
}
// ── Upload (P2-4: progress bar) ──
function pickUploadFile(){document.getElementById('uploadFileInput').click()}
async function doUploadFiles(files){
if(!_currentServerId){toast('warning','请先选择服务器');return}
if(!files||!files.length)return;
const remotePath=_currentPath||'/';
const progressEl=document.getElementById('uploadProgress');
const barEl=document.getElementById('uploadBar');
const fileEl=document.getElementById('uploadFileName');
const pctEl=document.getElementById('uploadPercent');
progressEl.classList.remove('hidden');
let ok=0,fail=0;
for(const file of files){
const formData=new FormData();
formData.append('server_id',_currentServerId);
formData.append('remote_path',remotePath);
formData.append('file',file);
for(let i=0;i<files.length;i++){
const file=files[i];
fileEl.textContent=`[${i+1}/${files.length}] ${file.name}`;
barEl.style.width='0%';pctEl.textContent='0%';
try{
const r=await apiFetch(API+'/sync/upload',{method:'POST',body:formData});
if(r&&r.ok){ok++}else{
fail++;
const e=await r.json().catch(()=>({detail:'上传失败'}));
toast('error',`${file.name}: ${(e.detail||'未知错误').substring(0,60)}`);
}
await new Promise((resolve,reject)=>{
const fd=new FormData();
fd.append('server_id',_currentServerId);
fd.append('remote_path',remotePath);
fd.append('file',file);
const xhr=new XMLHttpRequest();
xhr.upload.onprogress=e=>{if(e.lengthComputable){const p=Math.round(e.loaded/e.total*100);barEl.style.width=p+'%';pctEl.textContent=p+'%'}};
xhr.onload=()=>{
if(xhr.status===200){ok++;resolve()}
else{fail++;try{const d=JSON.parse(xhr.responseText);toast('error',`${file.name}: ${(d.detail||'上传失败').substring(0,60)}`)}catch(e){toast('error',`${file.name}: 上传失败 (${xhr.status})`)}resolve()}
};
xhr.onerror=()=>{fail++;toast('error',`${file.name}: 网络错误`);resolve()};
xhr.open('POST',API+'/sync/upload');
xhr.setRequestHeader('Authorization','Bearer '+access_token);
xhr.send(fd);
});
}catch(e){fail++;toast('error',`${file.name}: ${e.message}`)}
}
if(ok>0)toast('success',`上传完成: ${ok} 个文件${fail?', '+fail+' 失败':''}`);
progressEl.classList.add('hidden');
if(ok>0)toast('success',`上传完成: ${ok} 个文件${fail?`, ${fail} 失败`:''}`);
if(ok>0)browseDir(_currentPath);
document.getElementById('uploadFileInput').value='';
}
// Drag & drop support on file list area
const fileListEl=document.getElementById('fileList');
// ── Drag & drop ──
fileListEl.addEventListener('dragover',e=>{e.preventDefault();e.stopPropagation();fileListEl.classList.add('ring-2','ring-brand','ring-offset-2','ring-offset-slate-950')});
fileListEl.addEventListener('dragleave',e=>{e.preventDefault();e.stopPropagation();fileListEl.classList.remove('ring-2','ring-brand','ring-offset-2','ring-offset-slate-950')});
fileListEl.addEventListener('drop',e=>{
@@ -203,9 +853,18 @@
if(e.dataTransfer.files.length)doUploadFiles(e.dataTransfer.files);
});
// Enter key in path input triggers browse
// Enter key in path input
document.getElementById('dirPath').addEventListener('keydown',e=>{if(e.key==='Enter')browseDir()});
loadServers();
// Auto-select server from URL param (e.g., from servers.html "文件" button)
loadServers().then(()=>{
const urlServerId=new URLSearchParams(window.location.search).get('server_id');
if(urlServerId){
const sel=document.getElementById('serverSelect');
sel.value=urlServerId;
_currentServerId=parseInt(urlServerId);
browseDir('/www/wwwroot');
}
});
</script>
</body></html>
+1
View File
@@ -359,6 +359,7 @@
function renderActions(s){
let html=`<a href="/app/terminal.html?server_id=${s.id}" onclick="event.stopPropagation()" class="text-brand-light hover:underline text-xs mr-2">终端</a>`;
html+=`<a href="/app/files.html?server_id=${s.id}" onclick="event.stopPropagation()" class="text-amber-400 hover:underline text-xs mr-2">文件</a>`;
if(s.agent_version) html+=`<button onclick="event.stopPropagation();_quickCheck(${s.id})" class="text-emerald-400 hover:underline text-xs mr-2">检查</button>`;
html+=`<button onclick="event.stopPropagation();showEditServer(${s.id})" class="text-slate-400 hover:underline text-xs mr-2">编辑</button>`;
html+=`<button onclick="event.stopPropagation();deleteServer(${s.id})" class="text-red-400 hover:underline text-xs">删除</button>`;
+7
View File
@@ -119,6 +119,7 @@ initLayout('servers');
// ── Parse params ──
const params = new URLSearchParams(window.location.search);
const initialServerId = parseInt(params.get('server_id'));
const initialPath = params.get('path') || ''; // Auto-cd after connect
if (!initialServerId) window.location.href = '/app/servers.html';
// ── localStorage keys ──
@@ -357,6 +358,12 @@ async function connectSession(idx) {
if (d && s.ws && s.ws.readyState === WebSocket.OPEN) {
s.ws.send(JSON.stringify({ type: 'RESIZE', cols: d.cols, rows: d.rows }));
}
// Auto-cd to initial path if specified in URL
if (initialPath && s.ws && s.ws.readyState === WebSocket.OPEN) {
setTimeout(() => {
s.ws.send(JSON.stringify({ type: 'DATA', data: `cd ${initialPath}\r` }));
}, 200);
}
break;
case 'DATA':
s.term.write(msg.data);