Compare commits
20 Commits
36fde6e6ed
...
be633f3e58
| Author | SHA1 | Date | |
|---|---|---|---|
| be633f3e58 | |||
| 00a3309005 | |||
| 3d15200cfa | |||
| d8aca5bf18 | |||
| cb60af4483 | |||
| 5a5adafb39 | |||
| c0296b9d8e | |||
| ca4aceba3e | |||
| b5dfa8a870 | |||
| 17cdc96bb0 | |||
| 6012af0b4d | |||
| 9bdf7e420f | |||
| 40d672fdbb | |||
| 557c8d28c1 | |||
| 9f7be66356 | |||
| ac38442383 | |||
| 14131f98f0 | |||
| 64503c0260 | |||
| d19f5af807 | |||
| 463841a740 |
@@ -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 端点)
|
||||||
@@ -192,6 +192,26 @@ class FileOperation(BaseModel):
|
|||||||
new_path: Optional[str] = None # required for rename
|
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 ──
|
# ── Script ──
|
||||||
|
|
||||||
class ScriptCreate(BaseModel):
|
class ScriptCreate(BaseModel):
|
||||||
|
|||||||
+14
-21
@@ -14,7 +14,7 @@ import socket
|
|||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
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.dependencies import get_db
|
||||||
from server.api.auth_jwt import get_current_admin
|
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="当前密码错误")
|
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 ──
|
# ── System Settings ──
|
||||||
|
|
||||||
@router.get("/", response_model=list)
|
@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):
|
class IpAllowlistRequest(BaseModel):
|
||||||
ips: list[str] = Field(..., min_length=1, max_length=100, description="IP or hostname list")
|
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):
|
class SubscriptionParseRequest(BaseModel):
|
||||||
url: str = Field(..., min_length=1, description="Proxy subscription URL to fetch and parse")
|
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
|
subscription_url: Optional[str] = None # None = don't change; "" = clear
|
||||||
enabled: Optional[bool] = None # None = don't change
|
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)
|
@router.post("/ip-allowlist", response_model=dict)
|
||||||
async def set_ip_allowlist(
|
async def set_ip_allowlist(
|
||||||
@@ -958,6 +948,8 @@ async def set_ip_allowlist(
|
|||||||
):
|
):
|
||||||
"""Save manual IPs, subscription URL, and/or enabled toggle."""
|
"""Save manual IPs, subscription URL, and/or enabled toggle."""
|
||||||
from server.config import settings as _settings
|
from server.config import settings as _settings
|
||||||
|
if payload.manual_ips:
|
||||||
|
_validate_ip_list(payload.manual_ips)
|
||||||
|
|
||||||
repo = SettingRepositoryImpl(db)
|
repo = SettingRepositoryImpl(db)
|
||||||
|
|
||||||
@@ -1044,6 +1036,7 @@ async def add_manual_ips(
|
|||||||
):
|
):
|
||||||
"""Append IPs to the manual list and trigger allowlist rebuild."""
|
"""Append IPs to the manual list and trigger allowlist rebuild."""
|
||||||
from server.config import settings as _settings
|
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()]
|
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()]
|
new_ips = [ip.strip() for ip in payload.ips if ip.strip()]
|
||||||
|
|||||||
+158
-4
@@ -9,7 +9,7 @@ import logging
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
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.application.services.sync_engine_v2 import SyncEngineV2
|
||||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||||
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
||||||
@@ -177,12 +177,21 @@ async def browse_directory(
|
|||||||
continue
|
continue
|
||||||
parts = line.split(None, 8)
|
parts = line.split(None, 8)
|
||||||
if len(parts) >= 9:
|
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({
|
entries.append({
|
||||||
"name": parts[8], # Last field after split(None, 8) — handles spaces in names
|
"name": name,
|
||||||
"is_dir": is_dir,
|
"is_dir": is_dir,
|
||||||
|
"is_link": is_link,
|
||||||
|
"link_target": link_target,
|
||||||
"size": parts[4],
|
"size": parts[4],
|
||||||
"perms": parts[0],
|
"perms": perms,
|
||||||
"owner": parts[2],
|
"owner": parts[2],
|
||||||
"modified": " ".join(parts[5:8]),
|
"modified": " ".join(parts[5:8]),
|
||||||
})
|
})
|
||||||
@@ -1043,3 +1052,148 @@ async def verify_sync(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {"results": results, "total_local_files": len(local_files)}
|
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)}
|
||||||
|
|||||||
@@ -132,6 +132,22 @@ if (!token) {
|
|||||||
_recordActivity();
|
_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 ──
|
// ── Global Toast Notification System ──
|
||||||
// Call toast('success', '操作成功') or toast('error', '操作失败') from any page.
|
// Call toast('success', '操作成功') or toast('error', '操作失败') from any page.
|
||||||
|
|
||||||
|
|||||||
+750
-91
@@ -2,123 +2,523 @@
|
|||||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
<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>
|
<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">
|
<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">
|
<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>
|
<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>
|
<h1 class="text-white font-semibold">文件管理</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<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">
|
<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>
|
<option value="">-- 选择服务器 --</option>
|
||||||
</select>
|
</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 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>
|
||||||
<button onclick="browseDir()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">浏览</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="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)">
|
<input id="uploadFileInput" type="file" multiple class="hidden" onchange="doUploadFiles(this.files)">
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<!-- Breadcrumb -->
|
<!-- Upload Progress Bar -->
|
||||||
<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">
|
<div id="uploadProgress" class="hidden bg-slate-900/80 border-b border-slate-800 px-6 py-2">
|
||||||
<span class="text-slate-500">路径:</span>
|
<div class="flex items-center gap-3 text-sm">
|
||||||
<div id="breadcrumbPath" class="flex items-center gap-1"></div>
|
<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>
|
</div>
|
||||||
<main class="flex-1 overflow-y-auto p-6">
|
<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 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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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/api.js"></script>
|
||||||
<script src="/app/layout.js"></script>
|
<script src="/app/layout.js"></script>
|
||||||
<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');
|
initLayout('files');
|
||||||
let _currentPath='/';
|
let _currentPath='/';
|
||||||
let _currentServerId=null;
|
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(){
|
async function loadServers(){
|
||||||
const r=await apiFetch(API+'/servers/');if(!r)return;
|
const r=await apiFetch(API+'/servers/');if(!r)return;
|
||||||
const data=await r.json();const servers=data.items||data;
|
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){
|
async function browseDir(path){
|
||||||
const sid=document.getElementById('serverSelect').value;
|
const sid=document.getElementById('serverSelect').value;
|
||||||
if(path){document.getElementById('dirPath').value=path;_currentPath=path}
|
if(path){document.getElementById('dirPath').value=path;_currentPath=path}
|
||||||
else{_currentPath=document.getElementById('dirPath')?.value||'/'}
|
else{_currentPath=document.getElementById('dirPath')?.value||'/'}
|
||||||
if(!sid){toast('warning','请先选择服务器');return}
|
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{
|
try{
|
||||||
const r=await apiFetch(API+'/sync/browse',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path:_currentPath})});
|
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();
|
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);
|
updateBreadcrumb(_currentPath);
|
||||||
|
_allEntries=d.entries||[];
|
||||||
// Build file list
|
renderFileList(_allEntries);
|
||||||
let html='';
|
preloadFiles(_allEntries); // Background preload small text files
|
||||||
// Parent directory link (if not root)
|
if(_editorVisible)renderFileTree(); // Update IDE file tree
|
||||||
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;
|
|
||||||
}catch(e){
|
}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','连接失败');
|
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){
|
function updateBreadcrumb(path){
|
||||||
const bc=document.getElementById('breadcrumb');
|
const bc=document.getElementById('breadcrumb');
|
||||||
const bcPath=document.getElementById('breadcrumbPath');
|
const bcPath=document.getElementById('breadcrumbPath');
|
||||||
bc.classList.remove('hidden');
|
bc.classList.remove('hidden');
|
||||||
|
|
||||||
// Split path into segments
|
|
||||||
const segments=path.split('/').filter(Boolean);
|
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='';
|
let accumulated='';
|
||||||
segments.forEach((seg,i)=>{
|
segments.forEach((seg,i)=>{
|
||||||
accumulated+='/'+seg;
|
accumulated+='/'+seg;const p=accumulated;
|
||||||
const p=accumulated;
|
const sep=i===0?'':'<span class="text-slate-600">/</span>';
|
||||||
if(i<segments.length-1){
|
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{
|
}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;
|
bcPath.innerHTML=html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── File Operations ──
|
// ── File Operations ──
|
||||||
|
|
||||||
async function fileOp(operation,path,newPath){
|
async function fileOp(operation,path,newPath){
|
||||||
if(!_currentServerId){toast('warning','请先选择服务器');return false}
|
if(!_currentServerId){toast('warning','请先选择服务器');return false}
|
||||||
const body={server_id:_currentServerId,operation,path};
|
const body={server_id:_currentServerId,operation,path};
|
||||||
@@ -129,72 +529,322 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P1-1: Delete with double confirmation for directories
|
||||||
async function doDelete(fullPath,isDir){
|
async function doDelete(fullPath,isDir){
|
||||||
const name=fullPath.split('/').pop();
|
const name=fullPath.split('/').pop();
|
||||||
const warn=isDir?`确定要删除目录 "${name}" 及其所有内容吗?此操作不可恢复!`:`确定要删除文件 "${name}" 吗?`;
|
if(isDir){
|
||||||
if(!confirm(warn))return;
|
showModal({
|
||||||
if(await fileOp('delete', fullPath)){
|
title:'⚠️ 危险操作',
|
||||||
toast('success', `已删除: ${name}`);
|
text:`即将递归删除目录「${name}」及其所有内容,此操作不可恢复!\n请输入目录名以确认:`,
|
||||||
browseDir(_currentPath);
|
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){
|
async function doRename(fullPath,oldName){
|
||||||
const dir=fullPath.slice(0,fullPath.lastIndexOf('/'))||'/';
|
const dir=fullPath.slice(0,fullPath.lastIndexOf('/'))||'/';
|
||||||
const newName=prompt(`重命名「${oldName}」为:`, oldName);
|
showModal({
|
||||||
|
title:'重命名',input:true,inputDefault:oldName,confirmText:'确认',
|
||||||
|
onConfirm:async(newName)=>{
|
||||||
if(!newName||newName===oldName)return;
|
if(!newName||newName===oldName)return;
|
||||||
if(newName.includes('/')){toast('error','文件名不能包含 /');return}
|
if(newName.includes('/')){toast('error','文件名不能包含 /');return}
|
||||||
const newPath=dir.replace(/\/$/,'')+'/'+newName;
|
const newPath=dir.replace(/\/$/,'')+'/'+newName;
|
||||||
if(await fileOp('rename', fullPath, newPath)){
|
if(await fileOp('rename',fullPath,newPath)){toast('success',`已重命名: ${oldName} → ${newName}`);browseDir(_currentPath)}
|
||||||
toast('success', `已重命名: ${oldName} → ${newName}`);
|
|
||||||
browseDir(_currentPath);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doMkdir(){
|
async function doMkdir(){
|
||||||
if(!_currentServerId){toast('warning','请先选择服务器');return}
|
if(!_currentServerId){toast('warning','请先选择服务器');return}
|
||||||
const name=prompt('新建目录名称:', '');
|
showModal({
|
||||||
|
title:'新建目录',input:true,inputDefault:'',confirmText:'创建',
|
||||||
|
onConfirm:async(name)=>{
|
||||||
if(!name)return;
|
if(!name)return;
|
||||||
if(name.includes('/')){toast('error','目录名不能包含 /');return}
|
if(name.includes('/')){toast('error','目录名不能包含 /');return}
|
||||||
const fullPath=_currentPath.replace(/\/$/,'')+'/'+name;
|
const fullPath=_currentPath.replace(/\/$/,'')+'/'+name;
|
||||||
if(await fileOp('mkdir', fullPath)){
|
if(await fileOp('mkdir',fullPath)){toast('success',`已创建目录: ${name}`);browseDir(_currentPath)}
|
||||||
toast('success', `已创建目录: ${name}`);
|
}
|
||||||
browseDir(_currentPath);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
// ── Upload (P2-4: progress bar) ──
|
||||||
function escAttr(s){if(!s)return'';return s.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>')}
|
|
||||||
|
|
||||||
// ── File Upload ──
|
|
||||||
function pickUploadFile(){document.getElementById('uploadFileInput').click()}
|
function pickUploadFile(){document.getElementById('uploadFileInput').click()}
|
||||||
|
|
||||||
async function doUploadFiles(files){
|
async function doUploadFiles(files){
|
||||||
if(!_currentServerId){toast('warning','请先选择服务器');return}
|
if(!_currentServerId){toast('warning','请先选择服务器');return}
|
||||||
if(!files||!files.length)return;
|
if(!files||!files.length)return;
|
||||||
const remotePath=_currentPath||'/';
|
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;
|
let ok=0,fail=0;
|
||||||
for(const file of files){
|
for(let i=0;i<files.length;i++){
|
||||||
const formData=new FormData();
|
const file=files[i];
|
||||||
formData.append('server_id',_currentServerId);
|
fileEl.textContent=`[${i+1}/${files.length}] ${file.name}`;
|
||||||
formData.append('remote_path',remotePath);
|
barEl.style.width='0%';pctEl.textContent='0%';
|
||||||
formData.append('file',file);
|
|
||||||
try{
|
try{
|
||||||
const r=await apiFetch(API+'/sync/upload',{method:'POST',body:formData});
|
await new Promise((resolve,reject)=>{
|
||||||
if(r&&r.ok){ok++}else{
|
const fd=new FormData();
|
||||||
fail++;
|
fd.append('server_id',_currentServerId);
|
||||||
const e=await r.json().catch(()=>({detail:'上传失败'}));
|
fd.append('remote_path',remotePath);
|
||||||
toast('error',`${file.name}: ${(e.detail||'未知错误').substring(0,60)}`);
|
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}`)}
|
}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);
|
if(ok>0)browseDir(_currentPath);
|
||||||
document.getElementById('uploadFileInput').value='';
|
document.getElementById('uploadFileInput').value='';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag & drop support on file list area
|
// ── Drag & drop ──
|
||||||
const fileListEl=document.getElementById('fileList');
|
|
||||||
fileListEl.addEventListener('dragover',e=>{e.preventDefault();e.stopPropagation();fileListEl.classList.add('ring-2','ring-brand','ring-offset-2','ring-offset-slate-950')});
|
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('dragleave',e=>{e.preventDefault();e.stopPropagation();fileListEl.classList.remove('ring-2','ring-brand','ring-offset-2','ring-offset-slate-950')});
|
||||||
fileListEl.addEventListener('drop',e=>{
|
fileListEl.addEventListener('drop',e=>{
|
||||||
@@ -203,9 +853,18 @@
|
|||||||
if(e.dataTransfer.files.length)doUploadFiles(e.dataTransfer.files);
|
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()});
|
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>
|
</script>
|
||||||
</body></html>
|
</body></html>
|
||||||
|
|||||||
@@ -359,6 +359,7 @@
|
|||||||
|
|
||||||
function renderActions(s){
|
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>`;
|
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>`;
|
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();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>`;
|
html+=`<button onclick="event.stopPropagation();deleteServer(${s.id})" class="text-red-400 hover:underline text-xs">删除</button>`;
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ initLayout('servers');
|
|||||||
// ── Parse params ──
|
// ── Parse params ──
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const initialServerId = parseInt(params.get('server_id'));
|
const initialServerId = parseInt(params.get('server_id'));
|
||||||
|
const initialPath = params.get('path') || ''; // Auto-cd after connect
|
||||||
if (!initialServerId) window.location.href = '/app/servers.html';
|
if (!initialServerId) window.location.href = '/app/servers.html';
|
||||||
|
|
||||||
// ── localStorage keys ──
|
// ── localStorage keys ──
|
||||||
@@ -357,6 +358,12 @@ async function connectSession(idx) {
|
|||||||
if (d && s.ws && s.ws.readyState === WebSocket.OPEN) {
|
if (d && s.ws && s.ws.readyState === WebSocket.OPEN) {
|
||||||
s.ws.send(JSON.stringify({ type: 'RESIZE', cols: d.cols, rows: d.rows }));
|
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;
|
break;
|
||||||
case 'DATA':
|
case 'DATA':
|
||||||
s.term.write(msg.data);
|
s.term.write(msg.data);
|
||||||
|
|||||||
Reference in New Issue
Block a user