Compare commits
20 Commits
e704b01093
...
6cb591212d
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cb591212d | |||
| 7cbddf5d48 | |||
| cb738a8e9a | |||
| 5393252036 | |||
| 3bc2843642 | |||
| 1d6d26d612 | |||
| c65e3ea2a7 | |||
| 67dac0168f | |||
| 57d67c494b | |||
| 928e3c5fdb | |||
| 832e34fd98 | |||
| 19cfb7caaa | |||
| ea3046f635 | |||
| 39a8cf16cf | |||
| b62d4f6f9f | |||
| 44c19f329a | |||
| 9e924562d2 | |||
| e3eb0e65a5 | |||
| fc056059f9 | |||
| bd6467dff9 |
@@ -0,0 +1,8 @@
|
||||
{"ts":"2026-06-01T19:52:28+08:00","date":"2026-06-01","gate":"changelog","result":"PASS","detail":"2026-06-01-asyncssh-sftp-api-fix.md 25lines"}
|
||||
{"ts":"2026-06-01T19:52:28+08:00","date":"2026-06-01","gate":"audit","result":"PASS","detail":"2026-06-01-bug-audit.md"}
|
||||
{"ts":"2026-06-01T19:52:28+08:00","date":"2026-06-01","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-01T19:52:28+08:00","date":"2026-06-01","gate":"lint","result":"SKIP","detail":"ruff not installed"}
|
||||
{"ts":"2026-06-01T19:52:28+08:00","date":"2026-06-01","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-01T19:52:28+08:00","date":"2026-06-01","gate":"security","result":"SKIP","detail":"bandit not installed"}
|
||||
{"ts":"2026-06-01T19:52:28+08:00","date":"2026-06-01","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-01T19:52:28+08:00","date":"2026-06-01","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||
@@ -0,0 +1,183 @@
|
||||
# 审计记录:文件管理行点击与自动选服
|
||||
|
||||
## 审计信息
|
||||
|
||||
- **日期**: 2026-06-01
|
||||
- **审计人**: Claude (AI)
|
||||
- **触发原因**: Bug 修复 — `#/files` 无法进入子目录、未选服务器时空列表
|
||||
- **关联 changelog**: `docs/changelog/2026-06-01-files-page-row-click.md`
|
||||
|
||||
## 审计范围(实际改动文件清单)
|
||||
|
||||
| 文件 | 行数 | 状态 |
|
||||
|------|------|------|
|
||||
| `frontend/src/pages/FilesPage.vue` | 1429 | ☑ 全文已审 |
|
||||
| `frontend/src/composables/useServerList.ts` | 37 | ☑ 全文已审 |
|
||||
| `docs/changelog/2026-06-01-files-page-row-click.md` | 25 | ☑ 已审 |
|
||||
|
||||
## 变更摘要(审计对象)
|
||||
|
||||
1. **Vuetify 4 兼容**:移除无效的 `@click:row` / `@dblclick:row` / `@contextmenu:row`,改用 `:row-props="fileRowProps"` 绑定 `onClick` / `onDblclick` / `onContextmenu`。
|
||||
2. **自动选服**:`onMounted` 在 `loadServers()` 后调用 `resolveInitialServerId()`(URL → sessionStorage → 第一台),并 `browse()`。
|
||||
3. **记忆服务器**:`onServerChange` 写入 `sessionStorage` 键 `nexus:files:last_server_id`。
|
||||
4. **列表加载**:`useServerList` 改用 `http.getList`,兼容数组与 `{ items, total }`。
|
||||
|
||||
---
|
||||
|
||||
## 审计 8 步结果
|
||||
|
||||
### Step 1: 登记 ✅
|
||||
|
||||
已登记 3 个文件;变更动机与 changelog 一致。
|
||||
|
||||
### Step 2: 全文 Read ✅
|
||||
|
||||
- `FilesPage.vue`:template(工具栏、表格、对话框、FileEditorWorkbench)+ script setup 全量通读。
|
||||
- `useServerList.ts`:全量 37 行。
|
||||
- 交叉引用:`remotePath.ts`、`fileBrowse.ts`、`api/index.ts`(`getList`)未改,仅核对调用契约。
|
||||
|
||||
### Step 3: 规则扫描 H
|
||||
|
||||
| ID | 规则 | 扫描位置 | 命中 |
|
||||
|----|------|----------|------|
|
||||
| H1 | XSS(未消毒用户输入进 DOM) | `FilesPage.vue` template | 否 — 文件名来自 API,Vue 默认转义 |
|
||||
| H2 | 凭据泄露 | sessionStorage、路由 query | 否 — 仅存 `server_id` 数字 |
|
||||
| H3 | IDOR(越权 server_id) | `browse()`、`resolveInitialServerId` | 否 — 后端 JWT + `get_by_id`;前端校验 ID 在列表内(初始化路径) |
|
||||
| H4 | 路径穿越(`../`) | `openEntry` 符号链接绝对路径 | 否(既有)— `normalizeRemotePath`;穿越依赖 SSH 返回的 link_target,属管理员可信面 |
|
||||
| H5 | 静默吞错 | `useServerList` catch | 既有模式 — `servers=[]`,页面有空状态提示 |
|
||||
| H6 | 事件委托冲突(checkbox/按钮误触发行导航) | `fileRowProps` | 已缓解 — `isRowActionTarget` + `closest` |
|
||||
| H7 | Vuetify API 废弃 | `@click:row` | 已修复 — 改用 `row-props` |
|
||||
| H8 | 未选服务器调用 API | `browse()` | 否 — 入口 `if (!selectedServer.value) return` |
|
||||
| H9 | 双击/单击竞态 | 目录行 click + dblclick | 否 — `onRowDblClick` 仅处理 `file` 类型 |
|
||||
| H10 | sessionStorage 污染 | `FILES_LAST_SERVER_KEY` | 否 — 写入前 `onServerChange` 有值;读取时 `serverList.some` 校验 |
|
||||
| H11 | 类型安全 | `fileRowProps(data)` | 可接受 — `data.item` 与 `FileEntry` 一致 |
|
||||
| H12 | 常量声明顺序 | `FILES_LAST_SERVER_KEY` 在 `onServerChange` 之后 | 见 Closure — 运行时无 TDZ 问题,可读性欠佳 |
|
||||
|
||||
### Step 4: Closure 表
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1 | 通过 | 无 `v-html`;`{{ item.name }}` 等文本插值 |
|
||||
| H2 | 通过 | `sessionStorage` 仅 `String(serverId)` |
|
||||
| H3 | 通过 | `resolveInitialServerId` L857-868:`Number.isFinite` + `serverList.some`;API 仍由后端鉴权 |
|
||||
| H4 | 通过(既有) | 符号链接绝对路径 L751-752 走 `normalizeRemotePath`;后端 `sync_v2.browse_directory` 亦有规范化 |
|
||||
| H5 | 通过(既有) | `useServerList` L29-30 与多页一致;Files 页 `onMounted` L1363-1365 有 snackbar |
|
||||
| H6 | 通过 | `isRowActionTarget` L891-894 排除 `.v-selection-control, .v-btn, …` |
|
||||
| H7 | 通过 | L187 `:row-props="fileRowProps"`;L896-914 实现与 Vuetify 4 `RowPropsFunction` 一致 |
|
||||
| H8 | 通过 | `browse` L800-801;`navigateToPath` L759-762 |
|
||||
| H9 | 通过 | `onRowDblClick` L923-926 仅 `isRegularFile` |
|
||||
| H10 | 通过 | L861-866、L844-845 |
|
||||
| H11 | 通过 | TypeScript `type-check` 已通过(本地构建前) |
|
||||
| H12 | 建议 | `FILES_LAST_SERVER_KEY` 宜上移至 `// ── State ──` 区,非功能缺陷 |
|
||||
|
||||
**附带观察(非本次引入,不记 FINDING)**:
|
||||
|
||||
- `watch(route.query.server_id)` L1378-1390 未校验 `serverList`,非法 `?server_id=abc` 可能得到 `NaN`(原逻辑已存在)。建议在后续小改中与 `resolveInitialServerId` 对齐。
|
||||
|
||||
### Step 5: 入口表
|
||||
|
||||
| 入口类型 | 位置 | 说明 |
|
||||
|----------|------|------|
|
||||
| 用户点击 | `fileRowProps.onClick/onDblclick/onContextmenu` | 行级导航/编辑/菜单 |
|
||||
| 用户选择 | `v-select` `selectedServer` | 切换服务器 → `onServerChange` |
|
||||
| 路由 query | `server_id`, `path` | `onMounted` / `watch` / `syncRouteQuery` |
|
||||
| sessionStorage | `nexus:files:last_server_id` | 恢复上次服务器 |
|
||||
| HTTP GET | `/servers/` | `useServerList.loadServers` |
|
||||
| HTTP POST | `/sync/browse` 等 | 既有文件操作(未改契约) |
|
||||
|
||||
### Step 6: 输入 → Sink
|
||||
|
||||
```
|
||||
[URL server_id / sessionStorage / 默认第一台]
|
||||
→ resolveInitialServerId (校验 ∈ serverList)
|
||||
→ selectedServer
|
||||
→ browse() → POST /sync/browse { server_id, path }
|
||||
→ 后端 JWT + ServerRepository + SSH ls
|
||||
|
||||
[行点击 item.name]
|
||||
→ openEntry → joinRemotePath + normalizeRemotePath
|
||||
→ browse()(同上)
|
||||
|
||||
[行点击 非目录]
|
||||
→ onRowClick 无操作(仅目录/符号链接)
|
||||
|
||||
[双击文件]
|
||||
→ editFile → readRemoteFileContent → FileEditorWorkbench
|
||||
```
|
||||
|
||||
**控制措施**:路径片段经 `joinRemotePath`/`validatePathSegment`;服务器 ID 初始化路径白名单校验;API 鉴权在后端。
|
||||
|
||||
### Step 7: 归类
|
||||
|
||||
```
|
||||
FilesPage.vue 1429行 12H 0 FINDING
|
||||
useServerList.ts 37行 1H 0 FINDING
|
||||
changelog 25行 0H 0 FINDING
|
||||
────────────────────────────────────────────
|
||||
总计 13H 0 FINDING
|
||||
```
|
||||
|
||||
### Step 8: DoD ✅
|
||||
|
||||
| 维度 | 项 | 结果 |
|
||||
|------|-----|------|
|
||||
| 安全 | 无新增 XSS/IDOR/凭据泄露 | ✅ |
|
||||
| 逻辑 | 目录单击可进入;无 server 时自动选服并 browse | ✅ |
|
||||
| 逻辑 | 复选框/操作列不误触发目录进入 | ✅ |
|
||||
| 性能 | `row-props` 每行创建闭包(与 Vuetify 常规用法一致) | ✅ |
|
||||
| 质量 | TS type-check | ✅(实现时已通过) |
|
||||
| 质量 | `FILES_LAST_SERVER_KEY` 声明位置 | ⚠️ 建议上移(不阻断) |
|
||||
| 文档 | changelog ≥10 行 | ✅ |
|
||||
| 部署 | 生产 tar 需在 WSL/Git Bash 重跑;prune 路径为仓库根 `deploy/prune_frontend_assets.py` | ⚠️ 运维注意 |
|
||||
|
||||
---
|
||||
|
||||
## FINDING 列表
|
||||
|
||||
无(0 FINDING)。
|
||||
|
||||
---
|
||||
|
||||
## 审查 4 维度(摘要)
|
||||
|
||||
### 安全(12 项抽样)
|
||||
|
||||
- JWT 请求经既有 `http` 客户端 ✅
|
||||
- 不在前端存储 SSH 密码 ✅
|
||||
- `server_id` 仅数字、列表内校验(初始化) ✅
|
||||
- 行事件不执行 `eval`/动态 HTML ✅
|
||||
|
||||
### 逻辑(8 项)
|
||||
|
||||
- 根因:Vuetify 4 无 `click:row` → 已用 `row-props` 修复 ✅
|
||||
- 根因:无 query 不 browse → 已自动选服 ✅
|
||||
- 空服务器列表提示 ✅
|
||||
- 符号链接相对路径不盲目 navigate ✅
|
||||
|
||||
### 性能(5 项)
|
||||
|
||||
- `displayedFiles` 仍为 computed 过滤,未增加额外请求 ✅
|
||||
- `preloadDirectoryFiles` 行为未改 ✅
|
||||
|
||||
### 质量(5 项)
|
||||
|
||||
- 注释说明 Vuetify 4 变更原因 ✅
|
||||
- `useServerList` 与 `SchedulesPage`/`RetriesPage` 列表解析策略统一 ✅
|
||||
- 常量位置可优化(H12 建议)⚠️
|
||||
|
||||
---
|
||||
|
||||
## 结论
|
||||
|
||||
☑ **审计通过,0 FINDING,可合并/可部署**
|
||||
|
||||
**部署前提醒**(非代码缺陷):
|
||||
|
||||
1. Windows `tar` 对 `-C web/app` 可能失败,请用 WSL 或 `deploy/deploy-frontend.sh` 打包上传。
|
||||
2. 生产 prune:`cd /www/wwwroot/api.synaglobal.vip && python3 deploy/prune_frontend_assets.py`
|
||||
3. 浏览器验证 `#/files` 后建议 **Ctrl+F5** 强刷。
|
||||
|
||||
**可选后续小改**(不阻断):
|
||||
|
||||
- 将 `FILES_LAST_SERVER_KEY` 移至 state 区顶部。
|
||||
- `watch(route.query.server_id)` 增加与 `resolveInitialServerId` 相同的列表校验。
|
||||
@@ -0,0 +1,14 @@
|
||||
# 2026-06-01 审计日志操作中文展示
|
||||
|
||||
## 摘要
|
||||
|
||||
补全审计日志 `action` / `target_type` 中文映射(覆盖登录、服务器、脚本、文件、设置等全部后端动作);未登记动作按词段智能兜底为中文。审计页筛选下拉同步为中文标签。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/utils/auditLabels.ts`
|
||||
- `frontend/src/pages/AuditPage.vue`
|
||||
|
||||
## 验证
|
||||
|
||||
打开 `/#/audit`,「操作」列与筛选器应显示中文(如「登录成功」「文件推送」「写入远程文件」),不再出现 `create_server` 等英文蛇形命名。
|
||||
@@ -0,0 +1,33 @@
|
||||
# Changelog: 仪表盘/审计/调度/重试队列修复
|
||||
|
||||
**日期**: 2026-06-01
|
||||
|
||||
## 摘要
|
||||
|
||||
1. 仪表盘「最近操作」固定最近 10 条并中文展示
|
||||
2. 审计日志全中文标签 + 正确分页参数
|
||||
3. 仪表盘等业务页随侧栏自适应缩进
|
||||
4. 修复调度/重试队列无法打开(路由未卸载、server_ids 格式)
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/App.vue` — `router-view :key`、 `nexus-page-main`
|
||||
- `frontend/src/pages/DashboardPage.vue`
|
||||
- `frontend/src/pages/AuditPage.vue`
|
||||
- `frontend/src/pages/SchedulesPage.vue`
|
||||
- `frontend/src/pages/RetriesPage.vue`
|
||||
- `frontend/src/utils/auditLabels.ts`(新)
|
||||
- `frontend/src/utils/auditNormalize.ts`(新)
|
||||
- `server/api/settings.py` — 审计响应 `resource_*` 别名
|
||||
|
||||
## 验证
|
||||
|
||||
1. `#/` 最近操作 ≤10 条,中文描述
|
||||
2. `#/audit` 操作/目标列为中文
|
||||
3. 打开侧栏 → 仪表盘表格区域随动缩进
|
||||
4. 从 `#/terminal` 点「调度」「重试队列」可正常切换页面
|
||||
5. `cd frontend && npm run type-check`
|
||||
|
||||
## 部署
|
||||
|
||||
仅前端构建上传;后端 `settings.py` 小改需 `supervisorctl restart nexus`(可选,前端 normalize 已兼容)。
|
||||
@@ -0,0 +1,32 @@
|
||||
# Changelog — 文件管理/POSIX 生产部署
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-01 |
|
||||
| 类型 | 部署 |
|
||||
|
||||
## 摘要
|
||||
|
||||
生产部署:`git pull` 至 `bd6467d`,`supervisorctl restart nexus`,Windows Node 22 构建前端并 scp 至 `web/app/`,prune 732 个过期 assets。`/health` → ok,`/app/` → 200。
|
||||
|
||||
## 动机
|
||||
|
||||
主计划 Phase B 部署闭环。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- 生产:`/www/wwwroot/api.synaglobal.vip/`(后端 + `web/app/index.html` + `assets/`)
|
||||
- 本地构建产物(gitignore,未提交)
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 已 `supervisorctl restart nexus`
|
||||
- 已删除生产机遗留目录 `server/server/`(bandit 误扫)
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
ssh nexus "curl -s http://127.0.0.1:8600/health"
|
||||
ssh nexus "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8600/app/"
|
||||
# 浏览器:#/files → 归属列、权限对话框、顶栏提权提示
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
# 2026-06-01 文件浏览目录 sudo 提权回退
|
||||
|
||||
## 日期
|
||||
2026-06-01
|
||||
|
||||
## 变更摘要
|
||||
`POST /api/sync/browse` 与写文件/chmod 一致,在 `files_elevation=auto_sudo`(默认)且 `ls` 遇 Permission denied 时自动 `sudo -n bash -c`。
|
||||
|
||||
## 动机
|
||||
- 非 root SSH 用户无法直接 `ls /www/wwwroot`,前端表现为「暂无文件」
|
||||
- 浏览此前仅用 `exec_ssh_command`,**从未走 sudo**;与「去掉 sudo」无关,应**启用**提权而非关闭
|
||||
|
||||
## 涉及文件
|
||||
- `server/api/sync_v2.py` — `browse_directory`
|
||||
- `frontend/src/utils/fileMode.ts` — 提示文案含「浏览」
|
||||
|
||||
## 迁移 / 重启
|
||||
- 需 `supervisorctl restart nexus` 部署后端
|
||||
|
||||
## 验证方式
|
||||
1. 服务器 SSH 用户为非 root,`files_elevation` 为 `auto_sudo`(默认)
|
||||
2. 目标机已安装 `docs/deploy/nexus-files-sudoers.example`(含 bash)
|
||||
3. 打开 `#/files?server_id=8`,应列出 `/www/wwwroot` 内容
|
||||
|
||||
## 运维说明(不要关掉 sudo)
|
||||
|
||||
| 策略 | 适用 |
|
||||
|------|------|
|
||||
| `auto_sudo`(推荐) | 先普通 `ls`,失败再 sudo |
|
||||
| `off` | 仅当 SSH 已是 root 或目录对当前用户可读 |
|
||||
| `always_sudo` | 所有命令先 sudo;sudoers 未配全时反而更易失败 |
|
||||
|
||||
若 `always_sudo` 且 sudo 未配好:在「服务器」编辑里改回 **自动提权 (auto_sudo)**,并安装 sudoers 示例。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 2026-06-01 文件管理行点击与自动选服
|
||||
|
||||
## 日期
|
||||
2026-06-01
|
||||
|
||||
## 变更摘要
|
||||
修复 `#/files` 无法进入子目录、打开页无列表等体验问题:Vuetify 4 改用 `row-props` 绑定行点击;进入页自动选中服务器并浏览默认目录。
|
||||
|
||||
## 动机
|
||||
- Vuetify 4 已移除 `v-data-table` 的 `@click:row` / `@dblclick:row` / `@contextmenu:row`,原事件静默失效,单击目录无法进入
|
||||
- 未带 `?server_id=` 时不会调用 `browse()`,用户看到空表且多数按钮禁用
|
||||
|
||||
## 涉及文件
|
||||
- `frontend/src/pages/FilesPage.vue` — `fileRowProps`、自动选服、`sessionStorage` 记忆上次服务器
|
||||
- `frontend/src/composables/useServerList.ts` — `http.getList` 兼容列表响应
|
||||
|
||||
## 迁移 / 重启
|
||||
- 需重新构建并部署前端至 `web/app/`(建议执行 `deploy/prune_frontend_assets.py`)
|
||||
|
||||
## 验证方式
|
||||
1. 打开 `https://…/app/#/files`(无需 URL 参数)
|
||||
2. 应自动选中服务器并列出 `/www/wwwroot`(或站点根)内容
|
||||
3. 单击目录行进入子目录;双击普通文件打开编辑器
|
||||
4. 右键行弹出上下文菜单
|
||||
@@ -0,0 +1,24 @@
|
||||
# 2026-06-01 文件管理 sudo 提示与白名单示例
|
||||
|
||||
## 日期
|
||||
2026-06-01
|
||||
|
||||
## 变更摘要
|
||||
区分「无免密 sudo / 白名单不全 / 已就绪」三种提示;sudoers 示例补充 `bash`(与 `sudo -n bash -c` 提权路径一致)。
|
||||
|
||||
## 动机
|
||||
- 用户看到「部分操作将自动尝试 sudo」但操作仍失败:多为目标机未装 sudoers 或白名单缺少 `bash`
|
||||
- 原示例未包含 `bash`,与 `remote_shell.exec_ssh_command_with_fallback` 行为不一致
|
||||
|
||||
## 涉及文件
|
||||
- `frontend/src/utils/fileMode.ts`
|
||||
- `frontend/src/pages/FilesPage.vue`
|
||||
- `docs/deploy/nexus-files-sudoers.example`
|
||||
|
||||
## 迁移 / 重启
|
||||
- 仅前端文案 + 文档;目标机需按新示例更新 `/etc/sudoers.d/nexus-files` 后无需重启 Nexus
|
||||
|
||||
## 验证方式
|
||||
1. 非 root SSH + 无 sudo:文件页顶部红色提示,含「免密 sudo」与示例路径
|
||||
2. 有 `sudo -n true` 但无 bash:黄色提示「白名单可能不完整」
|
||||
3. 配置完整后:无多余横幅(除非目录内有不可读文件)
|
||||
@@ -0,0 +1,25 @@
|
||||
# Changelog — 主计划长任务首轮执行
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-01 |
|
||||
| 类型 | 执行 / 部署 |
|
||||
|
||||
## 摘要
|
||||
|
||||
执行 [2026-06-01-master-backlog-plan.md](../project/2026-06-01-master-backlog-plan.md):Phase A 单测 44 通过;提交并推送 `d93c518`~`e704b01`;生产拉取、`supervisorctl restart nexus`、health `ok`;门控 6/7(bandit 报生产机 `server/server/` 旧路径 HIGH);修复 `pre_deploy_check.sh` 默认 `DEPLOY_DIR` 与最新 audit 选取。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- 79+ 文件功能提交;`deploy/pre_deploy_check.sh`;`docs/audit/2026-06-01-master-execution-audit.md`;主计划进度更新
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 生产已 `supervisorctl restart nexus`
|
||||
- 前端 `npm run build` 待服务器 Node 环境确认
|
||||
|
||||
## 验证
|
||||
|
||||
- 本地:`pytest` 44 passed;`ruff`;`import server.main`
|
||||
- 生产:`curl http://127.0.0.1:8600/health` → `ok`
|
||||
- 门控:`bash deploy/pre_deploy_check.sh` → 6/7(Security 待清 `server/server` 重复目录或 bandit 路径)
|
||||
@@ -0,0 +1,37 @@
|
||||
# 2026-06-01 全功能验收脚本 run_nexus_acceptance
|
||||
|
||||
## 日期
|
||||
2026-06-01
|
||||
|
||||
## 变更摘要
|
||||
新增统一验收入口 `scripts/run_nexus_acceptance.py`:自动跑核心/扩展 API 测试、按功能模块汇总 PASS/FAIL,可选单元测试与 Playwright UI。
|
||||
|
||||
## 动机
|
||||
用户需要一键脚本识别各功能是否正常,而非分散执行 `test_api.py` 与 Playwright。
|
||||
|
||||
## 涉及文件
|
||||
- `scripts/run_nexus_acceptance.py` — 主入口
|
||||
- `scripts/run_full_acceptance.sh` — WSL/生产包装(API + 可选 UI)
|
||||
- `tests/acceptance_catalog.py` — 功能模块与检查项映射
|
||||
|
||||
## 迁移 / 重启
|
||||
无
|
||||
|
||||
## 2026-06-01 修复
|
||||
|
||||
- `tests/test_full_site_api.py`:`api_test_plain_text` / `api_test` 与 `test_api.py` 对齐
|
||||
|
||||
## 验证方式
|
||||
```bash
|
||||
# 本地(后端需已启动,.env 含测试账号)
|
||||
python scripts/run_nexus_acceptance.py
|
||||
|
||||
# 含 UI(需 frontend npm install + NEXUS_E2E_PASSWORD)
|
||||
python scripts/run_nexus_acceptance.py --with-ui
|
||||
|
||||
# 含单元测试(无需后端)
|
||||
python scripts/run_nexus_acceptance.py --with-unit --skip-api
|
||||
|
||||
# JSON 报告
|
||||
python scripts/run_nexus_acceptance.py --json reports/acceptance.json
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
# 2026-06-01 调度 / 重试队列页面无法打开修复
|
||||
|
||||
## 摘要
|
||||
|
||||
- 侧栏增加可滚动区域与显式 `router.push`,避免「调度」「重试队列」在视口外或点击无跳转。
|
||||
- 路由懒加载失败时全局提示 Ctrl+F5(chunk 与 index.html 哈希不一致)。
|
||||
- `http.getList` 兼容数组与 `{ items, total }` 两种 API 响应。
|
||||
- 调度/重试页加载失败显示 toast;修复 `v-chip` 非法 `border="current sm"`。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/App.vue`
|
||||
- `frontend/src/router/index.ts`
|
||||
- `frontend/src/api/index.ts`
|
||||
- `frontend/src/pages/SchedulesPage.vue`
|
||||
- `frontend/src/pages/RetriesPage.vue`
|
||||
|
||||
## 验证
|
||||
|
||||
1. Ctrl+F5 后点击侧栏「调度」「重试队列」,URL 应为 `#/schedules`、`#/retries`,页面显示表格与标题。
|
||||
2. 直接访问 `https://api.synaglobal.vip/app/#/schedules` 与 `#/retries` 应正常。
|
||||
@@ -0,0 +1,36 @@
|
||||
# Changelog: 终端页空状态布局修复
|
||||
|
||||
**日期**: 2026-06-01
|
||||
|
||||
## 摘要
|
||||
|
||||
修复 `#/terminal` 无会话时页面模板叠层:空状态与「选择服务器」对话框同时出现、工具栏显示无意义的 `...` / `断开`。
|
||||
|
||||
## 动机
|
||||
|
||||
用户反馈终端页存在「模板问题」;生产页表现为空状态文案 + 弹窗服务器列表 + 未连接工具栏三层叠在一起,不符合 `2026-06-01-terminal-spa-iteration-design.md` 空状态规范。
|
||||
|
||||
## 变更
|
||||
|
||||
- **侧栏自适应**:保留 `v-main` 的 `--v-layout-left` 缩进;菜单开合时终端宽度随动,xterm 在动画后 `fit()`;桌面 persistent / 移动端 `temporary` 浮层
|
||||
- **侧栏遮挡修复**:`nexus-terminal-main` 不再 `padding:0`(保留 Vuetify 侧栏留白)
|
||||
- 取消进入 `/terminal` 时自动 `showServerDialog = true`
|
||||
- 空状态改为内嵌可搜索服务器列表(点击即 `newSession`)
|
||||
- 打开对话框时隐藏空状态(`!showServerDialog`)
|
||||
- 无会话时隐藏会话工具栏
|
||||
- 侧栏 `v-list-subheader` 移入 `v-list`;浅色主题终端区背景跟随主题
|
||||
- 快捷命令编辑文案去掉字面量 `\r`
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/pages/TerminalPage.vue`
|
||||
|
||||
## 验证
|
||||
|
||||
1. `cd frontend && npm run type-check`
|
||||
2. `vite build` 后访问 `https://api.synaglobal.vip/app/#/terminal`:仅见内嵌列表,无自动弹窗
|
||||
3. 标签栏「+」仍可打开对话框,且不与空状态同时显示
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
仅前端构建部署。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 2026-06-01 终端快捷命令与布局修正
|
||||
|
||||
## 摘要
|
||||
|
||||
- 移除终端页左侧「服务器列表」抽屉,避免与全局运维侧栏菜单混淆;新建会话仍通过标签栏「新建会话」与空状态选择服务器。
|
||||
- 终端页快捷命令改为只读执行;添加/编辑/删除/排序迁移至 **系统设置 → 终端快捷命令**(锚点 `#terminal-quick-commands`)。
|
||||
- 命令输入区加高约 3 倍(`ShellHighlightField` large + multiline);快捷命令区字号加大;Enter 发送、Shift+Enter 换行。
|
||||
- 后端:自定义命令 `sort_order` 优先于内置(内置 `sort_order` 基值 1000+);新增 `PUT /api/terminal/quick-commands/reorder`。
|
||||
|
||||
## 动机
|
||||
|
||||
用户反馈终端页误将左侧改为服务器列表、未充分验证;要求在终端不可直接增删命令,统一全局菜单,并在系统设置中管理顺序(自定义排最前)。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/pages/TerminalPage.vue`
|
||||
- `frontend/src/pages/SettingsPage.vue`
|
||||
- `frontend/src/components/TerminalQuickCommandsSettings.vue`
|
||||
- `frontend/src/composables/useTerminalQuickCommands.ts`
|
||||
- `frontend/src/components/ShellHighlightField.vue`
|
||||
- `server/api/terminal.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 前端:重新 `vite build` 并部署 `web/app/`。
|
||||
- 后端:拉取代码后 `supervisorctl restart nexus`(reorder 端点)。
|
||||
|
||||
## 后续(同日)
|
||||
|
||||
- 终端右下角「快捷键」区改为中文大按钮(中断/EOF/补全);工具栏字号/回滚/全屏中文化。
|
||||
- 右键菜单加大(comfortable 密度)、靠近底部时向上弹出;命令栏右键同样显示中文菜单。
|
||||
|
||||
## 验证
|
||||
|
||||
1. 打开 `https://api.synaglobal.vip/app/#/terminal`:左侧仍为全局运维菜单,无第二列服务器列表。
|
||||
2. 快捷命令仅可点击执行,无删除/添加按钮;「管理」跳转设置页锚点。
|
||||
3. `https://api.synaglobal.vip/app/#/settings#terminal-quick-commands`:可添加、编辑、删除、上/下移自定义命令。
|
||||
4. 终端底部输入框明显加高;Enter 发送、Shift+Enter 换行。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 2026-06-01 登录锁定解除脚本
|
||||
|
||||
## 日期
|
||||
2026-06-01
|
||||
|
||||
## 变更摘要
|
||||
新增 `scripts/unlock_login_lockout.py`:删除 `login_attempts` 中近期失败记录,解除 15 分钟防暴破锁定。
|
||||
|
||||
## 动机
|
||||
验收/误测导致 `admin` 触发 429,需运维可重复、可审计的解锁方式(非改代码降级)。
|
||||
|
||||
## 涉及文件
|
||||
- `scripts/unlock_login_lockout.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
无
|
||||
|
||||
## 验证方式
|
||||
```bash
|
||||
# 生产机(须 venv)
|
||||
ssh nexus "cd /www/wwwroot/api.synaglobal.vip && venv/bin/python scripts/unlock_login_lockout.py --username admin"
|
||||
```
|
||||
|
||||
凭据见 `docs/handoff-2026-06-01.md`(`admin` / `Nexus@2026`)。
|
||||
@@ -0,0 +1,48 @@
|
||||
# Changelog: 文件管理器复制粘贴、编辑器外观与 sudo 自动配置
|
||||
|
||||
**日期**: 2026-06-02
|
||||
**类别**: 功能改进 + Bug 修复
|
||||
|
||||
## 变更摘要
|
||||
|
||||
### 1. 文件管理器复制粘贴逻辑修复
|
||||
- `Ctrl+C` / `Ctrl+X` 仅在有勾选文件时拦截浏览器默认行为并执行复制/剪切;无勾选时让浏览器原生复制正常工作
|
||||
- `Ctrl+V` 仅在剪贴板有内容时执行粘贴,空剪贴板不触发
|
||||
|
||||
### 2. 文件编辑器大小限制提升至 2MB
|
||||
- `FILE_EDITOR_MAX_BYTES` 新增为 2MB(原 300KB 仅用于预加载)
|
||||
- 编辑/查看文件 >300KB 时显示"正在加载…"提示
|
||||
- 错误提示文案更新为"文件超过 2MB"
|
||||
|
||||
### 3. 非 root 用户一键配置 sudo 权限
|
||||
- 后端新增 `POST /servers/{id}/setup-files-sudo` 端点
|
||||
- 使用服务器已存储的 SSH 密码,通过 `sudo -S` 自动写入 `/etc/sudoers.d/nexus-files`
|
||||
- 前端:权限提示横幅(非 root 服务器)增加「一键配置 sudo 权限」按钮
|
||||
- 成功后自动刷新权限探测并重新浏览目录
|
||||
|
||||
### 4. 编辑器外观全面改进
|
||||
- **工具栏重构**:移除 `v-toolbar`,改用 flexbox 布局,解决语言选择器位置错乱问题
|
||||
- **语法选择器**:改为 `variant="plain"` 更轻量,宽度从 132px 调整至 110px
|
||||
- **状态栏**:背景改为主题色(蓝色),跟随 Vuetify 主题,不再硬编码 `#252526`;显示行:列、语言、路径、"未保存"提示
|
||||
- **自动换行**:工具栏新增换行切换按钮(`mdi-wrap` / `mdi-wrap-disabled`),支持 Alt+Z 快捷键
|
||||
- **文件标签**:未保存标记从 `text-error` 改为 `text-warning`,更柔和
|
||||
- **全屏/Esc 提示**:全屏按钮 title 补充"(Esc)"说明
|
||||
|
||||
## 涉及文件
|
||||
- `frontend/src/composables/useFilesHotkeys.ts` — 复制粘贴快捷键守卫
|
||||
- `frontend/src/utils/filePreload.ts` — 新增 `FILE_EDITOR_MAX_BYTES`
|
||||
- `frontend/src/pages/FilesPage.vue` — 大文件加载提示、sudoers 一键配置、剪贴板守卫
|
||||
- `frontend/src/components/FileEditorWorkbench.vue` — 编辑器外观全面改进
|
||||
- `server/api/servers.py` — 新增 `setup-files-sudo` 端点
|
||||
|
||||
## 验证方式
|
||||
1. 文件管理器无勾选时 Ctrl+C 不弹出"已复制 N 项" snackbar
|
||||
2. 无剪贴板时 Ctrl+V 无反应
|
||||
3. 编辑 300KB~2MB 文件时显示"正在加载…"蓝色提示
|
||||
4. 非 root 服务器权限横幅出现「一键配置 sudo 权限」按钮
|
||||
5. 编辑器工具栏语言选择器正常显示,状态栏为蓝色主题
|
||||
6. Alt+Z 切换换行,状态栏出现"换行"标记
|
||||
|
||||
## 是否需要迁移/重启
|
||||
- 后端需重启(新增 API 端点):✅ 已执行
|
||||
- 数据库无变更
|
||||
@@ -0,0 +1,57 @@
|
||||
# Changelog: 文件管理器 P1 — Composable 拆分 + 目录缓存
|
||||
|
||||
**日期**: 2026-06-02
|
||||
**类型**: 重构 (Refactor)
|
||||
**影响范围**: `frontend/src/pages/FilesPage.vue`、`frontend/src/stores/files.ts`、`frontend/src/composables/files/*`
|
||||
|
||||
---
|
||||
|
||||
## 变更摘要
|
||||
|
||||
按 `docs/design/specs/2026-06-02-files-refactor-design.md` 完成 P1:将 1500+ 行 `FilesPage.vue` 业务逻辑拆分为 Pinia store 与域 composable;页面 script 约 110 行,仅负责装配与模板绑定。
|
||||
|
||||
---
|
||||
|
||||
## 动机
|
||||
|
||||
- 单文件难以维护,三处 `browse()` 入口难追踪
|
||||
- 为 P2 组件拆分、P3 缓存、P4 REST API 打基础
|
||||
|
||||
---
|
||||
|
||||
## 主要改动
|
||||
|
||||
| 模块 | 说明 |
|
||||
|------|------|
|
||||
| `stores/files.ts` | 服务器/路径/列表/权限状态;5s TTL 目录缓存(LRU 50 项) |
|
||||
| `useFilesBrowse.ts` | 浏览、路由同步、序号与 in-flight 去重 |
|
||||
| `useFilesNavigation.ts` | 面包屑、初始化、路由 watch、服务器切换 |
|
||||
| `useFilesPermissions.ts` | capability、权限横幅、一键 sudo |
|
||||
| `useFilesActions.ts` | 上传/删除/重命名/压缩/剪贴板等 |
|
||||
| `useFilesEditor.ts` | 预览、Monaco 编辑 |
|
||||
| `useFilesPage.ts` | 页面级装配入口 |
|
||||
| `FilesPage.vue` | 模板不变,script 瘦身为 `useFilesPage()` |
|
||||
|
||||
---
|
||||
|
||||
## 行为变化
|
||||
|
||||
- **重复进入同一目录(5s 内)**:命中内存缓存,0 次 `/sync/browse`
|
||||
- **写操作后**:`invalidateCache` 强制下次 `browse({ force: true })`
|
||||
- 其余 UX 与 P0 后一致
|
||||
|
||||
---
|
||||
|
||||
## 是否需迁移 / 重启
|
||||
|
||||
- 仅前端重新构建部署
|
||||
- 后端无变更
|
||||
|
||||
---
|
||||
|
||||
## 验证方式
|
||||
|
||||
1. `cd frontend && npx vite build`
|
||||
2. 部署后 `prune_frontend_assets.py`
|
||||
3. 文件管理:切换服务器、进目录、上传/删除/编辑/粘贴/sudo 配置
|
||||
4. Network:同目录 5s 内二次进入无 browse 请求
|
||||
@@ -0,0 +1,48 @@
|
||||
# Changelog: 文件管理器 P2 — 组件拆分
|
||||
|
||||
**日期**: 2026-06-02
|
||||
**类型**: 重构 (Refactor)
|
||||
**影响范围**: `frontend/src/pages/FilesPage.vue`、`frontend/src/components/files/*`
|
||||
|
||||
---
|
||||
|
||||
## 变更摘要
|
||||
|
||||
将文件管理页模板拆分为 6 个子组件;`FilesPage.vue` 仅负责布局装配与 `provideFilesPage` 注入上下文。
|
||||
|
||||
---
|
||||
|
||||
## 动机
|
||||
|
||||
P1 已将逻辑迁入 composable,但单文件模板仍超 500 行,不利于维护与 P5/P6 局部优化。
|
||||
|
||||
---
|
||||
|
||||
## 新增文件
|
||||
|
||||
- `frontend/src/composables/files/filesPageContext.ts` — provide/inject 上下文
|
||||
- `frontend/src/components/files/FilesToolbar.vue`
|
||||
- `frontend/src/components/files/FilesPermissionAlert.vue`
|
||||
- `frontend/src/components/files/FilesStatusBar.vue`
|
||||
- `frontend/src/components/files/FilesBreadcrumb.vue`
|
||||
- `frontend/src/components/files/FilesList.vue`
|
||||
- `frontend/src/components/files/FilesDialogs.vue`
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/pages/FilesPage.vue` — 由 ~640 行降至 ~45 行
|
||||
|
||||
---
|
||||
|
||||
## 是否需迁移 / 重启
|
||||
|
||||
- 仅前端重新构建部署
|
||||
|
||||
---
|
||||
|
||||
## 验证方式
|
||||
|
||||
1. `cd frontend && npx vite build`
|
||||
2. 文件管理:工具栏、列表、对话框、编辑器、右键菜单、快捷键
|
||||
@@ -0,0 +1,92 @@
|
||||
# 2026-06-02 MEDIUM 级 Bug 修复(6 项)
|
||||
|
||||
## 日期
|
||||
2026-06-02
|
||||
|
||||
## 变更摘要
|
||||
修复系统扫描中发现的 6 个 MEDIUM 级 Bug,提升安全性、数据准确性和系统健壮性。
|
||||
|
||||
---
|
||||
|
||||
## M1: 登录锁定按 username+ip 计数,可换 IP 绕过
|
||||
|
||||
**文件**: `server/infrastructure/database/admin_repo.py`
|
||||
|
||||
**问题**: `count_recent_failures` 的 WHERE 条件同时过滤 `username AND ip_address`,攻击者只需更换 IP 即可无限尝试同一账号密码。
|
||||
|
||||
**修复**: 移除 `ip_address` 维度,改为仅按 `username` 计数失败次数。换 IP 不再能绕过锁定。
|
||||
|
||||
---
|
||||
|
||||
## M2: 重复 PushRetryJob 创建(同一服务器多条 pending)
|
||||
|
||||
**文件**: `server/application/services/sync_engine_v2.py`
|
||||
|
||||
**问题**: 每次 push 失败都无条件创建新的 `PushRetryJob`,若多次触发同一路径的推送且反复失败,会堆积大量重复 pending 任务。
|
||||
|
||||
**修复**: 创建前先查询是否已存在相同 `(server_id, source_path, target_path, status=pending)` 的记录,存在则跳过创建。
|
||||
|
||||
---
|
||||
|
||||
## M3: schedule_runner 先标记已执行再同步,失败时调度记录丢失
|
||||
|
||||
**文件**: `server/background/schedule_runner.py`
|
||||
|
||||
**问题**: 执行前先将 `last_run_at = now` 并 `commit()`,若同步任务抛出异常,`last_run_at` 已更新,该次调度不会重新触发,相当于"静默跳过"了一次调度。
|
||||
|
||||
**修复**: 保存 `prev_last_run_at`,执行失败时将 `last_run_at` 回滚,允许下一个周期重新触发。`once` 模式不回滚(已标记 `enabled=False`,防止重复执行)。
|
||||
|
||||
---
|
||||
|
||||
## M4: servers.py is_online 过滤后 total 仍用 DB 总数,分页不准
|
||||
|
||||
**文件**: `server/api/servers.py`
|
||||
|
||||
**问题**: 当传入 `is_online` 过滤参数时,Redis 覆盖后的内存过滤结果与 DB 总数不一致,`total` 和 `pages` 字段仍反映 DB 全量数量,前端分页逻辑错乱。
|
||||
|
||||
**修复**: 内存过滤后重新计算 `total = len(result)`,`pages` 同步更新。
|
||||
|
||||
---
|
||||
|
||||
## M5: asyncssh_pool exec_ssh_command 超时后未踢出坏连接
|
||||
|
||||
**文件**: `server/infrastructure/ssh/asyncssh_pool.py`
|
||||
|
||||
**问题**: `asyncio.TimeoutError` 在 `finally` 中仍会 `release()` 超时的连接,将其放回连接池,下一次 `acquire()` 可能复用这个状态未知的坏连接,导致命令执行结果不可靠。
|
||||
|
||||
**修复**: 超时和连接错误时改为 `close_connection()` 而非 `release()`,将坏连接从池中彻底移除;一般异常仍 `release()`(可能仍可用)。
|
||||
|
||||
---
|
||||
|
||||
## M6: token_version += 1 在 NULL 时 TypeError
|
||||
|
||||
**文件**: `server/api/auth.py`, `server/application/services/auth_service.py`(3 处)
|
||||
|
||||
**问题**: DB 中 `token_version` 为 `NULL`(直接 INSERT 未设默认值时)时,`admin.token_version += 1` 抛 `TypeError: unsupported operand type(s) for +=: 'NoneType' and 'int'`,导致修改密码 / 禁用 TOTP / 强制登出接口 500。
|
||||
|
||||
**修复**: 所有 `token_version += 1` 统一改为 `(token_version or 0) + 1`,共修复 3 处。
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 修复项 |
|
||||
|------|-------|
|
||||
| `server/infrastructure/database/admin_repo.py` | M1(锁定维度) |
|
||||
| `server/application/services/sync_engine_v2.py` | M2(重复 RetryJob) |
|
||||
| `server/background/schedule_runner.py` | M3(调度回滚) |
|
||||
| `server/api/servers.py` | M4(分页 total) |
|
||||
| `server/infrastructure/ssh/asyncssh_pool.py` | M5(超时踢连接) |
|
||||
| `server/api/auth.py` | M6(token_version None) |
|
||||
| `server/application/services/auth_service.py` | M6(token_version None,2 处) |
|
||||
|
||||
## 需要迁移/重启
|
||||
- 后端重启生效(`supervisorctl restart nexus`)
|
||||
- 无数据库 schema 变更
|
||||
|
||||
## 验证方式
|
||||
1. `python -c "import server.main"` — 导入无报错 ✓
|
||||
2. 同一 IP 登录失败 5 次后,换 IP 仍被锁定
|
||||
3. 查看 `push_retry_jobs` 表,相同路径不再出现多条 pending
|
||||
4. 调度执行失败后 `last_run_at` 应保持原值
|
||||
5. 服务器列表按在线状态过滤,分页 total 与实际条目数一致
|
||||
@@ -0,0 +1,79 @@
|
||||
# Changelog: 主动 JWT 续期 —— 消除文件管理器双刷新
|
||||
|
||||
**日期**: 2026-06-02
|
||||
**类型**: 修复 (Bug Fix)
|
||||
**影响范围**: frontend/src/stores/auth.ts · frontend/src/api/index.ts
|
||||
|
||||
---
|
||||
|
||||
## 变更摘要
|
||||
|
||||
实现客户端 JWT access token 提前续期机制,彻底消除文件管理器"进目录双刷新"问题。
|
||||
|
||||
---
|
||||
|
||||
## 动机
|
||||
|
||||
每次 access token 过期后,`api/index.ts` 的 `fetchAuthed()` 会:
|
||||
1. 先发出一次业务请求 → 服务端返回 401
|
||||
2. 触发 `/auth/refresh` 获取新 token
|
||||
3. 用新 token 重发同一业务请求
|
||||
|
||||
导致每次进入目录产生 2 次 `/api/sync/browse` 请求,页面可见"双刷新"(`_browseSeq` 虽可防止旧数据覆盖,但无法消除 2 次请求本身)。
|
||||
|
||||
---
|
||||
|
||||
## 方案
|
||||
|
||||
### `frontend/src/stores/auth.ts`
|
||||
|
||||
- 新增 `decodeJwtExp(jwt)` 工具函数:不验签地从 JWT Payload 解码 `exp` 字段(秒)
|
||||
- 新增 `tokenExp` computed:返回当前 token 的过期 unix 秒,无 token 时为 0
|
||||
- 新增 `isTokenExpiringSoon(thresholdSec = 300)` 方法:当距过期 < `thresholdSec` 秒时返回 true
|
||||
|
||||
### `frontend/src/api/index.ts`
|
||||
|
||||
在 `fetchAuthed()` 发出实际请求之前加入一个提前续期分支:
|
||||
|
||||
```typescript
|
||||
if (!_retry && !AUTH_SKIP_REFRESH.has(path) && auth.token && auth.isTokenExpiringSoon(300)) {
|
||||
await tryRefreshSession()
|
||||
}
|
||||
```
|
||||
|
||||
若 token 将在 5 分钟内过期,先刷新再发请求,完全跳过"401 → 重试"路径。
|
||||
原有的 401 兜底重试逻辑保留不变(处理极端并发或时钟偏差场景)。
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 变更类型 |
|
||||
|------|---------|
|
||||
| `frontend/src/stores/auth.ts` | 新增 `decodeJwtExp` / `tokenExp` / `isTokenExpiringSoon` |
|
||||
| `frontend/src/api/index.ts` | 在 `fetchAuthed` 中新增提前续期分支 |
|
||||
|
||||
---
|
||||
|
||||
## 是否需要迁移 / 重启
|
||||
|
||||
- **前端**: 需重新 `vite build` 后部署到服务器
|
||||
- **后端**: 无变更,无需重启
|
||||
- **数据库**: 无变更
|
||||
|
||||
---
|
||||
|
||||
## 追加修复(2026-06-02 续)
|
||||
|
||||
- `FilesPage.vue`:`v-select` 在 `onMounted` 赋值 `selectedServer` 时会触发 `@update:model-value` → `onServerChange` 再次 `browse()`;增加 `_initing` 守卫
|
||||
- `FilesPage.vue`:`pageReady` 避免首屏短暂显示「请先选择服务器」
|
||||
- `FilesPage.vue`:同路径并发 `browse()` 合并为单次 in-flight 请求
|
||||
- 部署须执行 `deploy/prune_frontend_assets.py` 清理服务器上残留的旧 hash chunk(否则浏览器可能仍加载旧 `FilesPage-*.js`)
|
||||
|
||||
## 验证方式
|
||||
|
||||
1. 登录后等待 access token 接近过期(或手动修改 token exp 为近期时间)
|
||||
2. 进入文件管理器浏览目录
|
||||
3. 打开 DevTools Network 过滤 `/sync/browse`
|
||||
4. 预期:每次进目录只有 **1 条** `/sync/browse` 请求,不再出现 401 后重试的第 2 条
|
||||
5. 空目录区不应再闪现「请先选择服务器」(已选服务器时)
|
||||
@@ -0,0 +1,101 @@
|
||||
# 2026-06-02 全系统 Bug 扫描与修复
|
||||
|
||||
## 日期
|
||||
2026-06-02
|
||||
|
||||
## 变更摘要
|
||||
对全系统进行静态代码扫描,发现并修复 **7 个 HIGH 级 Bug**。
|
||||
|
||||
---
|
||||
|
||||
## HIGH-1: `download_file` SSH 连接在流传输前被过早释放
|
||||
|
||||
**文件**: `server/api/sync_v2.py`
|
||||
|
||||
**问题**: `finally` 块中 `ssh_pool.release()` 在 `StreamingResponse` 生成器尚未开始执行时就关闭连接,导致文件下载中断。
|
||||
|
||||
**修复**: 将 `release()` 移入 `file_generator()` 的 `finally` 子句,在流传输完成或客户端断开后再释放;提前退出(404/400/413)在各自分支 `await release()`。
|
||||
|
||||
---
|
||||
|
||||
## HIGH-2: WebSocket JWT `tv` 默认值 `-1` 与 HTTP 中间件 `0` 不一致
|
||||
|
||||
**文件**: `server/api/websocket.py`
|
||||
|
||||
**问题**: `_verify_ws_token` 对无 `tv` 字段的旧 token 使用默认值 `-1`,而数据库 `token_version` 为 `NULL/0`,导致 `-1 != 0` 判断失败,合法用户 WebSocket 连接被拒。
|
||||
|
||||
**修复**: 对齐 HTTP 中间件逻辑,改为 `payload.get("tv") or 0`,DB 侧 `admin.token_version or 0`。
|
||||
|
||||
---
|
||||
|
||||
## HIGH-3: `retry_runner` 达到 `max_retries` 后任务永久僵尸
|
||||
|
||||
**文件**: `server/background/retry_runner.py`
|
||||
|
||||
**问题**: 重试次数达到 `max_retries` 时,任务状态仍维持 `pending` 且 `retry_count < max_retries` 条件不再触发,形成永不被处理的僵尸记录。
|
||||
|
||||
**修复**: 成功/失败分支均检查 `retry_count >= max_retries`,达到上限时将 `status` 置为 `failed`,记录原因日志。
|
||||
|
||||
---
|
||||
|
||||
## HIGH-4: `sync_v2.py` 和 `sync_engine_v2.py` 多处 `stderr` 为 `None` 引发 `TypeError`
|
||||
|
||||
**文件**: `server/api/sync_v2.py`, `server/application/services/sync_engine_v2.py`
|
||||
|
||||
**问题**: 多处直接执行 `result["stderr"][:N]`,当 SSH 执行结果中 `stderr` 为 `None` 时(如连接超时)抛 `TypeError: 'NoneType' object is not subscriptable`,导致 API 返回 500。
|
||||
|
||||
**修复**: 所有 `result["stderr"][:N]` 统一改为 `(result.get("stderr") or "")[:N]`,共修复 5 处。
|
||||
|
||||
---
|
||||
|
||||
## HIGH-5: `auth_service._verify_password` bcrypt 抛 `ValueError` 导致 500
|
||||
|
||||
**文件**: `server/application/services/auth_service.py`
|
||||
|
||||
**问题**: DB 中若存在格式异常的 `password_hash`,`bcrypt.checkpw` 会抛 `ValueError`,导致登录接口 500 而非 401。
|
||||
|
||||
**修复**: 用 `try/except` 包裹 `bcrypt.checkpw`,异常时返回 `False`(密码不匹配)。
|
||||
|
||||
---
|
||||
|
||||
## HIGH-6: `install.py /lock` 硬编码查询 `"admin"` 用户名
|
||||
|
||||
**文件**: `server/api/install.py`
|
||||
|
||||
**问题**: `/lock` 端点通过 `get_by_username("admin")` 验证管理员是否创建,若用户在步骤 4 使用了非 `admin` 的用户名(如 `administrator`),会错误地拒绝锁定(状态 400),且 DB 不可达时会静默放行锁定操作。
|
||||
|
||||
**修复**: 改为 `SELECT COUNT(*) FROM admins WHERE is_active = 1` 验证活跃管理员存在;DB 不可达时改为拒绝锁定(503)而非放行。
|
||||
|
||||
---
|
||||
|
||||
## HIGH-7: `heartbeat_flush.py` 单条失败污染整批事务
|
||||
|
||||
**文件**: `server/background/heartbeat_flush.py`
|
||||
|
||||
**问题**: 所有服务器在同一 `AsyncSession` 中批量更新,单条 `UPDATE` 失败时仅打日志,但会话处于需要 rollback 的状态,最终 `commit()` 使整批失败,日志显示 "N 台更新" 实际全部失败。同时修复 `datetime` 时区感知问题(naive datetime 写入 MySQL)。
|
||||
|
||||
**修复**: 每台服务器使用独立的 `AsyncSessionLocal()` 上下文,单条失败不影响其他;`fromisoformat` 后统一补 `tzinfo=timezone.utc`。
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 修复项 |
|
||||
|------|-------|
|
||||
| `server/api/sync_v2.py` | HIGH-1(下载连接)、HIGH-4(stderr None, 3 处) |
|
||||
| `server/api/websocket.py` | HIGH-2(WebSocket tv) |
|
||||
| `server/api/install.py` | HIGH-6(/lock 用户名) |
|
||||
| `server/application/services/auth_service.py` | HIGH-5(bcrypt ValueError) |
|
||||
| `server/application/services/sync_engine_v2.py` | HIGH-4(stderr None, 2 处) |
|
||||
| `server/background/retry_runner.py` | HIGH-3(僵尸任务) |
|
||||
| `server/background/heartbeat_flush.py` | HIGH-7(事务污染、时区) |
|
||||
|
||||
## 需要迁移/重启
|
||||
- 后端重启生效(`supervisorctl restart nexus`)
|
||||
- 无数据库 migration 变更
|
||||
|
||||
## 验证方式
|
||||
1. `python -c "import server.main"` — 导入无报错 ✓
|
||||
2. 登录测试 WebSocket 实时推送
|
||||
3. 下载文件功能测试
|
||||
4. 查看 `push_retry_jobs` 表确认无 `status=pending` 的僵尸记录
|
||||
@@ -0,0 +1,53 @@
|
||||
# 2026-06-02 文件管理器目录列表全空 Bug 修复(unix_ls long-iso 解析)
|
||||
|
||||
## 日期
|
||||
2026-06-02
|
||||
|
||||
## 变更摘要
|
||||
修复 `server/utils/unix_ls.py` 中 `parse_ls_la_line` 函数对 GNU `ls -la --time-style=long-iso`
|
||||
输出格式的解析错误,导致所有普通文件和目录(非软链接)被静默丢弃。
|
||||
|
||||
## 根本原因
|
||||
|
||||
`ls -la --time-style=long-iso` 输出每行 **8 个字段**(日期和时间各占 1 个字段):
|
||||
```
|
||||
drwxr-xr-x 2 root root 4096 2024-05-22 10:30 dirname
|
||||
```
|
||||
而原始代码要求 `len(parts) >= 9`,导致所有普通文件/目录行被 `return None` 丢弃。
|
||||
|
||||
软链接能幸存,是因为它们的文件名包含 ` -> target`,在 **标准格式**(3 个日期字段)下
|
||||
使字段数达到 9,但在 long-iso 格式下仍只有 8 个字段,实际上也应该被修复。
|
||||
|
||||
**表现**:
|
||||
- root 用户(最高权限)浏览任意目录,返回条目数为 0,且 `error` 为空
|
||||
- 只有软链接偶尔出现(取决于目标系统的 ls 实现)
|
||||
- 浏览 `/` 只显示 `bin, lib, lib64, sbin` 这 4 个软链接,而非全部目录
|
||||
|
||||
## 修复方案
|
||||
|
||||
在 `parse_ls_la_line` 中检测字段 `parts[5]` 是否匹配 `YYYY-MM-DD`:
|
||||
- **long-iso** → 文件名在 `parts[7]`,修改时间为 `parts[5] + parts[6]`
|
||||
- **standard** → 文件名在 `parts[8]`(同原逻辑)
|
||||
|
||||
两种格式均正确提取文件名、软链接目标、修改时间。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `server/utils/unix_ls.py` | 修复 `parse_ls_la_line`,兼容 long-iso 格式(8 字段) |
|
||||
|
||||
## 重要说明
|
||||
|
||||
- 此 Bug 与 SSH 用户权限(root / 非 root)**无关**
|
||||
- 使用 GNU `ls`(支持 `--time-style`)的服务器均受影响
|
||||
- 使用 BusyBox `ls`(不支持 `--time-style`,会 fallback 到标准格式)的服务器不受影响
|
||||
- **修复后无需重新配置 sudoers**,纯后端解析逻辑修复
|
||||
|
||||
## 需要迁移/重启
|
||||
- 后端重启生效(`supervisorctl restart nexus`)
|
||||
|
||||
## 验证方式
|
||||
1. 运行 `python scripts/_test_unix_ls.py` — 全部 OK
|
||||
2. 部署后访问 `/files?server_id=8`,应能看到 `/` 下的完整目录列表
|
||||
3. 对比修复前后:`/etc`, `/usr`, `/var`, `/tmp` 等目录应正常显示
|
||||
@@ -0,0 +1,30 @@
|
||||
# Changelog: 登录白名单 IP 绕过防暴破锁定
|
||||
|
||||
**日期**: 2026-06-02
|
||||
|
||||
## 摘要
|
||||
|
||||
修复登录流程中「白名单 IP 仍受 15 分钟失败锁定」的问题:白名单内 IP 跳过 `count_recent_failures` 锁定检查;失败时不写入 `login_attempts` 失败计数,仅写审计日志;成功登录时清除该用户历史失败记录,避免被其他 IP 的累计失败连带锁住。
|
||||
|
||||
## 动机
|
||||
|
||||
运维/内网可信 IP 已在登录白名单中,不应再被全局用户名维度的失败次数锁定;同时白名单 IP 登录成功后应解除因外网误试导致的账户锁定状态。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/application/services/auth_service.py` — `login()` 增加 `is_whitelisted_ip`、`_handle_login_failure()`、成功时 `clear_failures_for_username`
|
||||
- `server/infrastructure/database/admin_repo.py` — `LoginAttemptRepositoryImpl.clear_failures_for_username`
|
||||
- `server/domain/repositories/__init__.py` — `LoginAttemptRepository` 协议补充方法
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无需数据库迁移
|
||||
- 需重启 Nexus 后端(`supervisorctl restart nexus`)后生效
|
||||
|
||||
## 验证方式
|
||||
|
||||
1. 本地:`python -c "import server.application.services.auth_service"`
|
||||
2. 开启 `LOGIN_ALLOWLIST_ENABLED`,将当前 IP 加入白名单
|
||||
3. 用非白名单 IP 对同一用户连续失败 5 次至锁定
|
||||
4. 从白名单 IP 登录:应不被 `account_locked` 拦截;密码错误时 `audit_logs` 有 `login_failed`,`login_attempts` 不新增失败行
|
||||
5. 白名单 IP 正确密码登录后,该用户 `login_attempts` 中 `success=0` 记录应被清除
|
||||
@@ -1,7 +1,25 @@
|
||||
# 示例:Nexus 文件管理免密 sudo(由运维审阅后安装)
|
||||
# 安装:visudo -cf /etc/sudoers.d/nexus-files /etc/sudoers.d/nexus-files
|
||||
# 将 deploy 替换为实际 SSH 用户名
|
||||
# Nexus 文件管理 — 目标机免密 sudo 示例(须运维审阅后安装)
|
||||
#
|
||||
# 安装步骤(在目标服务器上):
|
||||
# 1. 将本文件复制为 /etc/sudoers.d/nexus-files(权限 0440)
|
||||
# 2. 把下面 deploy 替换为 Nexus 里该服务器的 SSH 用户名
|
||||
# 3. visudo -cf /etc/sudoers.d/nexus-files
|
||||
# 4. 用该 SSH 用户执行:sudo -n true && sudo -n bash -c 'command -v chmod'
|
||||
#
|
||||
# 说明:
|
||||
# - 后端在权限失败时会执行 sudo -n bash -c '…',因此必须允许 /bin/bash 或 /usr/bin/bash
|
||||
# - 写入回退还会用到 tee、mv、mkdir、rm、cat 等
|
||||
# - 禁止使用 ALL=(ALL) NOPASSWD: ALL
|
||||
|
||||
deploy ALL=(ALL) NOPASSWD: /bin/chmod, /usr/bin/chmod, /bin/chown, /usr/bin/chown, \
|
||||
/bin/cp, /bin/mv, /bin/mkdir, /bin/rm, /bin/tar, /usr/bin/tar, \
|
||||
/usr/bin/unzip, /usr/bin/zip, /usr/bin/tee, /bin/cat, /usr/bin/touch
|
||||
deploy ALL=(ALL) NOPASSWD: \
|
||||
/bin/bash, /usr/bin/bash, \
|
||||
/bin/chmod, /usr/bin/chmod, \
|
||||
/bin/chown, /usr/bin/chown, \
|
||||
/bin/cp, /usr/bin/cp, \
|
||||
/bin/mv, /usr/bin/mv, \
|
||||
/bin/mkdir, /usr/bin/mkdir, \
|
||||
/bin/rm, /usr/bin/rm, \
|
||||
/bin/tar, /usr/bin/tar, \
|
||||
/usr/bin/unzip, /usr/bin/zip, \
|
||||
/usr/bin/tee, /bin/cat, /usr/bin/cat, \
|
||||
/usr/bin/touch, /bin/touch
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 文件管理器重构 P1 — 拆分 Composable
|
||||
|
||||
**日期**: 2026-06-02
|
||||
**设计**: `docs/design/specs/2026-06-02-files-refactor-design.md` §3 P1
|
||||
**前置**: P0 JWT 主动续期(已完成)
|
||||
|
||||
## 目标
|
||||
|
||||
- 将 `FilesPage.vue` 业务逻辑迁入 `stores/files.ts` + `composables/files/*`
|
||||
- `FilesPage.vue` 仅保留模板、筛选 UI 状态、组合 composable(目标 script < 400 行,P2 再压至 ≤250)
|
||||
- 引入 Pinia 单一真相 + 5s 目录缓存(P3 子集)
|
||||
|
||||
## 文件清单
|
||||
|
||||
| 路径 | 职责 |
|
||||
|------|------|
|
||||
| `frontend/src/stores/files.ts` | 服务器/路径/列表/loading/权限/缓存 |
|
||||
| `frontend/src/composables/files/constants.ts` | sessionStorage 键 |
|
||||
| `frontend/src/composables/files/useFilesBrowse.ts` | browse、错误文案、emptyHint |
|
||||
| `frontend/src/composables/files/useFilesNavigation.ts` | 路由同步、面包屑、初始化、watch |
|
||||
| `frontend/src/composables/files/useFilesPermissions.ts` | capability、横幅、一键 sudo |
|
||||
| `frontend/src/composables/files/useFilesEditor.ts` | 预览/编辑/保存回调 |
|
||||
| `frontend/src/composables/files/useFilesActions.ts` | 上传/删除/重命名/压缩等 |
|
||||
| `frontend/src/composables/files/index.ts` | 桶导出 |
|
||||
| `frontend/src/pages/FilesPage.vue` | 瘦身后页面 |
|
||||
|
||||
## 测试要点
|
||||
|
||||
- `npx vite build` 通过
|
||||
- 进目录仍 ≤1 次 `/sync/browse`(缓存命中 0 次)
|
||||
- 切换服务器、面包屑、粘贴、编辑、sudo 一键配置行为不变
|
||||
|
||||
## 回滚
|
||||
|
||||
`git revert` P1 commit;`FilesPage.vue` 单文件可独立回退。
|
||||
@@ -0,0 +1,27 @@
|
||||
# 文件管理器重构 P2 — 拆分 Vue 组件
|
||||
|
||||
**日期**: 2026-06-02
|
||||
**设计**: `docs/design/specs/2026-06-02-files-refactor-design.md` §3 P2
|
||||
**前置**: P1 composable + Pinia store
|
||||
|
||||
## 目标
|
||||
|
||||
- 模板迁入 `frontend/src/components/files/*`
|
||||
- `FilesPage.vue` ≤ 250 行(实际 ~45 行模板 + ~25 行 script)
|
||||
- 子组件通过 `provideFilesPage` / `useFilesPageContext` 共享状态,避免 props 爆炸
|
||||
|
||||
## 组件清单
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| `FilesToolbar.vue` | 服务器、路径、筛选、操作按钮 |
|
||||
| `FilesPermissionAlert.vue` | 权限横幅 + 一键 sudo |
|
||||
| `FilesStatusBar.vue` | 目录统计、剪贴板提示 |
|
||||
| `FilesBreadcrumb.vue` | 面包屑 + 批量操作条 |
|
||||
| `FilesList.vue` | 表格、拖拽上传、右键菜单 |
|
||||
| `FilesDialogs.vue` | 上传/新建/删除/重命名/压缩/预览等对话框 |
|
||||
|
||||
## 验证
|
||||
|
||||
- `npx vite build`
|
||||
- 文件管理全功能回归(与 P1 相同)
|
||||
@@ -64,7 +64,7 @@ flowchart LR
|
||||
| A-04 | ruff `server/` 零错误 | [x] | |
|
||||
| A-05 | import 冒烟 | [x] | |
|
||||
| A-06 | bandit 无 HIGH/MEDIUM | [x] | 门控仅 HIGH;0 HIGH |
|
||||
| A-07 | 7 门预检脚本 | [ ] | 生产需重启后重跑;已修 `DEPLOY_DIR` 默认仓库根 |
|
||||
| A-07 | 7 门预检脚本 | [x] | 生产 7/7(已删 `server/server/` 遗留目录) |
|
||||
| A-08 | 合并 changelog(本次主题) | [x] | 见 `docs/changelog/2026-06-01-*.md` 系列(posix / files / phase5 / phase2 / capability) |
|
||||
| A-09 | WSL 集成 smoke(若环境可用) | [ ] | `wsl_integration_smoke.sh` 或项目等价脚本 |
|
||||
|
||||
@@ -78,11 +78,11 @@ flowchart LR
|
||||
|----|------|------|----------|
|
||||
| DEP-01 | `git push origin main` | [x] | d93c518 |
|
||||
| DEP-02 | 生产 `git fetch && reset --hard origin/main` | [x] | |
|
||||
| DEP-03 | `supervisorctl restart nexus` | [ ] | 待 gate 通过后执行 |
|
||||
| DEP-04 | 健康检查 | [x] | 拉取前已 ok;重启后待复验 |
|
||||
| DEP-05 | 前端 `deploy/deploy-frontend.sh` | [ ] | `web/app/index.html` + `assets/` 已更新 |
|
||||
| DEP-06 | 浏览器 `/app/` HTTP 200 | [ ] | 非旧 Tailwind 静态页 |
|
||||
| DEP-07 | 记录部署日期到本文 | [ ] | 在 §部署记录 填一行 |
|
||||
| DEP-03 | `supervisorctl restart nexus` | [x] | e704b01 后已 restart |
|
||||
| DEP-04 | 健康检查 | [x] | `curl` → `ok` |
|
||||
| DEP-05 | 前端 `deploy/deploy-frontend.sh` | [x] | Windows Node 22 `npm run build` + tar/scp |
|
||||
| DEP-06 | 浏览器 `/app/` HTTP 200 | [x] | 生产 `curl` → 200 |
|
||||
| DEP-07 | 记录部署日期到本文 | [x] | 见下表 |
|
||||
|
||||
**依赖**:Phase A 的 A-07 通过。
|
||||
**路径 SSOT**:`/www/wwwroot/api.synaglobal.vip/` · SSH 别名 `nexus` · 端口 `8600`。
|
||||
@@ -91,7 +91,7 @@ flowchart LR
|
||||
|
||||
| 日期 | 操作人 | DEP-01~06 | 备注 |
|
||||
|------|--------|------------|------|
|
||||
| | | | |
|
||||
| 2026-06-01 | AI | 01-06 ✓ | bd6467d 后端 + 前端 FilesPage 等已上线;/app/ 200 |
|
||||
|
||||
---
|
||||
|
||||
@@ -231,7 +231,7 @@ flowchart LR
|
||||
代码实现 [x] POSIX 1-7 + 文件管理 P1-P5 + 二期
|
||||
本地单测门控 [x] Phase A(A-07 生产待重跑)
|
||||
正式审计 8 步 [x] Phase D(合订 master-execution-audit)
|
||||
生产部署 [~] Phase B(已 push+pull;restart+前端待完成)
|
||||
生产部署 [x] Phase B
|
||||
浏览器 L1-L4 [ ] Phase C
|
||||
POSIX 13-18 [~] Phase E(13 完成;14-18 待做)
|
||||
全站 T1-T8 [ ] Phase F
|
||||
|
||||
+66
-9
@@ -166,7 +166,14 @@
|
||||
</v-app-bar>
|
||||
|
||||
<!-- ── Navigation Drawer (隐藏于登录页) ── -->
|
||||
<v-navigation-drawer v-if="auth.isLoggedIn" v-model="drawer" color="surface" width="250">
|
||||
<v-navigation-drawer
|
||||
v-if="auth.isLoggedIn"
|
||||
v-model="drawer"
|
||||
color="surface"
|
||||
width="250"
|
||||
class="nexus-nav-drawer"
|
||||
:temporary="mobile"
|
||||
>
|
||||
<v-list nav slim>
|
||||
<v-list-subheader>运维</v-list-subheader>
|
||||
|
||||
@@ -181,6 +188,7 @@
|
||||
link
|
||||
nav
|
||||
slim
|
||||
@click="navigateTo(item.to)"
|
||||
/>
|
||||
</v-list>
|
||||
|
||||
@@ -200,6 +208,7 @@
|
||||
link
|
||||
nav
|
||||
slim
|
||||
@click="navigateTo(item.to)"
|
||||
/>
|
||||
</v-list>
|
||||
|
||||
@@ -219,8 +228,8 @@
|
||||
</v-navigation-drawer>
|
||||
|
||||
<!-- ── Main Content ── -->
|
||||
<v-main :class="{ 'nexus-terminal-main': route.path === '/terminal' }">
|
||||
<router-view />
|
||||
<v-main :class="mainLayoutClass">
|
||||
<router-view :key="route.fullPath" />
|
||||
</v-main>
|
||||
|
||||
<!-- ── Snackbar ── -->
|
||||
@@ -231,9 +240,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, onUnmounted } from 'vue'
|
||||
import { ref, reactive, watch, onUnmounted, provide, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { useTheme, useDisplay } from 'vuetify'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import { api, http } from '@/api'
|
||||
@@ -273,6 +282,15 @@ async function toggleTheme() {
|
||||
}
|
||||
|
||||
const drawer = ref(true)
|
||||
const { mobile } = useDisplay()
|
||||
|
||||
/** 终端页监听菜单开合,在动画结束后 refit xterm */
|
||||
provide('nexusDrawer', drawer)
|
||||
|
||||
const mainLayoutClass = computed(() => ({
|
||||
'nexus-terminal-main': route.path === '/terminal',
|
||||
'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
|
||||
}))
|
||||
|
||||
const opsItems = [
|
||||
{ to: '/', title: '仪表盘', icon: 'mdi-view-dashboard-outline' },
|
||||
@@ -347,7 +365,16 @@ function goToCredentials() {
|
||||
}
|
||||
function goToSchedules() {
|
||||
searchMenuOpen.value = false
|
||||
router.push('/schedules')
|
||||
navigateTo('/schedules')
|
||||
}
|
||||
|
||||
function navigateTo(to: string) {
|
||||
if (route.path !== to) {
|
||||
router.push(to).catch(() => {})
|
||||
}
|
||||
if (mobile.value) {
|
||||
drawer.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Theme ──
|
||||
@@ -380,18 +407,48 @@ interface SearchResults {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 终端页占满主内容区,避免 xterm 高度为 0 */
|
||||
/*
|
||||
* 终端页:高度占满内容区;宽度随 --v-layout-left 随侧栏开合自动缩进(勿 padding:0)
|
||||
*/
|
||||
.v-application .v-main.nexus-terminal-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100dvh - 64px);
|
||||
height: calc(100dvh - var(--v-layout-top, 64px));
|
||||
min-height: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
transition:
|
||||
padding 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
height 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.v-application .v-main.nexus-terminal-main > .v-container {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 仪表盘等业务页:随侧栏 --v-layout-left 缩进,表格可横向滚动 */
|
||||
.v-application .v-main.nexus-page-main {
|
||||
box-sizing: border-box;
|
||||
min-height: calc(100dvh - var(--v-layout-top, 64px));
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
transition: padding 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.v-application .v-main.nexus-page-main > * {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 侧栏菜单项过多时可滚动,避免「调度/重试队列」在视口外无法点击 */
|
||||
.nexus-nav-drawer :deep(.v-navigation-drawer__content) {
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -79,6 +79,12 @@ export async function fetchAuthed(
|
||||
const { params, _retry, ...init } = opts
|
||||
const url = buildApiUrl(path, params)
|
||||
const auth = useAuthStore()
|
||||
|
||||
// 主动续期:距过期 < 5min 且非 auth 路径,先刷新再发请求(消除 401 重试双请求)
|
||||
if (!_retry && !AUTH_SKIP_REFRESH.has(path) && auth.token && auth.isTokenExpiringSoon(300)) {
|
||||
await tryRefreshSession()
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: buildAuthHeaders(init),
|
||||
@@ -206,8 +212,17 @@ export const http = {
|
||||
api<T>(path, { method: 'GET', params }),
|
||||
|
||||
getList: async <T = any>(path: string, params?: Record<string, any>) => {
|
||||
const arr = await api<T[]>(path, { method: 'GET', params })
|
||||
return { items: (arr || []) as T[], total: (arr || []).length }
|
||||
const data = await api<unknown>(path, { method: 'GET', params })
|
||||
if (Array.isArray(data)) {
|
||||
return { items: data as T[], total: data.length }
|
||||
}
|
||||
if (data && typeof data === 'object') {
|
||||
const obj = data as { items?: T[]; total?: number }
|
||||
if (Array.isArray(obj.items)) {
|
||||
return { items: obj.items, total: obj.total ?? obj.items.length }
|
||||
}
|
||||
}
|
||||
return { items: [] as T[], total: 0 }
|
||||
},
|
||||
|
||||
post: <T = any>(path: string, body?: unknown) =>
|
||||
|
||||
@@ -7,16 +7,18 @@
|
||||
:style="floatContainerStyle"
|
||||
>
|
||||
<v-card border rounded="lg" class="file-editor-panel d-flex flex-column h-100">
|
||||
<v-toolbar
|
||||
density="compact"
|
||||
color="surface"
|
||||
class="flex-grow-0 file-editor-toolbar"
|
||||
<!-- ── Toolbar ── -->
|
||||
<div
|
||||
class="file-editor-toolbar d-flex align-center flex-shrink-0"
|
||||
:class="{ 'file-editor-toolbar--draggable': !maximized }"
|
||||
@mousedown="onToolbarMouseDown"
|
||||
>
|
||||
<v-icon v-if="!maximized" size="18" class="mr-1 text-medium-emphasis file-editor-drag-hint">
|
||||
<!-- Drag handle -->
|
||||
<v-icon v-if="!maximized" size="16" class="ml-2 mr-1 text-disabled file-editor-drag-hint" @mousedown.stop>
|
||||
mdi-drag
|
||||
</v-icon>
|
||||
|
||||
<!-- File tabs -->
|
||||
<v-tabs
|
||||
v-model="activeTabId"
|
||||
density="compact"
|
||||
@@ -29,49 +31,75 @@
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:value="tab.id"
|
||||
class="text-none"
|
||||
class="text-none px-2"
|
||||
>
|
||||
<span class="text-truncate" style="max-width: 140px;">{{ tab.name }}</span>
|
||||
<span v-if="tab.modified" class="text-error ml-1">●</span>
|
||||
<span class="text-truncate" style="max-width: 130px;">{{ tab.name }}</span>
|
||||
<span v-if="tab.modified" class="text-warning ml-1" style="font-size:10px;">●</span>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-1"
|
||||
class="ml-1 opacity-60"
|
||||
density="compact"
|
||||
@click.stop="requestCloseTab(tab.id)"
|
||||
/>
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
<v-select
|
||||
v-model="activeLanguageChoice"
|
||||
:items="EDITOR_LANGUAGE_OPTIONS"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="file-editor-lang-select flex-grow-0"
|
||||
label="语法"
|
||||
@mousedown.stop
|
||||
@update:model-value="onLanguageChoiceChange"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-content-save"
|
||||
|
||||
<!-- Separator -->
|
||||
<v-divider vertical class="mx-1" style="height:20px;align-self:center;" @mousedown.stop />
|
||||
|
||||
<!-- Language selector -->
|
||||
<v-select
|
||||
v-model="activeLanguageChoice"
|
||||
:items="EDITOR_LANGUAGE_OPTIONS"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
density="compact"
|
||||
variant="plain"
|
||||
hide-details
|
||||
class="file-editor-lang-select flex-shrink-0"
|
||||
@mousedown.stop
|
||||
@update:model-value="onLanguageChoiceChange"
|
||||
/>
|
||||
|
||||
<!-- Word wrap toggle -->
|
||||
<v-btn
|
||||
:icon="wordWrap ? 'mdi-wrap' : 'mdi-wrap-disabled'"
|
||||
size="small"
|
||||
variant="text"
|
||||
:color="wordWrap ? 'primary' : undefined"
|
||||
:title="wordWrap ? '关闭自动换行 (Alt+Z)' : '开启自动换行 (Alt+Z)'"
|
||||
density="compact"
|
||||
class="flex-shrink-0"
|
||||
@mousedown.stop
|
||||
@click="toggleWordWrap"
|
||||
/>
|
||||
|
||||
<!-- Separator -->
|
||||
<v-divider vertical class="mx-1" style="height:20px;align-self:center;" @mousedown.stop />
|
||||
|
||||
<!-- Save -->
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-content-save"
|
||||
:loading="saving"
|
||||
:disabled="!activeTab?.modified || !ready"
|
||||
class="flex-shrink-0 mr-1"
|
||||
@mousedown.stop
|
||||
@click="saveActive"
|
||||
>
|
||||
保存
|
||||
</v-btn>
|
||||
|
||||
<!-- Window controls -->
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
:icon="maximized ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
|
||||
:title="maximized ? '退出全屏' : '全屏'"
|
||||
:title="maximized ? '退出全屏 (Esc)' : '全屏'"
|
||||
density="compact"
|
||||
@mousedown.stop
|
||||
@click="maximized = !maximized"
|
||||
/>
|
||||
@@ -80,6 +108,7 @@
|
||||
variant="text"
|
||||
icon="mdi-window-minimize"
|
||||
title="最小化"
|
||||
density="compact"
|
||||
@mousedown.stop
|
||||
@click="minimized = true"
|
||||
/>
|
||||
@@ -88,11 +117,14 @@
|
||||
variant="text"
|
||||
icon="mdi-close"
|
||||
title="关闭编辑器"
|
||||
density="compact"
|
||||
class="mr-1"
|
||||
@mousedown.stop
|
||||
@click="requestClosePanel"
|
||||
/>
|
||||
</v-toolbar>
|
||||
</div>
|
||||
|
||||
<!-- ── Main area ── -->
|
||||
<div class="file-editor-main d-flex flex-grow-1 min-h-0">
|
||||
<aside class="file-editor-sidebar flex-shrink-0" @mousedown.stop>
|
||||
<FileDirectoryTree
|
||||
@@ -108,10 +140,16 @@
|
||||
<v-progress-linear v-if="!ready" indeterminate absolute class="file-editor-progress" />
|
||||
<div ref="editorHost" class="file-editor-host" />
|
||||
</div>
|
||||
<div class="file-editor-status text-caption px-3 py-1 d-flex flex-wrap ga-3">
|
||||
<span>行 {{ cursorLine }}, 列 {{ cursorColumn }}</span>
|
||||
<!-- Status bar -->
|
||||
<div class="file-editor-status text-caption px-3 py-1 d-flex flex-wrap align-center ga-3">
|
||||
<span class="text-mono">{{ cursorLine }}:{{ cursorColumn }}</span>
|
||||
<v-divider vertical style="height:12px;" />
|
||||
<span>{{ activeLanguage }}</span>
|
||||
<span class="text-truncate flex-grow-1">{{ activeTab?.path ?? '' }}</span>
|
||||
<v-divider vertical style="height:12px;" />
|
||||
<span v-if="wordWrap" class="text-primary" style="font-size:11px;">换行</span>
|
||||
<v-divider v-if="wordWrap" vertical style="height:12px;" />
|
||||
<span class="text-truncate flex-grow-1 text-medium-emphasis">{{ activeTab?.path ?? '' }}</span>
|
||||
<span v-if="activeTab?.modified" class="text-warning">未保存</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,6 +163,7 @@
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- Minimized dock -->
|
||||
<v-card
|
||||
v-if="tabs.length > 0 && minimized"
|
||||
border
|
||||
@@ -142,7 +181,7 @@
|
||||
@click="restoreTab(tab.id)"
|
||||
>
|
||||
{{ tab.name }}
|
||||
<span v-if="tab.modified" class="text-error ml-1">●</span>
|
||||
<span v-if="tab.modified" class="text-warning ml-1">●</span>
|
||||
</v-chip>
|
||||
<v-spacer />
|
||||
<v-btn size="x-small" variant="text" icon="mdi-dock-window" title="展开" @click="minimized = false" />
|
||||
@@ -150,6 +189,7 @@
|
||||
</v-card>
|
||||
</Teleport>
|
||||
|
||||
<!-- Unsaved-changes dialog -->
|
||||
<v-dialog v-model="showCloseConfirm" max-width="400">
|
||||
<v-card border>
|
||||
<v-card-title>{{ closeConfirmTitle }}</v-card-title>
|
||||
@@ -229,6 +269,7 @@ const showCloseConfirm = ref(false)
|
||||
const cursorLine = ref(1)
|
||||
const cursorColumn = ref(1)
|
||||
const treeBrowsePath = ref(props.currentPath || '/')
|
||||
const wordWrap = ref(false)
|
||||
|
||||
const editorHost = ref<HTMLElement>()
|
||||
let editor: Monaco.editor.IStandaloneCodeEditor | null = null
|
||||
@@ -356,6 +397,7 @@ function mountEditor() {
|
||||
editor = monaco.editor.create(editorHost.value, {
|
||||
...buildMonacoEditorOptions(),
|
||||
model,
|
||||
wordWrap: wordWrap.value ? 'on' : 'off',
|
||||
})
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
@@ -376,6 +418,11 @@ function mountEditor() {
|
||||
else requestClosePanel()
|
||||
})
|
||||
|
||||
// Alt+Z: toggle word wrap (VSCode convention)
|
||||
editor.addCommand(monaco.KeyMod.Alt | monaco.KeyCode.KeyZ, () => {
|
||||
toggleWordWrap()
|
||||
})
|
||||
|
||||
layoutObserver = new ResizeObserver(() => {
|
||||
editor?.layout()
|
||||
})
|
||||
@@ -554,8 +601,12 @@ async function confirmSaveClose() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleWordWrap() {
|
||||
wordWrap.value = !wordWrap.value
|
||||
editor?.updateOptions({ wordWrap: wordWrap.value ? 'on' : 'off' })
|
||||
}
|
||||
|
||||
async function saveActive() {
|
||||
if (!editor || !activeTab.value) return
|
||||
if (!props.serverId) {
|
||||
snackbar('请先选择服务器', 'error')
|
||||
return
|
||||
@@ -637,6 +688,14 @@ onBeforeUnmount(() => {
|
||||
height: 100%;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
/* ── Toolbar ── */
|
||||
.file-editor-toolbar {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-editor-toolbar--draggable {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
@@ -644,11 +703,40 @@ onBeforeUnmount(() => {
|
||||
.file-editor-toolbar--draggable:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.file-editor-drag-hint {
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-editor-tabs {
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
}
|
||||
.file-editor-tabs :deep(.v-tab) {
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
padding-inline: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.file-editor-tabs :deep(.v-tab--selected) {
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
.file-editor-lang-select {
|
||||
width: 110px;
|
||||
min-width: 90px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.file-editor-lang-select :deep(.v-field__input) {
|
||||
font-size: 12px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
min-height: 28px;
|
||||
}
|
||||
/* ── Main area ── */
|
||||
.file-editor-main {
|
||||
min-height: 0;
|
||||
}
|
||||
.file-editor-sidebar {
|
||||
width: 220px;
|
||||
width: 210px;
|
||||
border-right: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
@@ -672,23 +760,35 @@ onBeforeUnmount(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* ── Status bar ── */
|
||||
.file-editor-status {
|
||||
background: #252526;
|
||||
color: #cccccc;
|
||||
border-top: 1px solid #3c3c3c;
|
||||
background: rgb(var(--v-theme-primary));
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-top: none;
|
||||
flex-shrink: 0;
|
||||
height: 24px;
|
||||
font-size: 11px;
|
||||
line-height: 24px;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
.file-editor-lang-select {
|
||||
max-width: 132px;
|
||||
min-width: 108px;
|
||||
.file-editor-status .text-primary {
|
||||
color: rgba(255, 255, 255, 1) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.file-editor-status .text-warning {
|
||||
color: #FFD740 !important;
|
||||
}
|
||||
.file-editor-status .text-medium-emphasis {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.text-mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
/* ── Resize handle ── */
|
||||
.file-editor-progress {
|
||||
z-index: 2;
|
||||
}
|
||||
.file-editor-tabs :deep(.v-tab) {
|
||||
min-width: 0;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
.file-editor-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
@@ -703,6 +803,7 @@ onBeforeUnmount(() => {
|
||||
rgba(var(--v-theme-on-surface), 0.25) 50%
|
||||
);
|
||||
}
|
||||
/* ── Minimized dock ── */
|
||||
.file-editor-dock {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
|
||||
@@ -173,11 +173,8 @@
|
||||
|
||||
|
||||
<p class="text-caption text-medium-emphasis mb-0">
|
||||
|
||||
非 root 用户写/chmod 失败时将按服务器策略尝试 <code>sudo -n</code>。
|
||||
|
||||
示例配置见仓库 <code>{{ docsPath }}</code>(由运维审阅后安装到目标机)。
|
||||
|
||||
{{ filesSudoersDocHint() }}
|
||||
</p>
|
||||
|
||||
</v-card-text>
|
||||
@@ -208,7 +205,7 @@ import type { FileEntry, FilesCapabilityResult, FilesElevationMode } from '@/typ
|
||||
|
||||
import { http } from '@/api'
|
||||
|
||||
import { CHMOD_PRESETS, permissionTooltip } from '@/utils/fileMode'
|
||||
import { CHMOD_PRESETS, filesSudoersDocHint, permissionTooltip } from '@/utils/fileMode'
|
||||
|
||||
|
||||
|
||||
@@ -249,10 +246,6 @@ const recursive = ref(false)
|
||||
|
||||
const isDirectory = computed(() => props.file?.type === 'directory')
|
||||
|
||||
const docsPath = computed(() => capability.value?.docs_path ?? 'docs/deploy/nexus-files-sudoers.example')
|
||||
|
||||
|
||||
|
||||
const fileName = computed(() => props.file?.name ?? '')
|
||||
|
||||
const currentSummary = computed(() => (props.file ? permissionTooltip(props.file) : ''))
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<template>
|
||||
<div
|
||||
class="sh-field position-relative"
|
||||
:class="{ 'sh-field--disabled': disabled, 'sh-field--empty': !modelValue }"
|
||||
:class="{
|
||||
'sh-field--disabled': disabled,
|
||||
'sh-field--empty': !modelValue,
|
||||
'sh-field--large': size === 'large',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="modelValue"
|
||||
@@ -37,12 +41,14 @@ const props = withDefaults(
|
||||
disabled?: boolean
|
||||
multiline?: boolean
|
||||
rows?: number
|
||||
size?: 'default' | 'large'
|
||||
}>(),
|
||||
{
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
multiline: false,
|
||||
rows: 3,
|
||||
size: 'default',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -123,4 +129,27 @@ function onKeydown(e: KeyboardEvent) {
|
||||
.sh-field:not(.sh-field--empty) .sh-field__input {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.sh-field--large .sh-field__highlight,
|
||||
.sh-field--large .sh-field__input {
|
||||
font-size: 1rem;
|
||||
line-height: 1.55;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.sh-field--large .sh-field__input--single {
|
||||
min-height: 108px;
|
||||
}
|
||||
|
||||
.sh-field--large .sh-field__highlight--single {
|
||||
min-height: 108px;
|
||||
}
|
||||
|
||||
.sh-field--large .sh-field__input--multi {
|
||||
min-height: 108px;
|
||||
}
|
||||
|
||||
.sh-field--large .sh-field__highlight--multi {
|
||||
min-height: 108px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<v-card id="terminal-quick-commands" elevation="0" border rounded="lg" class="mb-4">
|
||||
<v-card-title class="d-flex align-center flex-wrap ga-2">
|
||||
<v-icon class="mr-1">mdi-flash</v-icon>
|
||||
终端快捷命令
|
||||
<v-spacer />
|
||||
<v-btn size="small" color="primary" variant="flat" prepend-icon="mdi-plus" @click="openCreate">
|
||||
添加命令
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<div class="text-body-2 text-medium-emphasis mb-4">
|
||||
自定义命令显示在终端栏最前(优先级最高)。终端页仅可点击执行;在此添加、编辑、删除与调整顺序。
|
||||
</div>
|
||||
|
||||
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-3" />
|
||||
|
||||
<div v-if="customCommands().length === 0 && !loading" class="text-medium-emphasis text-body-2 mb-4">
|
||||
暂无自定义命令,点击「添加命令」创建。
|
||||
</div>
|
||||
|
||||
<v-list v-if="customCommands().length" density="compact" class="mb-4 pa-0">
|
||||
<v-list-item
|
||||
v-for="(cmd, index) in customCommands()"
|
||||
:key="cmd.id"
|
||||
border
|
||||
rounded="lg"
|
||||
class="mb-2"
|
||||
>
|
||||
<template #prepend>
|
||||
<span class="text-caption text-medium-emphasis mr-2" style="min-width: 1.5rem">{{ index + 1 }}</span>
|
||||
</template>
|
||||
<v-list-item-title>{{ cmd.name }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="font-mono text-truncate">{{ cmd.cmd }}</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-btn
|
||||
icon="mdi-arrow-up"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:disabled="index === 0 || savingOrder"
|
||||
@click="moveUp(index)"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-arrow-down"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:disabled="index === customCommands().length - 1 || savingOrder"
|
||||
@click="moveDown(index)"
|
||||
/>
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text" @click="openEdit(cmd)" />
|
||||
<v-btn icon="mdi-delete" size="x-small" variant="text" color="error" @click="confirmRemove(cmd)" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<div class="text-subtitle-2 mb-2">系统内置(不可编辑)</div>
|
||||
<div class="d-flex flex-wrap ga-2">
|
||||
<v-chip
|
||||
v-for="cmd in builtinCommands()"
|
||||
:key="cmd.id"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label
|
||||
>
|
||||
{{ cmd.name }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-dialog v-model="showEditor" max-width="520">
|
||||
<v-card border>
|
||||
<v-card-title>{{ editingId ? '编辑命令' : '添加快捷命令' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="formName" label="显示名称" variant="outlined" density="compact" class="mb-3" />
|
||||
<div class="text-body-2 text-medium-emphasis mb-2">命令内容(发送时自动追加回车)</div>
|
||||
<ShellHighlightField v-model="formCmd" multiline :rows="4" placeholder="systemctl status nginx" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showEditor = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" :loading="saving" @click="save">保存</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="showDelete" max-width="400">
|
||||
<v-card border>
|
||||
<v-card-title>删除命令</v-card-title>
|
||||
<v-card-text>确定删除「{{ deletingName }}」?</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showDelete = false">取消</v-btn>
|
||||
<v-btn color="error" variant="flat" :loading="saving" @click="doDelete">删除</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import ShellHighlightField from '@/components/ShellHighlightField.vue'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useTerminalQuickCommands, type QuickCmdItem } from '@/composables/useTerminalQuickCommands'
|
||||
|
||||
const snackbar = useSnackbar()
|
||||
const { commands, loading, load, create, update, remove, reorder, customCommands, builtinCommands } =
|
||||
useTerminalQuickCommands()
|
||||
|
||||
const showEditor = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const formName = ref('')
|
||||
const formCmd = ref('')
|
||||
const saving = ref(false)
|
||||
const savingOrder = ref(false)
|
||||
|
||||
const showDelete = ref(false)
|
||||
const deletingId = ref<number | null>(null)
|
||||
const deletingName = ref('')
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
formName.value = ''
|
||||
formCmd.value = ''
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
function openEdit(cmd: QuickCmdItem) {
|
||||
editingId.value = cmd.id
|
||||
formName.value = cmd.name
|
||||
formCmd.value = cmd.cmd.replace(/\r$/, '')
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!formName.value.trim() || !formCmd.value.trim()) {
|
||||
snackbar('名称和命令不能为空', 'warning')
|
||||
return
|
||||
}
|
||||
let cmdText = formCmd.value.trim()
|
||||
if (!cmdText.endsWith('\r')) cmdText += '\r'
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) await update(editingId.value, formName.value, cmdText)
|
||||
else await create(formName.value, cmdText)
|
||||
showEditor.value = false
|
||||
snackbar('已保存')
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRemove(cmd: QuickCmdItem) {
|
||||
deletingId.value = cmd.id
|
||||
deletingName.value = cmd.name
|
||||
showDelete.value = true
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deletingId.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await remove(deletingId.value)
|
||||
showDelete.value = false
|
||||
snackbar('已删除')
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '删除失败', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function moveUp(index: number) {
|
||||
const list = customCommands()
|
||||
if (index <= 0) return
|
||||
const ids = list.map((c) => c.id)
|
||||
const prev = ids[index - 1]
|
||||
ids[index - 1] = ids[index]
|
||||
ids[index] = prev
|
||||
await applyOrder(ids)
|
||||
}
|
||||
|
||||
async function moveDown(index: number) {
|
||||
const list = customCommands()
|
||||
if (index >= list.length - 1) return
|
||||
const ids = list.map((c) => c.id)
|
||||
const next = ids[index + 1]
|
||||
ids[index + 1] = ids[index]
|
||||
ids[index] = next
|
||||
await applyOrder(ids)
|
||||
}
|
||||
|
||||
async function applyOrder(ids: number[]) {
|
||||
savingOrder.value = true
|
||||
try {
|
||||
await reorder(ids)
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '排序失败', 'error')
|
||||
} finally {
|
||||
savingOrder.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load().catch((e) => snackbar(e.message, 'error'))
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { useFilesPageContext } from '@/composables/files/filesPageContext'
|
||||
|
||||
const p = useFilesPageContext()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-breadcrumbs :items="p.breadcrumbs" class="pa-0 mb-2">
|
||||
<template #item="{ item, index }">
|
||||
<v-breadcrumbs-item
|
||||
:disabled="item.disabled"
|
||||
:class="{ 'text-primary': !item.disabled, 'cursor-pointer': !item.disabled }"
|
||||
@click="p.onBreadcrumbClick(index)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</v-breadcrumbs-item>
|
||||
</template>
|
||||
<template #divider>/</template>
|
||||
</v-breadcrumbs>
|
||||
|
||||
<v-card v-if="p.selectedFileCount > 0" class="mb-4" color="primary" variant="tonal" rounded="lg">
|
||||
<v-card-text class="d-flex align-center py-2">
|
||||
<span class="text-body-2 mr-4">已选择 {{ p.selectedFileCount }} 个文件</span>
|
||||
<v-spacer />
|
||||
<v-btn size="small" variant="text" color="error" @click="p.batchDelete">批量删除</v-btn>
|
||||
<v-btn size="small" variant="text" @click="p.selectedFiles = []">取消选择</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import FilePermissionDialog from '@/components/FilePermissionDialog.vue'
|
||||
import { useFilesPageContext } from '@/composables/files/filesPageContext'
|
||||
|
||||
const p = useFilesPageContext()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog v-model="p.showUpload" max-width="500">
|
||||
<v-card border>
|
||||
<v-card-title>上传文件</v-card-title>
|
||||
<v-card-text>
|
||||
<v-file-input v-model="p.uploadFiles" label="选择文件" variant="outlined" multiple show-size />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="p.showUpload = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" :loading="p.uploading" @click="p.doUpload">上传</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="p.showNewFile" max-width="400">
|
||||
<v-card border>
|
||||
<v-card-title>新建文件</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="p.newFileName"
|
||||
label="文件名"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[p.validatePathSegment]"
|
||||
placeholder="example.conf"
|
||||
autofocus
|
||||
@keydown.enter="p.doNewFile"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="p.showNewFile = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" :loading="p.actionLoading" @click="p.doNewFile">创建</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="p.showMkdir" max-width="400">
|
||||
<v-card border>
|
||||
<v-card-title>新建目录</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="p.mkdirName"
|
||||
label="目录名"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[p.validatePathSegment]"
|
||||
@keydown.enter="p.doMkdir"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="p.showMkdir = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="p.doMkdir">创建</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="p.showFileDelete" max-width="400">
|
||||
<v-card border>
|
||||
<v-card-title>确认删除</v-card-title>
|
||||
<v-card-text>
|
||||
确定要删除 <strong>{{ p.deletingFile?.name }}</strong> 吗?
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="p.showFileDelete = false">取消</v-btn>
|
||||
<v-btn color="error" variant="flat" @click="p.doDeleteFile">删除</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="p.showRename" max-width="400">
|
||||
<v-card border>
|
||||
<v-card-title>重命名</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="p.newName"
|
||||
label="新名称"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[p.required('名称')]"
|
||||
autofocus
|
||||
@keydown.enter="p.doRename"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="p.showRename = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="p.doRename">确定</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<FilePermissionDialog
|
||||
v-model="p.showChmod"
|
||||
:file="p.chmodFile"
|
||||
:server-id="p.selectedServer"
|
||||
:loading="p.chmodLoading"
|
||||
@apply="p.doChmod"
|
||||
/>
|
||||
|
||||
<v-dialog v-model="p.showCompress" max-width="440">
|
||||
<v-card border>
|
||||
<v-card-title>压缩</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-3">将压缩 {{ p.compressPaths.length }} 项到当前目录</p>
|
||||
<v-text-field v-model="p.compressDest" label="压缩包路径" variant="outlined" density="compact" />
|
||||
<v-select
|
||||
v-model="p.compressFormat"
|
||||
:items="[
|
||||
{ title: 'tar.gz', value: 'tar.gz' },
|
||||
{ title: 'zip', value: 'zip' },
|
||||
]"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
label="格式"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="p.showCompress = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" :loading="p.actionLoading" @click="p.doCompress">压缩</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="p.showDecompress" max-width="440">
|
||||
<v-card border>
|
||||
<v-card-title>解压</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-2 text-truncate">{{ p.decompressSource }}</p>
|
||||
<v-text-field v-model="p.decompressDest" label="解压到目录" variant="outlined" density="compact" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="p.showDecompress = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" :loading="p.actionLoading" @click="p.doDecompress">解压</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="p.showPreview" max-width="900" scrollable>
|
||||
<v-card border>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<span class="text-truncate">{{ p.previewTitle }}</span>
|
||||
<v-spacer />
|
||||
<v-btn icon="mdi-close" variant="text" @click="p.showPreview = false" />
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text style="max-height: 70vh">
|
||||
<v-progress-linear v-if="p.previewLoading" indeterminate class="mb-2" />
|
||||
<pre v-else class="files-preview-body text-body-2">{{ p.previewContent }}</pre>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="p.showPreview = false">关闭</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.files-preview-body {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,234 @@
|
||||
<script setup lang="ts">
|
||||
import { useFilesPageContext } from '@/composables/files/filesPageContext'
|
||||
|
||||
const p = useFilesPageContext()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card
|
||||
elevation="0"
|
||||
border
|
||||
rounded="lg"
|
||||
:class="{ 'files-drop-active': p.dropActive }"
|
||||
@dragenter.prevent="p.dropActive = true"
|
||||
@dragover.prevent="p.dropActive = true"
|
||||
@dragleave.prevent="p.dropActive = false"
|
||||
@drop.prevent="p.onDropFiles"
|
||||
>
|
||||
<v-data-table
|
||||
v-model="p.selectedFiles"
|
||||
:items="p.displayedFiles"
|
||||
:headers="p.fileHeaders"
|
||||
:loading="p.loading"
|
||||
show-select
|
||||
return-object
|
||||
hover
|
||||
density="comfortable"
|
||||
item-value="name"
|
||||
:row-props="p.fileRowProps"
|
||||
>
|
||||
<template #item.name="{ item }">
|
||||
<div class="d-flex align-center ga-2 min-w-0">
|
||||
<v-icon :color="p.entryIconColor(item)" size="20">
|
||||
{{ p.entryIcon(item) }}
|
||||
</v-icon>
|
||||
<v-icon
|
||||
v-if="p.isEntryLikelyUnreadable(item, p.sshUserForFiles)"
|
||||
size="18"
|
||||
color="warning"
|
||||
title="当前 SSH 用户可能无法读取"
|
||||
>
|
||||
mdi-shield-lock
|
||||
</v-icon>
|
||||
<div class="min-w-0">
|
||||
<span :class="{ 'font-weight-medium': item.type === 'directory' }">{{ item.name }}</span>
|
||||
<div
|
||||
v-if="item.type === 'symlink' && item.link_target"
|
||||
class="text-caption text-medium-emphasis text-truncate"
|
||||
>
|
||||
→ {{ item.link_target }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.owner="{ item }">
|
||||
<v-tooltip :text="p.permissionTooltip(item)" location="top">
|
||||
<template #activator="{ props: tipProps }">
|
||||
<v-chip
|
||||
v-bind="tipProps"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:color="p.ownerChipColor(item.owner || '')"
|
||||
class="text-none"
|
||||
>
|
||||
{{ p.formatOwnerChip(item) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<template #item.size="{ item }">
|
||||
<span class="text-medium-emphasis">
|
||||
{{ item.type === 'directory' ? '—' : p.formatSize(item.size) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #item.modified="{ item }">
|
||||
<span class="text-medium-emphasis">{{ item.modified || '—' }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="d-flex ga-1">
|
||||
<v-btn
|
||||
v-if="p.isRegularFile(item)"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
density="compact"
|
||||
color="primary"
|
||||
@click.stop="p.editFile(item)"
|
||||
>
|
||||
编辑
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="p.isRegularFile(item)"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
density="compact"
|
||||
@click.stop="p.previewFile(item)"
|
||||
>
|
||||
查看
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="p.isRegularFile(item)"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
density="compact"
|
||||
@click.stop="p.downloadFile(item)"
|
||||
>
|
||||
下载
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="x-small"
|
||||
color="error"
|
||||
density="compact"
|
||||
@click.stop="p.confirmDeleteFile(item)"
|
||||
>
|
||||
删除
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #no-data>
|
||||
<div v-if="p.pageReady && !p.loading" class="text-center text-medium-emphasis py-8">
|
||||
<v-icon size="40" class="mb-2">mdi-folder-open-outline</v-icon>
|
||||
<div>{{ p.emptyHint }}</div>
|
||||
<div v-if="p.selectedServer" class="text-caption mt-2 text-disabled">
|
||||
单击目录进入 · 双击文件编辑 · Ctrl+A 全选 · Ctrl+C/V 复制粘贴
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<v-menu v-model="p.showContextMenu" :target="[p.contextX, p.contextY]" location="bottom start">
|
||||
<v-list density="compact" nav>
|
||||
<v-list-item
|
||||
v-if="p.contextFile && p.contextFile.type === 'directory'"
|
||||
prepend-icon="mdi-folder-open"
|
||||
@click="p.contextAction('open')"
|
||||
>
|
||||
<v-list-item-title>打开</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="p.contextFile && p.isRegularFile(p.contextFile)"
|
||||
prepend-icon="mdi-pencil"
|
||||
@click="p.contextAction('edit')"
|
||||
>
|
||||
<v-list-item-title>编辑</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="p.contextFile && p.isRegularFile(p.contextFile)"
|
||||
prepend-icon="mdi-eye"
|
||||
@click="p.contextAction('preview')"
|
||||
>
|
||||
<v-list-item-title>查看</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="p.contextFile && p.isRegularFile(p.contextFile)"
|
||||
prepend-icon="mdi-download"
|
||||
@click="p.contextAction('download')"
|
||||
>
|
||||
<v-list-item-title>下载</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="p.contextFile"
|
||||
prepend-icon="mdi-content-copy"
|
||||
@click="p.contextAction('copy')"
|
||||
>
|
||||
<v-list-item-title>复制 (Ctrl+C)</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="p.contextFile"
|
||||
prepend-icon="mdi-content-cut"
|
||||
@click="p.contextAction('cut')"
|
||||
>
|
||||
<v-list-item-title>剪切 (Ctrl+X)</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-content-paste"
|
||||
:disabled="!p.canPasteHere"
|
||||
@click="p.contextAction('paste')"
|
||||
>
|
||||
<v-list-item-title>粘贴到当前目录 (Ctrl+V)</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="p.contextFile?.type === 'directory' && p.canPasteHere"
|
||||
prepend-icon="mdi-folder-arrow-down"
|
||||
@click="p.contextAction('pasteInto')"
|
||||
>
|
||||
<v-list-item-title>粘贴到「{{ p.contextFile.name }}」</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="mdi-link-variant" @click="p.contextAction('copyPath')">
|
||||
<v-list-item-title>复制路径</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="mdi-console" @click="p.contextAction('terminal')">
|
||||
<v-list-item-title>终端</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider />
|
||||
<v-list-item
|
||||
v-if="p.contextFile && (p.isRegularFile(p.contextFile) || p.contextFile.type === 'directory')"
|
||||
prepend-icon="mdi-zip-box"
|
||||
@click="p.contextAction('compress')"
|
||||
>
|
||||
<v-list-item-title>压缩</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="p.contextFile && p.isArchiveName(p.contextFile.name)"
|
||||
prepend-icon="mdi-package-up"
|
||||
@click="p.contextAction('decompress')"
|
||||
>
|
||||
<v-list-item-title>解压</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider />
|
||||
<v-list-item prepend-icon="mdi-file-rename-box" @click="p.contextAction('rename')">
|
||||
<v-list-item-title>重命名</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="mdi-lock" @click="p.contextAction('chmod')">
|
||||
<v-list-item-title>修改权限</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider />
|
||||
<v-list-item prepend-icon="mdi-delete" @click="p.contextAction('delete')">
|
||||
<v-list-item-title class="text-error">删除</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.files-drop-active {
|
||||
outline: 2px dashed rgb(var(--v-theme-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { useFilesPageContext } from '@/composables/files/filesPageContext'
|
||||
|
||||
const p = useFilesPageContext()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-alert
|
||||
v-if="p.filesAccessHint"
|
||||
:type="p.filesAccessHint.type"
|
||||
variant="tonal"
|
||||
closable
|
||||
prominent
|
||||
density="comfortable"
|
||||
class="mb-4"
|
||||
icon="mdi-shield-lock-outline"
|
||||
@click:close="p.dismissFilesAccessHint"
|
||||
>
|
||||
<div class="d-flex flex-wrap align-center justify-space-between ga-2">
|
||||
<span>{{ p.filesAccessHint.text.split('目标机安装示例')[0].trim() }}</span>
|
||||
<v-btn
|
||||
v-if="p.sshUserForFiles && p.sshUserForFiles !== 'root'"
|
||||
size="small"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
prepend-icon="mdi-auto-fix"
|
||||
:loading="p.setupSudoLoading"
|
||||
:disabled="p.setupSudoDone"
|
||||
@click.stop="p.setupFilesSudo"
|
||||
>
|
||||
{{ p.setupSudoDone ? '已配置' : '一键配置 sudo 权限' }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-alert>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { useFilesPageContext } from '@/composables/files/filesPageContext'
|
||||
|
||||
const p = useFilesPageContext()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-caption text-medium-emphasis mb-2 d-flex flex-wrap align-center ga-2">
|
||||
<span>{{ p.statusSummary }}</span>
|
||||
<v-chip v-if="p.browseError" size="x-small" color="warning" variant="tonal">
|
||||
{{ p.browseError }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="p.hasClipboard"
|
||||
size="x-small"
|
||||
color="info"
|
||||
variant="tonal"
|
||||
class="cursor-pointer"
|
||||
@click="() => p.pasteClipboard()"
|
||||
>
|
||||
{{ p.clipboardSummary }} → {{ p.pasteDestLabel }} · Ctrl+V 粘贴
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { useFilesPageContext } from '@/composables/files/filesPageContext'
|
||||
|
||||
const p = useFilesPageContext()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card elevation="0" border rounded="lg" class="mb-4 pa-4">
|
||||
<v-row align="center" dense>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-select
|
||||
v-model="p.selectedServer"
|
||||
:items="p.serverList"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
label="选择服务器"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-server"
|
||||
@update:model-value="p.onServerChange"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-text-field
|
||||
v-model="p.currentPath"
|
||||
label="路径"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-folder"
|
||||
append-inner-icon="mdi-arrow-right"
|
||||
@click:append-inner="p.browseCurrent"
|
||||
@keydown.enter="p.browseCurrent"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="3">
|
||||
<v-text-field
|
||||
v-model="p.fileFilter"
|
||||
label="筛选文件名"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="3">
|
||||
<v-select
|
||||
v-model="p.extFilter"
|
||||
:items="p.FILE_EXT_FILTERS"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
label="类型"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="2">
|
||||
<v-select
|
||||
v-model="p.sortBy"
|
||||
:items="p.sortOptions"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
label="排序"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" class="d-flex ga-2 flex-wrap justify-end">
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-upload" @click="p.showUpload = true">
|
||||
上传
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-file-plus"
|
||||
:disabled="!p.selectedServer"
|
||||
@click="p.showNewFile = true"
|
||||
>
|
||||
新建文件
|
||||
</v-btn>
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-folder-plus" @click="p.showMkdir = true">
|
||||
新建目录
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-zip-box"
|
||||
:disabled="!p.selectedServer || !p.selectedFileCount"
|
||||
@click="p.openCompressDialog()"
|
||||
>
|
||||
压缩
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-content-paste"
|
||||
:disabled="!p.canPasteHere"
|
||||
@click="() => p.pasteClipboard()"
|
||||
>
|
||||
粘贴
|
||||
</v-btn>
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-arrow-up" :disabled="!p.canGoUp" @click="p.goUp">
|
||||
上级
|
||||
</v-btn>
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-refresh" @click="p.browseCurrent">刷新</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-console"
|
||||
:disabled="!p.selectedServer"
|
||||
@click="p.openInTerminal"
|
||||
>
|
||||
终端
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const FILES_LAST_SERVER_KEY = 'nexus:files:last_server_id'
|
||||
export const FILES_HINT_DISMISSED_KEY = 'nexus:files-hint-dismissed'
|
||||
|
||||
export const FILES_CACHE_TTL_MS = 5000
|
||||
export const FILES_CACHE_MAX_ENTRIES = 50
|
||||
@@ -0,0 +1,18 @@
|
||||
import { inject, provide, type InjectionKey } from 'vue'
|
||||
import type { useFilesPage } from './useFilesPage'
|
||||
|
||||
export type FilesPageContext = ReturnType<typeof useFilesPage>
|
||||
|
||||
export const filesPageKey: InjectionKey<FilesPageContext> = Symbol('filesPage')
|
||||
|
||||
export function provideFilesPage(ctx: FilesPageContext): void {
|
||||
provide(filesPageKey, ctx)
|
||||
}
|
||||
|
||||
export function useFilesPageContext(): FilesPageContext {
|
||||
const ctx = inject(filesPageKey)
|
||||
if (!ctx) {
|
||||
throw new Error('useFilesPageContext() must be used inside FilesPage')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { useFilesBrowse, isNotFoundError, friendlyBrowseError } from './useFilesBrowse'
|
||||
export { useFilesNavigation } from './useFilesNavigation'
|
||||
export { useFilesPermissions } from './useFilesPermissions'
|
||||
export { useFilesEditor } from './useFilesEditor'
|
||||
export { useFilesActions } from './useFilesActions'
|
||||
export { useFilesSelection } from './useFilesSelection'
|
||||
export { useFilesPage } from './useFilesPage'
|
||||
export { provideFilesPage, useFilesPageContext } from './filesPageContext'
|
||||
export type { FilesPageContext } from './filesPageContext'
|
||||
@@ -0,0 +1,601 @@
|
||||
import { computed, nextTick, ref, type Ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { http } from '@/api'
|
||||
import { useFilesClipboard } from '@/composables/useFilesClipboard'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useFilesStore } from '@/stores/files'
|
||||
import type { FileEntry } from '@/types/api'
|
||||
import { isArchiveFile } from '@/utils/fileSort'
|
||||
import { saveBlobAsFile } from '@/utils/fileDownload'
|
||||
import { invalidateCachedFile } from '@/utils/filePreload'
|
||||
import {
|
||||
joinRemotePath,
|
||||
normalizeRemotePath,
|
||||
validatePathSegment,
|
||||
} from '@/utils/remotePath'
|
||||
import type { useFilesSelection } from './useFilesSelection'
|
||||
|
||||
type SelectionApi = ReturnType<typeof useFilesSelection>
|
||||
|
||||
export function useFilesActions(options: {
|
||||
browse: (opts?: { force?: boolean }) => Promise<void | undefined>
|
||||
invalidateAfterMutation: (parentPath?: string) => void
|
||||
selection: SelectionApi
|
||||
openEntry: (item: FileEntry) => void
|
||||
editFile: (item: FileEntry) => Promise<void>
|
||||
previewFile: (item: FileEntry) => Promise<void>
|
||||
openInTerminal: () => void
|
||||
editorWorkbench: Ref<{ openFile: (p: { path: string; name: string; content: string }) => void } | null>
|
||||
actionLoading?: Ref<boolean>
|
||||
}) {
|
||||
const snackbar = useSnackbar()
|
||||
const store = useFilesStore()
|
||||
const { selectedServer, currentPath } = storeToRefs(store)
|
||||
const { selectedFiles, selectedAbsolutePaths, clearSelection } = options.selection
|
||||
const {
|
||||
setFromSelection,
|
||||
canPasteOnServer,
|
||||
pasteToDirectory,
|
||||
} = useFilesClipboard()
|
||||
|
||||
const dropActive = ref(false)
|
||||
const showUpload = ref(false)
|
||||
const showNewFile = ref(false)
|
||||
const newFileName = ref('')
|
||||
const showMkdir = ref(false)
|
||||
const showCompress = ref(false)
|
||||
const compressDest = ref('')
|
||||
const compressFormat = ref<'tar.gz' | 'zip'>('tar.gz')
|
||||
const compressPaths = ref<string[]>([])
|
||||
const showDecompress = ref(false)
|
||||
const decompressSource = ref('')
|
||||
const decompressDest = ref('.')
|
||||
const showFileDelete = ref(false)
|
||||
const uploadFiles = ref<File[]>([])
|
||||
const uploading = ref(false)
|
||||
const mkdirName = ref('')
|
||||
const actionLoading = options.actionLoading ?? ref(false)
|
||||
const deletingFile = ref<FileEntry | null>(null)
|
||||
const showRename = ref(false)
|
||||
const newName = ref('')
|
||||
const renamingFile = ref<FileEntry | null>(null)
|
||||
const showChmod = ref(false)
|
||||
const chmodLoading = ref(false)
|
||||
const chmodFile = ref<FileEntry | null>(null)
|
||||
const showContextMenu = ref(false)
|
||||
const contextX = ref(0)
|
||||
const contextY = ref(0)
|
||||
const contextFile = ref<FileEntry | null>(null)
|
||||
|
||||
function resolvePasteDestination(explicitDest?: string): string {
|
||||
if (explicitDest) return normalizeRemotePath(explicitDest)
|
||||
const dirs = selectedFiles.value.filter((f) => f.type === 'directory')
|
||||
if (dirs.length === 1 && selectedFiles.value.length === 1) {
|
||||
return joinRemotePath(currentPath.value, dirs[0].name)
|
||||
}
|
||||
return normalizeRemotePath(currentPath.value)
|
||||
}
|
||||
|
||||
const pasteDestLabel = computed(() => {
|
||||
const dest = resolvePasteDestination()
|
||||
const cur = normalizeRemotePath(currentPath.value)
|
||||
return dest === cur ? '当前目录' : dest
|
||||
})
|
||||
|
||||
function copyToClipboard(items?: FileEntry[]) {
|
||||
if (!selectedServer.value) return
|
||||
const paths = selectedAbsolutePaths(items)
|
||||
if (!paths.length) {
|
||||
snackbar('请先选择文件或目录', 'warning')
|
||||
return
|
||||
}
|
||||
if (setFromSelection('copy', selectedServer.value, paths)) {
|
||||
snackbar(`已复制 ${paths.length} 项`)
|
||||
}
|
||||
}
|
||||
|
||||
function cutToClipboard(items?: FileEntry[]) {
|
||||
if (!selectedServer.value) return
|
||||
const paths = selectedAbsolutePaths(items)
|
||||
if (!paths.length) {
|
||||
snackbar('请先选择文件或目录', 'warning')
|
||||
return
|
||||
}
|
||||
if (setFromSelection('cut', selectedServer.value, paths)) {
|
||||
snackbar(`已剪切 ${paths.length} 项`)
|
||||
}
|
||||
}
|
||||
|
||||
async function pasteClipboard(explicitDest?: string) {
|
||||
if (!selectedServer.value) {
|
||||
snackbar('请先选择服务器', 'warning')
|
||||
return
|
||||
}
|
||||
if (!canPasteOnServer(selectedServer.value)) {
|
||||
snackbar('剪贴板为空或服务器不一致', 'warning')
|
||||
return
|
||||
}
|
||||
const targetDir = resolvePasteDestination(explicitDest)
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await pasteToDirectory(targetDir)
|
||||
clearSelection()
|
||||
options.invalidateAfterMutation(targetDir)
|
||||
await options.browse({ force: true })
|
||||
snackbar(`已粘贴到 ${targetDir}`)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '粘贴失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDropFiles(e: DragEvent) {
|
||||
dropActive.value = false
|
||||
if (!selectedServer.value) {
|
||||
snackbar('请先选择服务器', 'warning')
|
||||
return
|
||||
}
|
||||
const dropped = e.dataTransfer?.files
|
||||
if (!dropped?.length) return
|
||||
uploadFiles.value = Array.from(dropped)
|
||||
await doUpload()
|
||||
}
|
||||
|
||||
function contextAction(action: string) {
|
||||
showContextMenu.value = false
|
||||
if (!contextFile.value) return
|
||||
const item = contextFile.value
|
||||
switch (action) {
|
||||
case 'open':
|
||||
options.openEntry(item)
|
||||
break
|
||||
case 'copy':
|
||||
copyToClipboard([item])
|
||||
break
|
||||
case 'cut':
|
||||
cutToClipboard([item])
|
||||
break
|
||||
case 'paste':
|
||||
void pasteClipboard()
|
||||
break
|
||||
case 'pasteInto':
|
||||
if (item.type === 'directory') {
|
||||
void pasteClipboard(joinRemotePath(currentPath.value, item.name))
|
||||
}
|
||||
break
|
||||
case 'edit':
|
||||
void options.editFile(item)
|
||||
break
|
||||
case 'preview':
|
||||
void options.previewFile(item)
|
||||
break
|
||||
case 'download':
|
||||
void downloadFile(item)
|
||||
break
|
||||
case 'copyPath':
|
||||
void copyRemotePath(item)
|
||||
break
|
||||
case 'terminal':
|
||||
options.openInTerminal()
|
||||
break
|
||||
case 'compress':
|
||||
openCompressDialog([item])
|
||||
break
|
||||
case 'decompress':
|
||||
openDecompressDialog(item)
|
||||
break
|
||||
case 'rename':
|
||||
startRename(item)
|
||||
break
|
||||
case 'chmod':
|
||||
startChmod(item)
|
||||
break
|
||||
case 'delete':
|
||||
void confirmDeleteFile(item)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function copyRemotePath(item: FileEntry) {
|
||||
const path = joinRemotePath(currentPath.value, item.name)
|
||||
try {
|
||||
await navigator.clipboard.writeText(path)
|
||||
snackbar('路径已复制')
|
||||
} catch {
|
||||
snackbar('复制失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function openCompressDialog(items?: FileEntry[]) {
|
||||
if (!selectedServer.value) return
|
||||
const targets = items?.length
|
||||
? items
|
||||
: selectedFiles.value.length
|
||||
? selectedFiles.value
|
||||
: []
|
||||
if (!targets.length) {
|
||||
snackbar('请选择要压缩的文件或目录', 'warning')
|
||||
return
|
||||
}
|
||||
compressPaths.value = targets.map((f) =>
|
||||
joinRemotePath(currentPath.value, f.name),
|
||||
)
|
||||
const base =
|
||||
targets.length === 1 ? targets[0].name.replace(/\.[^.]+$/, '') : 'archive'
|
||||
compressDest.value = joinRemotePath(currentPath.value, `${base}.tar.gz`)
|
||||
compressFormat.value = 'tar.gz'
|
||||
showCompress.value = true
|
||||
}
|
||||
|
||||
function openDecompressDialog(item: FileEntry) {
|
||||
if (!selectedServer.value) return
|
||||
if (!isArchiveFile(item.name)) {
|
||||
snackbar('仅支持 .tar.gz / .tgz / .zip', 'warning')
|
||||
return
|
||||
}
|
||||
decompressSource.value = joinRemotePath(currentPath.value, item.name)
|
||||
decompressDest.value = currentPath.value
|
||||
showDecompress.value = true
|
||||
}
|
||||
|
||||
async function doCompress() {
|
||||
if (!selectedServer.value || !compressPaths.value.length || !compressDest.value) {
|
||||
return
|
||||
}
|
||||
actionLoading.value = true
|
||||
try {
|
||||
let dest = compressDest.value
|
||||
if (compressFormat.value === 'zip' && !dest.endsWith('.zip')) {
|
||||
dest = dest.replace(/\.tar\.gz$/i, '.zip')
|
||||
if (!dest.endsWith('.zip')) dest += '.zip'
|
||||
}
|
||||
await http.post('/sync/compress', {
|
||||
server_id: selectedServer.value,
|
||||
paths: compressPaths.value,
|
||||
dest,
|
||||
format: compressFormat.value,
|
||||
})
|
||||
showCompress.value = false
|
||||
clearSelection()
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('压缩完成')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '压缩失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doDecompress() {
|
||||
if (!selectedServer.value || !decompressSource.value) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await http.post('/sync/decompress', {
|
||||
server_id: selectedServer.value,
|
||||
path: decompressSource.value,
|
||||
dest: decompressDest.value || '.',
|
||||
})
|
||||
showDecompress.value = false
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('解压完成')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '解压失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doNewFile() {
|
||||
if (!selectedServer.value || !newFileName.value) return
|
||||
const check = validatePathSegment(newFileName.value)
|
||||
if (check !== true) {
|
||||
snackbar(check, 'warning')
|
||||
return
|
||||
}
|
||||
const name = newFileName.value
|
||||
const path = joinRemotePath(currentPath.value, name)
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await http.post('/sync/write-file', {
|
||||
server_id: selectedServer.value,
|
||||
path,
|
||||
content: '',
|
||||
})
|
||||
showNewFile.value = false
|
||||
newFileName.value = ''
|
||||
invalidateCachedFile(selectedServer.value, path)
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('文件已创建')
|
||||
await nextTick()
|
||||
options.editorWorkbench.value?.openFile({ path, name, content: '' })
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '创建失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startRename(item: FileEntry) {
|
||||
renamingFile.value = item
|
||||
newName.value = item.name
|
||||
showRename.value = true
|
||||
}
|
||||
|
||||
async function doRename() {
|
||||
if (!selectedServer.value || !renamingFile.value || !newName.value) return
|
||||
try {
|
||||
await http.post('/sync/file-ops', {
|
||||
server_id: selectedServer.value,
|
||||
operation: 'rename',
|
||||
path: joinRemotePath(currentPath.value, renamingFile.value.name),
|
||||
new_path: joinRemotePath(currentPath.value, newName.value),
|
||||
})
|
||||
showRename.value = false
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('重命名成功')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '重命名失败'
|
||||
snackbar(msg, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function startChmod(item: FileEntry) {
|
||||
chmodFile.value = item
|
||||
showChmod.value = true
|
||||
}
|
||||
|
||||
async function doChmod(payload: {
|
||||
mode: string
|
||||
owner?: string
|
||||
group?: string
|
||||
recursive?: boolean
|
||||
}) {
|
||||
if (!selectedServer.value || !chmodFile.value) return
|
||||
chmodLoading.value = true
|
||||
try {
|
||||
await http.post('/sync/chmod', {
|
||||
server_id: selectedServer.value,
|
||||
path: joinRemotePath(currentPath.value, chmodFile.value.name),
|
||||
mode: payload.mode,
|
||||
owner: payload.owner,
|
||||
group: payload.group,
|
||||
recursive: payload.recursive ?? false,
|
||||
})
|
||||
showChmod.value = false
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('权限已修改')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '修改权限失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
chmodLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function batchDelete() {
|
||||
if (!selectedServer.value || !selectedFiles.value.length) return
|
||||
if (!confirm(`确定删除 ${selectedFiles.value.length} 个文件?`)) return
|
||||
let ok = 0
|
||||
let fail = 0
|
||||
for (const f of selectedFiles.value) {
|
||||
try {
|
||||
await http.post('/sync/file-ops', {
|
||||
server_id: selectedServer.value,
|
||||
operation: 'delete',
|
||||
path: joinRemotePath(currentPath.value, f.name),
|
||||
})
|
||||
ok++
|
||||
} catch {
|
||||
fail++
|
||||
}
|
||||
}
|
||||
clearSelection()
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
if (fail === 0) snackbar(`已删除 ${ok} 项`)
|
||||
else snackbar(`删除完成:成功 ${ok},失败 ${fail}`, fail > 0 ? 'warning' : 'success')
|
||||
}
|
||||
|
||||
async function downloadFile(item: FileEntry) {
|
||||
if (!selectedServer.value) return
|
||||
try {
|
||||
const remotePath = joinRemotePath(currentPath.value, item.name)
|
||||
const { blob, filename } = await http.download('/sync/download', {
|
||||
server_id: selectedServer.value,
|
||||
path: remotePath,
|
||||
})
|
||||
saveBlobAsFile(blob, filename)
|
||||
snackbar(`已下载 ${filename}`)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '下载失败'
|
||||
snackbar(msg, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteFile(item: FileEntry) {
|
||||
deletingFile.value = item
|
||||
showFileDelete.value = true
|
||||
}
|
||||
|
||||
async function doDeleteFile() {
|
||||
if (!selectedServer.value || !deletingFile.value) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await http.post('/sync/file-ops', {
|
||||
server_id: selectedServer.value,
|
||||
operation: 'delete',
|
||||
path: joinRemotePath(currentPath.value, deletingFile.value.name),
|
||||
})
|
||||
showFileDelete.value = false
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('已删除')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '删除失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doMkdir() {
|
||||
if (!selectedServer.value || !mkdirName.value) return
|
||||
const check = validatePathSegment(mkdirName.value)
|
||||
if (check !== true) {
|
||||
snackbar(check, 'warning')
|
||||
return
|
||||
}
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await http.post('/sync/file-ops', {
|
||||
server_id: selectedServer.value,
|
||||
operation: 'mkdir',
|
||||
path: joinRemotePath(currentPath.value, mkdirName.value),
|
||||
})
|
||||
showMkdir.value = false
|
||||
mkdirName.value = ''
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('目录已创建')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '创建失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doUpload() {
|
||||
if (!selectedServer.value || !uploadFiles.value.length) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const form = new FormData()
|
||||
for (const f of uploadFiles.value) form.append('files', f)
|
||||
form.append('server_id', String(selectedServer.value))
|
||||
form.append('remote_path', currentPath.value)
|
||||
await http.upload('/sync/upload', form)
|
||||
showUpload.value = false
|
||||
uploadFiles.value = []
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('上传成功')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '上传失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isRowActionTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return Boolean(target.closest('button, a, input, .v-checkbox, .v-selection-control'))
|
||||
}
|
||||
|
||||
function fileRowProps(data: { item: FileEntry }) {
|
||||
const item = data.item
|
||||
return {
|
||||
class: item.type === 'directory' ? 'files-row--dir' : undefined,
|
||||
style: {
|
||||
cursor:
|
||||
item.type === 'directory' || item.type === 'symlink' ? 'pointer' : 'default',
|
||||
},
|
||||
onClick: (e: MouseEvent) => {
|
||||
if (isRowActionTarget(e.target)) return
|
||||
if (e.detail !== 1) return
|
||||
onRowClick(e, { item })
|
||||
},
|
||||
onDblclick: (e: MouseEvent) => {
|
||||
if (isRowActionTarget(e.target)) return
|
||||
onRowDblClick(e, { item })
|
||||
},
|
||||
onContextmenu: (e: MouseEvent) => {
|
||||
if (isRowActionTarget(e.target)) return
|
||||
onRowContext(e, { item })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function onRowClick(_event: Event, { item }: { item: FileEntry }) {
|
||||
if (item.type === 'directory' || item.type === 'symlink') {
|
||||
options.openEntry(item)
|
||||
}
|
||||
}
|
||||
|
||||
function onRowDblClick(_event: Event, { item }: { item: FileEntry }) {
|
||||
if (item.type === 'file') {
|
||||
void options.editFile(item)
|
||||
}
|
||||
}
|
||||
|
||||
function onRowContext(e: MouseEvent, { item }: { item: FileEntry }) {
|
||||
e.preventDefault()
|
||||
contextFile.value = item
|
||||
contextX.value = e.clientX
|
||||
contextY.value = e.clientY
|
||||
showContextMenu.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
dropActive,
|
||||
showUpload,
|
||||
showNewFile,
|
||||
newFileName,
|
||||
showMkdir,
|
||||
showCompress,
|
||||
compressDest,
|
||||
compressFormat,
|
||||
compressPaths,
|
||||
showDecompress,
|
||||
decompressSource,
|
||||
decompressDest,
|
||||
showFileDelete,
|
||||
uploadFiles,
|
||||
uploading,
|
||||
mkdirName,
|
||||
actionLoading,
|
||||
deletingFile,
|
||||
showRename,
|
||||
newName,
|
||||
renamingFile,
|
||||
showChmod,
|
||||
chmodLoading,
|
||||
chmodFile,
|
||||
showContextMenu,
|
||||
contextX,
|
||||
contextY,
|
||||
contextFile,
|
||||
copyToClipboard,
|
||||
cutToClipboard,
|
||||
pasteClipboard,
|
||||
onDropFiles,
|
||||
contextAction,
|
||||
openCompressDialog,
|
||||
openDecompressDialog,
|
||||
doCompress,
|
||||
doDecompress,
|
||||
doNewFile,
|
||||
startRename,
|
||||
doRename,
|
||||
startChmod,
|
||||
doChmod,
|
||||
batchDelete,
|
||||
downloadFile,
|
||||
confirmDeleteFile,
|
||||
doDeleteFile,
|
||||
doMkdir,
|
||||
doUpload,
|
||||
fileRowProps,
|
||||
pasteDestLabel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { http } from '@/api'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useFilesStore } from '@/stores/files'
|
||||
import type { BrowseResponse, FileEntry } from '@/types/api'
|
||||
import { parseBrowseResponse } from '@/utils/fileBrowse'
|
||||
import {
|
||||
clearFilePreloadCache,
|
||||
preloadDirectoryFiles,
|
||||
pruneFilePreloadCache,
|
||||
} from '@/utils/filePreload'
|
||||
import { DEFAULT_FILES_ROOT, normalizeRemotePath, parentRemotePath } from '@/utils/remotePath'
|
||||
|
||||
let _browseSeq = 0
|
||||
let _browseInFlight: Promise<void> | null = null
|
||||
let _browseInFlightKey = ''
|
||||
|
||||
export function isNotFoundError(err: string): boolean {
|
||||
return err.includes('No such file or directory') || err.includes('not a directory')
|
||||
}
|
||||
|
||||
export function friendlyBrowseError(err: string, path: string): string {
|
||||
if (isNotFoundError(err)) {
|
||||
return `路径不存在:${path},请检查服务器「target_path」设置`
|
||||
}
|
||||
if (err.includes('Permission denied') || err.includes('Operation not permitted')) {
|
||||
return `无读取权限(${path})`
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
export function useFilesBrowse(fileFilter?: Ref<string>) {
|
||||
const snackbar = useSnackbar()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useFilesStore()
|
||||
const {
|
||||
selectedServer,
|
||||
currentPath,
|
||||
files,
|
||||
loading,
|
||||
browseError,
|
||||
pageReady,
|
||||
filesCapability,
|
||||
} = storeToRefs(store)
|
||||
|
||||
function syncRouteQuery() {
|
||||
const query: Record<string, string | undefined> = {
|
||||
...(route.query as Record<string, string>),
|
||||
}
|
||||
if (selectedServer.value) {
|
||||
query.server_id = String(selectedServer.value)
|
||||
query.path =
|
||||
currentPath.value === DEFAULT_FILES_ROOT ? undefined : currentPath.value
|
||||
} else {
|
||||
delete query.server_id
|
||||
delete query.path
|
||||
}
|
||||
router.replace({ query })
|
||||
}
|
||||
|
||||
const emptyHint = computed(() => {
|
||||
if (!pageReady.value) return '正在加载…'
|
||||
if (!selectedServer.value) return '请先选择服务器'
|
||||
if (browseError.value) return browseError.value
|
||||
if (fileFilter?.value.trim()) return '无匹配文件'
|
||||
const cap = filesCapability.value
|
||||
if (cap?.is_root) {
|
||||
return `目录为空(${currentPath.value}),可在上方修改路径或到「服务器」核对 target_path`
|
||||
}
|
||||
return `目录为空(${currentPath.value})`
|
||||
})
|
||||
|
||||
async function browse(options?: { force?: boolean }) {
|
||||
if (!selectedServer.value) return
|
||||
const path = normalizeRemotePath(currentPath.value)
|
||||
const serverId = selectedServer.value
|
||||
const browseKey = `${serverId}:${path}`
|
||||
|
||||
if (!options?.force) {
|
||||
const cached = store.getCached(serverId, path)
|
||||
if (cached) {
|
||||
files.value = cached
|
||||
browseError.value = ''
|
||||
loading.value = false
|
||||
syncRouteQuery()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (_browseInFlight && _browseInFlightKey === browseKey) {
|
||||
return _browseInFlight
|
||||
}
|
||||
_browseInFlightKey = browseKey
|
||||
|
||||
const seq = ++_browseSeq
|
||||
loading.value = true
|
||||
currentPath.value = path
|
||||
|
||||
_browseInFlight = (async () => {
|
||||
try {
|
||||
const res = await http.post<BrowseResponse | FileEntry[]>('/sync/browse', {
|
||||
server_id: serverId,
|
||||
path,
|
||||
})
|
||||
if (seq !== _browseSeq) return
|
||||
const { items, error } = parseBrowseResponse(res)
|
||||
|
||||
if (error && isNotFoundError(error) && path !== '/') {
|
||||
browseError.value = ''
|
||||
loading.value = false
|
||||
const parent = parentRemotePath(path)
|
||||
if (parent) {
|
||||
snackbar(`路径 "${path}" 不存在,已自动跳转到上级目录`, 'info')
|
||||
currentPath.value = parent
|
||||
void browse()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
browseError.value = error ? friendlyBrowseError(error, path) : ''
|
||||
if (error && !isNotFoundError(error)) {
|
||||
snackbar(friendlyBrowseError(error, path), 'warning')
|
||||
}
|
||||
files.value = items
|
||||
if (!error) {
|
||||
store.setCached(serverId, path, items)
|
||||
}
|
||||
if (serverId && items.length) {
|
||||
pruneFilePreloadCache(serverId, path)
|
||||
preloadDirectoryFiles(serverId, path, items)
|
||||
}
|
||||
} catch {
|
||||
if (seq !== _browseSeq) return
|
||||
files.value = []
|
||||
snackbar('浏览目录失败', 'error')
|
||||
} finally {
|
||||
if (seq === _browseSeq) {
|
||||
loading.value = false
|
||||
syncRouteQuery()
|
||||
}
|
||||
if (_browseInFlightKey === browseKey) {
|
||||
_browseInFlight = null
|
||||
_browseInFlightKey = ''
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return _browseInFlight
|
||||
}
|
||||
|
||||
function browseCurrent() {
|
||||
if (!selectedServer.value) {
|
||||
snackbar('请先选择服务器', 'warning')
|
||||
return
|
||||
}
|
||||
currentPath.value = normalizeRemotePath(currentPath.value)
|
||||
store.invalidateCache(selectedServer.value, currentPath.value)
|
||||
void browse({ force: true })
|
||||
}
|
||||
|
||||
function invalidateAfterMutation(parentPath?: string) {
|
||||
if (!selectedServer.value) return
|
||||
const p = parentPath ?? currentPath.value
|
||||
store.invalidateCache(selectedServer.value, normalizeRemotePath(p))
|
||||
clearFilePreloadCache()
|
||||
}
|
||||
|
||||
return {
|
||||
emptyHint,
|
||||
browse,
|
||||
browseCurrent,
|
||||
invalidateAfterMutation,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { nextTick, ref, type Ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useFilesStore } from '@/stores/files'
|
||||
import type { FileEntry } from '@/types/api'
|
||||
import { isPermissionDeniedMessage } from '@/utils/fileMode'
|
||||
import {
|
||||
exceedsEditorMaxSize,
|
||||
FILE_EDITOR_MAX_BYTES,
|
||||
FILE_PRELOAD_MAX_BYTES,
|
||||
invalidateCachedFile,
|
||||
readRemoteFileContent,
|
||||
} from '@/utils/filePreload'
|
||||
import { joinRemotePath } from '@/utils/remotePath'
|
||||
|
||||
function formatSize(bytes: number | undefined) {
|
||||
if (!bytes) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function useFilesEditor(options: {
|
||||
browse: (opts?: { force?: boolean }) => Promise<void | undefined>
|
||||
invalidateAfterMutation: (parentPath?: string) => void
|
||||
actionLoading: Ref<boolean>
|
||||
}) {
|
||||
const snackbar = useSnackbar()
|
||||
const store = useFilesStore()
|
||||
const { selectedServer, currentPath, lastReadDeniedPath, accessHintDismissed } =
|
||||
storeToRefs(store)
|
||||
|
||||
const showPreview = ref(false)
|
||||
const previewTitle = ref('')
|
||||
const previewContent = ref('')
|
||||
const previewLoading = ref(false)
|
||||
|
||||
const editorWorkbench = ref<{
|
||||
openFile: (p: { path: string; name: string; content: string }) => void
|
||||
} | null>(null)
|
||||
|
||||
function rejectOversizedFile(item: FileEntry, action: '编辑' | '查看'): boolean {
|
||||
if (exceedsEditorMaxSize(item.size)) {
|
||||
snackbar(
|
||||
`文件超过 2MB(${formatSize(item.size)}),无法${action},请下载或使用终端`,
|
||||
'warning',
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function onEditorSaved(path: string) {
|
||||
if (selectedServer.value && path) {
|
||||
invalidateCachedFile(selectedServer.value, path)
|
||||
}
|
||||
options.invalidateAfterMutation()
|
||||
void options.browse({ force: true })
|
||||
}
|
||||
|
||||
async function previewFile(item: FileEntry) {
|
||||
if (!selectedServer.value) return
|
||||
if (rejectOversizedFile(item, '查看')) return
|
||||
const remotePath = joinRemotePath(currentPath.value, item.name)
|
||||
previewTitle.value = item.name
|
||||
previewContent.value = ''
|
||||
previewLoading.value = true
|
||||
showPreview.value = true
|
||||
if ((item.size ?? 0) > FILE_PRELOAD_MAX_BYTES) {
|
||||
snackbar(`文件较大(${formatSize(item.size)}),正在加载…`, 'info')
|
||||
}
|
||||
try {
|
||||
const raw = await readRemoteFileContent(
|
||||
selectedServer.value,
|
||||
remotePath,
|
||||
FILE_EDITOR_MAX_BYTES,
|
||||
)
|
||||
previewContent.value = raw || '(空文件)'
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '读取失败'
|
||||
previewContent.value = msg
|
||||
if (isPermissionDeniedMessage(msg)) {
|
||||
lastReadDeniedPath.value = item.name
|
||||
accessHintDismissed.value = false
|
||||
}
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function editFile(item: FileEntry) {
|
||||
if (!selectedServer.value) return
|
||||
if (rejectOversizedFile(item, '编辑')) return
|
||||
if ((item.size ?? 0) > FILE_PRELOAD_MAX_BYTES) {
|
||||
snackbar(`文件较大(${formatSize(item.size)}),正在加载…`, 'info')
|
||||
}
|
||||
options.actionLoading.value = true
|
||||
try {
|
||||
const path = joinRemotePath(currentPath.value, item.name)
|
||||
const content = await readRemoteFileContent(
|
||||
selectedServer.value,
|
||||
path,
|
||||
FILE_EDITOR_MAX_BYTES,
|
||||
)
|
||||
await nextTick()
|
||||
editorWorkbench.value?.openFile({ path, name: item.name, content })
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '读取失败'
|
||||
if (isPermissionDeniedMessage(msg)) {
|
||||
lastReadDeniedPath.value = item.name
|
||||
accessHintDismissed.value = false
|
||||
}
|
||||
snackbar(msg, 'error')
|
||||
} finally {
|
||||
options.actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showPreview,
|
||||
previewTitle,
|
||||
previewContent,
|
||||
previewLoading,
|
||||
editorWorkbench,
|
||||
previewFile,
|
||||
editFile,
|
||||
onEditorSaved,
|
||||
formatSize,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useServerList } from '@/composables/useServerList'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useFilesStore } from '@/stores/files'
|
||||
import type { FileEntry } from '@/types/api'
|
||||
import {
|
||||
buildRemoteBreadcrumbs,
|
||||
DEFAULT_FILES_ROOT,
|
||||
joinRemotePath,
|
||||
normalizeRemotePath,
|
||||
parentRemotePath,
|
||||
type RemoteBreadcrumbItem,
|
||||
} from '@/utils/remotePath'
|
||||
import { FILES_HINT_DISMISSED_KEY, FILES_LAST_SERVER_KEY } from './constants'
|
||||
|
||||
export function useFilesNavigation(options: {
|
||||
browse: (opts?: { force?: boolean }) => Promise<void | undefined>
|
||||
refreshFilesCapability: () => Promise<void>
|
||||
clearSelection: () => void
|
||||
}) {
|
||||
const snackbar = useSnackbar()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { servers: serverList, loadServers } = useServerList()
|
||||
const store = useFilesStore()
|
||||
const {
|
||||
selectedServer,
|
||||
currentPath,
|
||||
pageReady,
|
||||
routeReady,
|
||||
initing,
|
||||
accessHintDismissed,
|
||||
} = storeToRefs(store)
|
||||
|
||||
const breadcrumbs = computed((): RemoteBreadcrumbItem[] =>
|
||||
buildRemoteBreadcrumbs(currentPath.value),
|
||||
)
|
||||
|
||||
const canGoUp = computed(() => parentRemotePath(currentPath.value) !== null)
|
||||
|
||||
function defaultPathForServer(serverId: number | null): string {
|
||||
if (serverId == null) return DEFAULT_FILES_ROOT
|
||||
const srv = serverList.value.find((s) => s.id === serverId)
|
||||
const tp = srv?.target_path?.trim()
|
||||
return tp ? normalizeRemotePath(tp) : DEFAULT_FILES_ROOT
|
||||
}
|
||||
|
||||
function pathFromRouteQuery(): string {
|
||||
const qPath = route.query.path
|
||||
if (typeof qPath === 'string' && qPath.trim()) {
|
||||
return normalizeRemotePath(qPath)
|
||||
}
|
||||
return defaultPathForServer(selectedServer.value)
|
||||
}
|
||||
|
||||
function resolveInitialServerId(): number | null {
|
||||
const qServer = route.query.server_id
|
||||
if (qServer) {
|
||||
const id = Number(qServer)
|
||||
if (Number.isFinite(id) && serverList.value.some((s) => s.id === id)) return id
|
||||
}
|
||||
const stored = sessionStorage.getItem(FILES_LAST_SERVER_KEY)
|
||||
if (stored) {
|
||||
const id = Number(stored)
|
||||
if (Number.isFinite(id) && serverList.value.some((s) => s.id === id)) return id
|
||||
}
|
||||
return serverList.value[0]?.id ?? null
|
||||
}
|
||||
|
||||
function navigateToPath(path: string) {
|
||||
if (!selectedServer.value) {
|
||||
snackbar('请先选择服务器', 'warning')
|
||||
return
|
||||
}
|
||||
currentPath.value = normalizeRemotePath(path)
|
||||
options.clearSelection()
|
||||
void options.browse()
|
||||
}
|
||||
|
||||
function openEntry(item: FileEntry) {
|
||||
if (item.type === 'directory') {
|
||||
navigateToPath(joinRemotePath(currentPath.value, item.name))
|
||||
return
|
||||
}
|
||||
if (item.type === 'symlink') {
|
||||
const target = item.link_target?.trim()
|
||||
if (target?.startsWith('/')) {
|
||||
navigateToPath(target)
|
||||
} else {
|
||||
snackbar(
|
||||
target ? `符号链接 → ${target}` : '相对符号链接(请在终端中打开)',
|
||||
'info',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
const parent = parentRemotePath(currentPath.value)
|
||||
if (parent != null) navigateToPath(parent)
|
||||
}
|
||||
|
||||
function onBreadcrumbClick(index: number) {
|
||||
const bc = breadcrumbs.value[index]
|
||||
if (bc && !bc.disabled) navigateToPath(bc.path)
|
||||
}
|
||||
|
||||
function openInTerminal() {
|
||||
if (!selectedServer.value) return
|
||||
router.push({
|
||||
path: '/terminal',
|
||||
query: {
|
||||
server_id: String(selectedServer.value),
|
||||
path: currentPath.value || '/',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function onServerChange() {
|
||||
if (initing.value) return
|
||||
if (selectedServer.value) {
|
||||
sessionStorage.setItem(FILES_LAST_SERVER_KEY, String(selectedServer.value))
|
||||
}
|
||||
store.invalidateCache()
|
||||
currentPath.value = defaultPathForServer(selectedServer.value)
|
||||
options.clearSelection()
|
||||
store.resetForServerChange()
|
||||
sessionStorage.removeItem(FILES_HINT_DISMISSED_KEY)
|
||||
void options.refreshFilesCapability()
|
||||
void options.browse({ force: true })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
initing.value = true
|
||||
try {
|
||||
await loadServers()
|
||||
if (sessionStorage.getItem(FILES_HINT_DISMISSED_KEY)) {
|
||||
accessHintDismissed.value = true
|
||||
}
|
||||
if (!serverList.value.length) {
|
||||
snackbar('暂无可用服务器,请先在「服务器」页添加', 'warning')
|
||||
pageReady.value = true
|
||||
return
|
||||
}
|
||||
const initialId = resolveInitialServerId()
|
||||
if (initialId == null) {
|
||||
pageReady.value = true
|
||||
return
|
||||
}
|
||||
selectedServer.value = initialId
|
||||
const qPath = route.query.path
|
||||
currentPath.value =
|
||||
typeof qPath === 'string' && qPath
|
||||
? normalizeRemotePath(qPath)
|
||||
: defaultPathForServer(initialId)
|
||||
await options.refreshFilesCapability()
|
||||
await options.browse()
|
||||
pageReady.value = true
|
||||
routeReady.value = true
|
||||
} finally {
|
||||
initing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.server_id,
|
||||
(id) => {
|
||||
if (!routeReady.value) return
|
||||
if (id && Number(id) !== selectedServer.value) {
|
||||
selectedServer.value = Number(id)
|
||||
currentPath.value = pathFromRouteQuery()
|
||||
store.resetForServerChange()
|
||||
void options.refreshFilesCapability()
|
||||
void options.browse({ force: true })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.path,
|
||||
() => {
|
||||
if (!routeReady.value || !selectedServer.value) return
|
||||
const next = pathFromRouteQuery()
|
||||
if (next !== normalizeRemotePath(currentPath.value)) {
|
||||
currentPath.value = next
|
||||
options.clearSelection()
|
||||
void options.browse()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
serverList,
|
||||
breadcrumbs,
|
||||
canGoUp,
|
||||
navigateToPath,
|
||||
openEntry,
|
||||
goUp,
|
||||
onBreadcrumbClick,
|
||||
openInTerminal,
|
||||
onServerChange,
|
||||
defaultPathForServer,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 文件管理页 — 组合各域 composable,供 FilesPage.vue 使用
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useFilesClipboard } from '@/composables/useFilesClipboard'
|
||||
import { useFilesHotkeys } from '@/composables/useFilesHotkeys'
|
||||
import { useFilesStore } from '@/stores/files'
|
||||
import type { FileEntry } from '@/types/api'
|
||||
import {
|
||||
FILE_EXT_FILTERS,
|
||||
isArchiveFile,
|
||||
matchesExtensionFilter,
|
||||
sortFileEntries,
|
||||
type FileSortKey,
|
||||
} from '@/utils/fileSort'
|
||||
import {
|
||||
formatOwnerChip,
|
||||
isEntryLikelyUnreadable,
|
||||
ownerChipColor,
|
||||
permissionTooltip,
|
||||
} from '@/utils/fileMode'
|
||||
import { isPreloadEligible } from '@/utils/filePreload'
|
||||
import { validatePathSegment } from '@/utils/remotePath'
|
||||
import { required } from '@/utils/validation'
|
||||
import { useFilesActions } from './useFilesActions'
|
||||
import { useFilesBrowse } from './useFilesBrowse'
|
||||
import { useFilesEditor } from './useFilesEditor'
|
||||
import { useFilesNavigation } from './useFilesNavigation'
|
||||
import { useFilesPermissions } from './useFilesPermissions'
|
||||
import { useFilesSelection } from './useFilesSelection'
|
||||
|
||||
export function useFilesPage() {
|
||||
const fileFilter = ref('')
|
||||
const extFilter = ref('')
|
||||
const sortBy = ref<FileSortKey>('name')
|
||||
const sortOptions = [
|
||||
{ title: '名称', value: 'name' as const },
|
||||
{ title: '大小', value: 'size' as const },
|
||||
{ title: '修改时间', value: 'modified' as const },
|
||||
]
|
||||
|
||||
const store = useFilesStore()
|
||||
const {
|
||||
selectedServer,
|
||||
currentPath,
|
||||
files,
|
||||
loading,
|
||||
browseError,
|
||||
pageReady,
|
||||
setupSudoLoading,
|
||||
setupSudoDone,
|
||||
} = storeToRefs(store)
|
||||
|
||||
const selection = useFilesSelection()
|
||||
const { selectedFiles, selectedFileCount, clearSelection } = selection
|
||||
|
||||
const { emptyHint, browse, browseCurrent, invalidateAfterMutation } =
|
||||
useFilesBrowse(fileFilter)
|
||||
|
||||
const permissions = useFilesPermissions({ browse })
|
||||
const {
|
||||
sshUserForFiles,
|
||||
filesAccessHint,
|
||||
dismissFilesAccessHint,
|
||||
setupFilesSudo,
|
||||
} = permissions
|
||||
|
||||
const nav = useFilesNavigation({
|
||||
browse,
|
||||
refreshFilesCapability: permissions.refreshFilesCapability,
|
||||
clearSelection,
|
||||
})
|
||||
|
||||
const actionLoading = ref(false)
|
||||
|
||||
const editor = useFilesEditor({
|
||||
browse,
|
||||
invalidateAfterMutation,
|
||||
actionLoading,
|
||||
})
|
||||
|
||||
const actions = useFilesActions({
|
||||
browse,
|
||||
invalidateAfterMutation,
|
||||
selection,
|
||||
openEntry: nav.openEntry,
|
||||
editFile: editor.editFile,
|
||||
previewFile: editor.previewFile,
|
||||
openInTerminal: nav.openInTerminal,
|
||||
editorWorkbench: editor.editorWorkbench,
|
||||
actionLoading,
|
||||
})
|
||||
|
||||
const { hasClipboard, clipboardSummary, canPasteOnServer } = useFilesClipboard()
|
||||
const canPasteHere = computed(() => canPasteOnServer(selectedServer.value))
|
||||
|
||||
const displayedFiles = computed(() => {
|
||||
let list = sortFileEntries(files.value, sortBy.value, 'asc')
|
||||
const ext = extFilter.value
|
||||
if (ext) list = list.filter((f) => matchesExtensionFilter(f.name, ext))
|
||||
const q = fileFilter.value.trim().toLowerCase()
|
||||
if (q) list = list.filter((f) => f.name.toLowerCase().includes(q))
|
||||
return list
|
||||
})
|
||||
|
||||
const statusSummary = computed(() => {
|
||||
const dirs = files.value.filter((f) => f.type === 'directory').length
|
||||
const regular = files.value.filter((f) => f.type === 'file').length
|
||||
const links = files.value.filter((f) => f.type === 'symlink').length
|
||||
const preloadable = files.value.filter(isPreloadEligible).length
|
||||
const parts = [`${dirs} 个目录`, `${regular} 个文件`]
|
||||
if (links) parts.push(`${links} 个链接`)
|
||||
if (preloadable) parts.push(`${preloadable} 个 ≤300KB 可预加载`)
|
||||
return `${parts.join(',')} · ${currentPath.value}`
|
||||
})
|
||||
|
||||
function isArchiveName(name: string): boolean {
|
||||
return isArchiveFile(name)
|
||||
}
|
||||
|
||||
function entryIcon(item: FileEntry): string {
|
||||
if (item.type === 'directory') return 'mdi-folder'
|
||||
if (item.type === 'symlink') return 'mdi-link-variant'
|
||||
return 'mdi-file-outline'
|
||||
}
|
||||
|
||||
function entryIconColor(item: FileEntry): string {
|
||||
if (item.type === 'directory') return 'amber'
|
||||
if (item.type === 'symlink') return 'teal'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
function isRegularFile(item: FileEntry): boolean {
|
||||
return item.type === 'file'
|
||||
}
|
||||
|
||||
const fileHeaders = [
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: '归属', key: 'owner', width: 110, sortable: false },
|
||||
{ title: '大小', key: 'size', width: 120 },
|
||||
{ title: '修改时间', key: 'modified', width: 180 },
|
||||
{ title: '操作', key: 'actions', width: 200, align: 'end' as const },
|
||||
]
|
||||
|
||||
useFilesHotkeys(
|
||||
{
|
||||
refresh: browseCurrent,
|
||||
goUp: nav.goUp,
|
||||
renameSelection: () => {
|
||||
if (selectedFiles.value.length === 1) {
|
||||
actions.startRename(selectedFiles.value[0])
|
||||
}
|
||||
},
|
||||
deleteSelection: () => {
|
||||
if (selectedFiles.value.length > 0) void actions.batchDelete()
|
||||
},
|
||||
selectAll: () => {
|
||||
selectedFiles.value = [...displayedFiles.value]
|
||||
},
|
||||
copySelection: () => actions.copyToClipboard(),
|
||||
cutSelection: () => actions.cutToClipboard(),
|
||||
pasteClipboard: () => {
|
||||
void actions.pasteClipboard()
|
||||
},
|
||||
},
|
||||
selectedFiles,
|
||||
canPasteHere,
|
||||
)
|
||||
|
||||
return {
|
||||
FILE_EXT_FILTERS,
|
||||
validatePathSegment,
|
||||
required,
|
||||
fileFilter,
|
||||
extFilter,
|
||||
sortBy,
|
||||
sortOptions,
|
||||
selectedServer,
|
||||
currentPath,
|
||||
loading,
|
||||
browseError,
|
||||
pageReady,
|
||||
setupSudoLoading,
|
||||
setupSudoDone,
|
||||
serverList: nav.serverList,
|
||||
onServerChange: nav.onServerChange,
|
||||
browseCurrent,
|
||||
filesAccessHint,
|
||||
dismissFilesAccessHint,
|
||||
setupFilesSudo,
|
||||
sshUserForFiles,
|
||||
statusSummary,
|
||||
hasClipboard,
|
||||
clipboardSummary,
|
||||
pasteDestLabel: actions.pasteDestLabel,
|
||||
canPasteHere,
|
||||
breadcrumbs: nav.breadcrumbs,
|
||||
onBreadcrumbClick: nav.onBreadcrumbClick,
|
||||
canGoUp: nav.canGoUp,
|
||||
goUp: nav.goUp,
|
||||
selectedFiles,
|
||||
selectedFileCount,
|
||||
displayedFiles,
|
||||
fileHeaders,
|
||||
fileRowProps: actions.fileRowProps,
|
||||
dropActive: actions.dropActive,
|
||||
onDropFiles: actions.onDropFiles,
|
||||
emptyHint,
|
||||
entryIcon,
|
||||
entryIconColor,
|
||||
isEntryLikelyUnreadable,
|
||||
formatOwnerChip,
|
||||
ownerChipColor,
|
||||
permissionTooltip,
|
||||
formatSize: editor.formatSize,
|
||||
isRegularFile,
|
||||
isArchiveName,
|
||||
showUpload: actions.showUpload,
|
||||
uploadFiles: actions.uploadFiles,
|
||||
uploading: actions.uploading,
|
||||
doUpload: actions.doUpload,
|
||||
showNewFile: actions.showNewFile,
|
||||
newFileName: actions.newFileName,
|
||||
doNewFile: actions.doNewFile,
|
||||
showMkdir: actions.showMkdir,
|
||||
mkdirName: actions.mkdirName,
|
||||
doMkdir: actions.doMkdir,
|
||||
showFileDelete: actions.showFileDelete,
|
||||
deletingFile: actions.deletingFile,
|
||||
doDeleteFile: actions.doDeleteFile,
|
||||
showRename: actions.showRename,
|
||||
newName: actions.newName,
|
||||
doRename: actions.doRename,
|
||||
showChmod: actions.showChmod,
|
||||
chmodFile: actions.chmodFile,
|
||||
chmodLoading: actions.chmodLoading,
|
||||
doChmod: actions.doChmod,
|
||||
showCompress: actions.showCompress,
|
||||
compressDest: actions.compressDest,
|
||||
compressFormat: actions.compressFormat,
|
||||
compressPaths: actions.compressPaths,
|
||||
doCompress: actions.doCompress,
|
||||
showDecompress: actions.showDecompress,
|
||||
decompressSource: actions.decompressSource,
|
||||
decompressDest: actions.decompressDest,
|
||||
doDecompress: actions.doDecompress,
|
||||
showContextMenu: actions.showContextMenu,
|
||||
contextX: actions.contextX,
|
||||
contextY: actions.contextY,
|
||||
contextFile: actions.contextFile,
|
||||
contextAction: actions.contextAction,
|
||||
actionLoading,
|
||||
batchDelete: actions.batchDelete,
|
||||
openCompressDialog: actions.openCompressDialog,
|
||||
showPreview: editor.showPreview,
|
||||
previewTitle: editor.previewTitle,
|
||||
previewContent: editor.previewContent,
|
||||
previewLoading: editor.previewLoading,
|
||||
editorWorkbench: editor.editorWorkbench,
|
||||
onEditorSaved: editor.onEditorSaved,
|
||||
navigateToPath: nav.navigateToPath,
|
||||
pasteClipboard: actions.pasteClipboard,
|
||||
editFile: editor.editFile,
|
||||
previewFile: editor.previewFile,
|
||||
downloadFile: actions.downloadFile,
|
||||
confirmDeleteFile: actions.confirmDeleteFile,
|
||||
openInTerminal: nav.openInTerminal,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { http } from '@/api'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useFilesStore } from '@/stores/files'
|
||||
import type { FilesCapabilityResult } from '@/types/api'
|
||||
import {
|
||||
buildFilesAccessHint,
|
||||
isEntryLikelyUnreadable,
|
||||
} from '@/utils/fileMode'
|
||||
import { FILES_HINT_DISMISSED_KEY } from './constants'
|
||||
import { isNotFoundError } from './useFilesBrowse'
|
||||
|
||||
export function useFilesPermissions(options: {
|
||||
browse: (opts?: { force?: boolean }) => Promise<void | undefined>
|
||||
}) {
|
||||
const snackbar = useSnackbar()
|
||||
const store = useFilesStore()
|
||||
const {
|
||||
selectedServer,
|
||||
files,
|
||||
browseError,
|
||||
filesCapability,
|
||||
lastReadDeniedPath,
|
||||
accessHintDismissed,
|
||||
setupSudoLoading,
|
||||
setupSudoDone,
|
||||
} = storeToRefs(store)
|
||||
|
||||
const sshUserForFiles = computed(() => filesCapability.value?.ssh_user ?? null)
|
||||
|
||||
const unreadableFileCount = computed(() => {
|
||||
const user = sshUserForFiles.value
|
||||
if (!user) return 0
|
||||
return files.value.filter((f) => isEntryLikelyUnreadable(f, user)).length
|
||||
})
|
||||
|
||||
const filesAccessHint = computed(() => {
|
||||
if (accessHintDismissed.value || !selectedServer.value) return null
|
||||
if (browseError.value && isNotFoundError(browseError.value)) return null
|
||||
const cap = filesCapability.value
|
||||
return buildFilesAccessHint({
|
||||
sshUser: cap?.ssh_user,
|
||||
canSudo: cap?.can_sudo_nopasswd ?? false,
|
||||
whitelistOk: cap?.whitelist_ok ?? false,
|
||||
isRootSsh: cap?.is_root ?? false,
|
||||
filesElevation: cap?.files_elevation,
|
||||
capabilityMessage: cap?.message,
|
||||
lastReadDeniedPath: lastReadDeniedPath.value,
|
||||
unreadableCount: unreadableFileCount.value,
|
||||
})
|
||||
})
|
||||
|
||||
function dismissFilesAccessHint() {
|
||||
accessHintDismissed.value = true
|
||||
sessionStorage.setItem(FILES_HINT_DISMISSED_KEY, '1')
|
||||
}
|
||||
|
||||
async function refreshFilesCapability() {
|
||||
if (!selectedServer.value) {
|
||||
filesCapability.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
filesCapability.value = await http.get<FilesCapabilityResult>(
|
||||
`/servers/${selectedServer.value}/files-capability`,
|
||||
)
|
||||
} catch {
|
||||
filesCapability.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function setupFilesSudo() {
|
||||
if (!selectedServer.value) return
|
||||
setupSudoLoading.value = true
|
||||
try {
|
||||
const res = await http.post<{ ok: boolean; message: string }>(
|
||||
`/servers/${selectedServer.value}/setup-files-sudo`,
|
||||
{},
|
||||
)
|
||||
setupSudoDone.value = true
|
||||
snackbar(res.message || 'sudoers 配置成功,文件管理器现在可自动提权', 'success')
|
||||
dismissFilesAccessHint()
|
||||
await refreshFilesCapability()
|
||||
await options.browse({ force: true })
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '配置失败'
|
||||
snackbar(`一键配置失败:${msg}`, 'error')
|
||||
} finally {
|
||||
setupSudoLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sshUserForFiles,
|
||||
filesAccessHint,
|
||||
dismissFilesAccessHint,
|
||||
refreshFilesCapability,
|
||||
setupFilesSudo,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useFilesStore } from '@/stores/files'
|
||||
import type { FileEntry } from '@/types/api'
|
||||
import { joinRemotePath } from '@/utils/remotePath'
|
||||
|
||||
export function useFilesSelection() {
|
||||
const store = useFilesStore()
|
||||
const { currentPath } = storeToRefs(store)
|
||||
const selectedFiles = ref<FileEntry[]>([])
|
||||
const selectedFileCount = computed(() => selectedFiles.value.length)
|
||||
|
||||
function clearSelection() {
|
||||
selectedFiles.value = []
|
||||
}
|
||||
|
||||
function selectedAbsolutePaths(extra?: FileEntry[]): string[] {
|
||||
const items = extra?.length ? extra : selectedFiles.value
|
||||
return items.map((f) => joinRemotePath(currentPath.value, f.name))
|
||||
}
|
||||
|
||||
return {
|
||||
selectedFiles,
|
||||
selectedFileCount,
|
||||
clearSelection,
|
||||
selectedAbsolutePaths,
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ function isTypingTarget(target: EventTarget | null): boolean {
|
||||
export function useFilesHotkeys(
|
||||
handlers: FilesHotkeyHandlers,
|
||||
selectedFiles: Ref<FileEntry[]>,
|
||||
canPaste: Ref<boolean>,
|
||||
) {
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (isTypingTarget(e.target)) return
|
||||
@@ -49,19 +50,27 @@ export function useFilesHotkeys(
|
||||
handlers.selectAll()
|
||||
return
|
||||
}
|
||||
// Ctrl+C / Ctrl+X:仅在有勾选文件时生效,否则让浏览器处理原生复制
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
|
||||
e.preventDefault()
|
||||
handlers.copySelection()
|
||||
if (selectedFiles.value.length > 0) {
|
||||
e.preventDefault()
|
||||
handlers.copySelection()
|
||||
}
|
||||
return
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'x') {
|
||||
e.preventDefault()
|
||||
handlers.cutSelection()
|
||||
if (selectedFiles.value.length > 0) {
|
||||
e.preventDefault()
|
||||
handlers.cutSelection()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Ctrl+V:仅在剪贴板有内容时生效
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault()
|
||||
handlers.pasteClipboard()
|
||||
if (canPaste.value) {
|
||||
e.preventDefault()
|
||||
handlers.pasteClipboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface ServerBrief {
|
||||
id: number
|
||||
name: string
|
||||
domain?: string
|
||||
target_path?: string
|
||||
is_online?: boolean
|
||||
status?: string
|
||||
}
|
||||
@@ -22,10 +23,10 @@ export function useServerList() {
|
||||
async function loadServers(opts?: { perPage?: number }) {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await http.get<{ items: ServerBrief[] }>('/servers/', {
|
||||
const res = await http.getList<ServerBrief>('/servers/', {
|
||||
per_page: opts?.perPage ?? 200,
|
||||
})
|
||||
servers.value = res.items || []
|
||||
servers.value = res.items
|
||||
} catch {
|
||||
servers.value = []
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ref } from 'vue'
|
||||
import { http } from '@/api'
|
||||
|
||||
export interface QuickCmdItem {
|
||||
id: number
|
||||
name: string
|
||||
cmd: string
|
||||
is_builtin: boolean
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export function sortQuickCommands(items: QuickCmdItem[]): QuickCmdItem[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (a.is_builtin !== b.is_builtin) return a.is_builtin ? 1 : -1
|
||||
if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order
|
||||
return a.id - b.id
|
||||
})
|
||||
}
|
||||
|
||||
export function useTerminalQuickCommands() {
|
||||
const commands = ref<QuickCmdItem[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await http.get<QuickCmdItem[]>('/terminal/quick-commands')
|
||||
commands.value = sortQuickCommands(res || [])
|
||||
} catch {
|
||||
commands.value = []
|
||||
throw new Error('加载快捷命令失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(name: string, cmd: string) {
|
||||
await http.post('/terminal/quick-commands', { name: name.trim(), cmd: cmd.trim() })
|
||||
await load()
|
||||
}
|
||||
|
||||
async function update(id: number, name: string, cmd: string) {
|
||||
await http.put(`/terminal/quick-commands/${id}`, { name: name.trim(), cmd: cmd.trim() })
|
||||
await load()
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
await http.delete(`/terminal/quick-commands/${id}`)
|
||||
await load()
|
||||
}
|
||||
|
||||
async function reorder(customIds: number[]) {
|
||||
const res = await http.put<{ items: QuickCmdItem[] }>('/terminal/quick-commands/reorder', {
|
||||
ids: customIds,
|
||||
})
|
||||
commands.value = sortQuickCommands(res.items || [])
|
||||
}
|
||||
|
||||
const customCommands = () => commands.value.filter((c) => !c.is_builtin)
|
||||
const builtinCommands = () => commands.value.filter((c) => c.is_builtin)
|
||||
|
||||
return {
|
||||
commands,
|
||||
loading,
|
||||
load,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
reorder,
|
||||
customCommands,
|
||||
builtinCommands,
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 160px"
|
||||
style="max-width: 200px"
|
||||
clearable
|
||||
@update:model-value="page = 1; loadAudit()"
|
||||
/>
|
||||
@@ -57,7 +57,7 @@
|
||||
>
|
||||
<template #item.action="{ item }">
|
||||
<v-chip :color="actionColor(item.action)" size="small" variant="tonal" label>
|
||||
{{ item.action }}
|
||||
{{ auditActionLabel(item.action) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
@@ -66,7 +66,9 @@
|
||||
</template>
|
||||
|
||||
<template #item.target="{ item }">
|
||||
<span class="text-body-2">{{ item.resource_type }}{{ item.resource_id ? ` #${item.resource_id}` : '' }}</span>
|
||||
<span class="text-body-2">
|
||||
{{ auditTargetLabel(item.resource_type) }}{{ item.resource_id ? ` #${item.resource_id}` : '' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #item.detail="{ item }">
|
||||
@@ -95,6 +97,8 @@ import { ref, onMounted } from 'vue'
|
||||
import { http } from '@/api'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import type { PaginatedResponse, AuditItem } from '@/types/api'
|
||||
import { auditActionLabel, auditTargetLabel, auditActionFilterOptions } from '@/utils/auditLabels'
|
||||
import { normalizeAuditList } from '@/utils/auditNormalize'
|
||||
|
||||
const snackbar = useSnackbar()
|
||||
|
||||
@@ -107,15 +111,7 @@ const actionFilter = ref<string | null>(null)
|
||||
const userFilter = ref('')
|
||||
const dateFilter = ref('')
|
||||
|
||||
const actionOptions = [
|
||||
{ label: '创建', value: 'create' },
|
||||
{ label: '更新', value: 'update' },
|
||||
{ label: '删除', value: 'delete' },
|
||||
{ label: '登录', value: 'login' },
|
||||
{ label: '登出', value: 'logout' },
|
||||
{ label: '执行', value: 'execute' },
|
||||
{ label: '推送', value: 'push' },
|
||||
]
|
||||
const actionOptions = auditActionFilterOptions
|
||||
|
||||
const headers = [
|
||||
{ title: '操作', key: 'action', width: 100 },
|
||||
@@ -130,14 +126,16 @@ const headers = [
|
||||
async function loadAudit() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await http.get<PaginatedResponse<AuditItem>>('/audit/', {
|
||||
page: page.value,
|
||||
per_page: 50,
|
||||
const limit = 50
|
||||
const offset = (page.value - 1) * limit
|
||||
const res = await http.get<PaginatedResponse<unknown>>('/audit/', {
|
||||
limit,
|
||||
offset,
|
||||
action: actionFilter.value || undefined,
|
||||
admin_username: userFilter.value || undefined,
|
||||
date: dateFilter.value || undefined,
|
||||
date_from: dateFilter.value || undefined,
|
||||
})
|
||||
logs.value = res.items || []
|
||||
logs.value = normalizeAuditList(res.items || [])
|
||||
total.value = res.total || 0
|
||||
} catch { logs.value = []; snackbar('加载审计日志失败', 'error') }
|
||||
finally { loading.value = false }
|
||||
@@ -145,11 +143,10 @@ async function loadAudit() {
|
||||
|
||||
// ── Helpers ──
|
||||
function actionColor(a: string) {
|
||||
if (a === 'create') return 'success'
|
||||
if (a === 'update') return 'info'
|
||||
if (a === 'delete') return 'error'
|
||||
if (a === 'login' || a === 'logout') return 'secondary'
|
||||
if (a === 'execute') return 'warning'
|
||||
if (a.startsWith('create') || a === 'login') return 'success'
|
||||
if (a.startsWith('update')) return 'info'
|
||||
if (a.startsWith('delete') || a === 'logout') return 'error'
|
||||
if (a.includes('execute') || a.includes('push') || a.includes('retry')) return 'warning'
|
||||
return 'primary'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-container fluid class="pa-4 pa-md-6 dashboard-root">
|
||||
<!-- ── Stats Cards ── -->
|
||||
<v-row>
|
||||
<v-col
|
||||
@@ -57,7 +57,7 @@
|
||||
<v-chip
|
||||
:color="statusChipColor(item.status)"
|
||||
:text="statusLabel(item.status)"
|
||||
border="current sm"
|
||||
border="sm"
|
||||
size="x-small"
|
||||
label
|
||||
/>
|
||||
@@ -230,10 +230,10 @@
|
||||
<v-list-item
|
||||
v-for="item in recentAudit"
|
||||
:key="item.id"
|
||||
:title="`${item.action} - ${item.resource_type}`"
|
||||
:title="auditSummary(item)"
|
||||
:subtitle="`${item.admin_username} · ${formatRelativeTime(item.created_at)}`"
|
||||
class="px-0"
|
||||
lines="one"
|
||||
lines="two"
|
||||
>
|
||||
<template #append>
|
||||
<v-chip size="x-small" variant="tonal" label>{{ item.ip_address || '—' }}</v-chip>
|
||||
@@ -260,6 +260,13 @@
|
||||
.stat-card:hover .stat-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.dashboard-root {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.dashboard-root :deep(.v-table) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -270,6 +277,8 @@ import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import { useServerPagination } from '@/composables/useServerPagination'
|
||||
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
|
||||
import { auditSummary } from '@/utils/auditLabels'
|
||||
import { normalizeAuditList } from '@/utils/auditNormalize'
|
||||
import type { ServerApiItem, AuditItem, AlertLogItem } from '@/types/api'
|
||||
|
||||
const snackbar = useSnackbar()
|
||||
@@ -365,8 +374,8 @@ async function loadAlerts() {
|
||||
|
||||
async function loadRecentAudit() {
|
||||
try {
|
||||
const res = await http.get<{ items: AuditItem[] }>('/audit/', { per_page: 5 })
|
||||
recentAudit.value = res.items || []
|
||||
const res = await http.get<{ items: unknown[] }>('/audit/', { limit: 10 })
|
||||
recentAudit.value = normalizeAuditList(res.items || []).slice(0, 10)
|
||||
} catch { recentAudit.value = [] }
|
||||
}
|
||||
|
||||
|
||||
+25
-1368
File diff suppressed because it is too large
Load Diff
@@ -98,7 +98,7 @@
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis text-truncate">{{ s.domain }}</div>
|
||||
<div v-if="pushStatus[s.id]" class="text-caption mt-1">
|
||||
<v-chip :color="pushColor(pushStatus[s.id])" size="x-small" variant="tonal" label border="current sm">{{ pushStatus[s.id] }}</v-chip>
|
||||
<v-chip :color="pushColor(pushStatus[s.id])" size="x-small" variant="tonal" label border="sm">{{ pushStatus[s.id] }}</v-chip>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
@@ -205,7 +205,7 @@
|
||||
@update:page="logPage = $event; loadLogs()"
|
||||
>
|
||||
<template #item.status="{ item }">
|
||||
<v-chip :color="item.status === 'success' ? 'success' : item.status === 'failed' ? 'error' : 'warning'" size="x-small" variant="tonal" label border="current sm">
|
||||
<v-chip :color="item.status === 'success' ? 'success' : item.status === 'failed' ? 'error' : 'warning'" size="x-small" variant="tonal" label border="sm">
|
||||
{{ item.status === 'success' ? '成功' : item.status === 'failed' ? '失败' : item.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<v-container fluid class="pa-6">
|
||||
<v-card elevation="0" border rounded="lg">
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-card-title class="d-flex align-center text-h6">
|
||||
重试队列
|
||||
<v-spacer />
|
||||
<v-select
|
||||
@@ -31,13 +31,15 @@
|
||||
@update:page="page = $event; loadRetries()"
|
||||
>
|
||||
<template #item.status="{ item }">
|
||||
<v-chip :color="retryColor(item.status)" size="x-small" variant="tonal" label border="current sm">
|
||||
<v-chip :color="retryColor(item.status)" size="x-small" variant="tonal" label border="sm">
|
||||
{{ retryLabel(item.status) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.operation="{ item }">
|
||||
<span class="font-weight-medium">{{ item.operation }}</span>
|
||||
<span class="font-weight-medium text-truncate d-inline-block" style="max-width: 240px">
|
||||
{{ retryOperationLabel(item) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #item.server_name="{ item }">
|
||||
@@ -112,26 +114,29 @@ const statusOptions = [
|
||||
|
||||
const headers = [
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '操作', key: 'operation' },
|
||||
{ title: '任务', key: 'operation' },
|
||||
{ title: '服务器', key: 'server_name' },
|
||||
{ title: '次数', key: 'retry_count', width: 100 },
|
||||
{ title: '下次重试', key: 'next_retry_at', width: 140 },
|
||||
{ title: '创建时间', key: 'created_at', width: 160 },
|
||||
{ title: '操作', key: 'actions', width: 120, align: 'end' as const },
|
||||
{ title: '', key: 'actions', width: 120, align: 'end' as const, sortable: false },
|
||||
]
|
||||
|
||||
// ── Data loading ──
|
||||
async function loadRetries() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await http.getList<RetryItem>('/retries/', {
|
||||
page: page.value,
|
||||
per_page: 20,
|
||||
status: statusFilter.value || undefined,
|
||||
})
|
||||
retries.value = res.items
|
||||
total.value = res.total
|
||||
} catch { retries.value = [] }
|
||||
const res = await http.getList<RetryItem>('/retries/', { limit: 200 })
|
||||
let all = res.items || []
|
||||
if (statusFilter.value) all = all.filter((r) => r.status === statusFilter.value)
|
||||
total.value = all.length
|
||||
const start = (page.value - 1) * 20
|
||||
retries.value = all.slice(start, start + 20)
|
||||
} catch (e: unknown) {
|
||||
retries.value = []
|
||||
total.value = 0
|
||||
snackbar(e instanceof Error ? e.message : '加载重试队列失败', 'error')
|
||||
}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
@@ -174,6 +179,13 @@ function retryLabel(s: string) {
|
||||
return s
|
||||
}
|
||||
|
||||
function retryOperationLabel(item: RetryItem): string {
|
||||
if (item.source_path && item.target_path) {
|
||||
return `${item.source_path} → ${item.target_path}`
|
||||
}
|
||||
return item.source_path || item.target_path || '文件推送'
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => loadRetries())
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<v-container fluid class="pa-6">
|
||||
<v-card elevation="0" border rounded="lg">
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-card-title class="d-flex align-center text-h6">
|
||||
推送调度
|
||||
<v-spacer />
|
||||
<v-btn color="primary" variant="flat" size="small" prepend-icon="mdi-plus" @click="openEditor(null)">新建调度</v-btn>
|
||||
@@ -16,8 +16,8 @@
|
||||
:items-per-page="itemsPerPage"
|
||||
hover
|
||||
density="comfortable"
|
||||
@update:page="(p: number) => { page = p; loadSchedules() }"
|
||||
@update:items-per-page="(n: number) => { itemsPerPage = n; page = 1; loadSchedules() }"
|
||||
@update:page="onPageChange"
|
||||
@update:items-per-page="onItemsPerPageChange"
|
||||
>
|
||||
<template #item.enabled="{ item }">
|
||||
<v-switch
|
||||
@@ -32,12 +32,12 @@
|
||||
|
||||
<template #item.cron_expr="{ item }">
|
||||
<v-chip size="small" variant="tonal" label prepend-icon="mdi-clock-outline">
|
||||
{{ item.cron_expr }}
|
||||
{{ item.cron_expr || '—' }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.server_count="{ item }">
|
||||
<span class="text-medium-emphasis">{{ item.server_ids?.length || 0 }} 台</span>
|
||||
<span class="text-medium-emphasis">{{ serverIdCount(item.server_ids) }} 台</span>
|
||||
</template>
|
||||
|
||||
<template #item.last_run_at="{ item }">
|
||||
@@ -64,8 +64,8 @@
|
||||
<v-card border>
|
||||
<v-card-title>{{ editingSchedule ? '编辑调度' : '新建调度' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="form.name" label="调度名称" variant="outlined" density="compact" class="mb-2" :rules="[required('任务名称')]" />
|
||||
<v-text-field v-model="form.cron" label="Cron 表达式" variant="outlined" density="compact" hint="例如: */30 * * * *" persistent-hint class="mb-2" :rules="[required('Cron表达式'), cronExpression()]" />
|
||||
<v-text-field v-model="form.name" label="调度名称" variant="outlined" density="compact" class="mb-2" />
|
||||
<v-text-field v-model="form.cron" label="Cron 表达式" variant="outlined" density="compact" hint="例如: */30 * * * *" persistent-hint class="mb-2" />
|
||||
<v-text-field v-model="form.source_path" label="推送源路径" variant="outlined" density="compact" class="mb-2" />
|
||||
<v-text-field v-model="form.target_path" label="目标路径" variant="outlined" density="compact" class="mb-2" />
|
||||
<v-select
|
||||
@@ -110,7 +110,6 @@ import { ref, onMounted } from 'vue'
|
||||
import { http } from '@/api'
|
||||
import { useServerList } from '@/composables/useServerList'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { required, cronExpression } from '@/utils/validation'
|
||||
import type { ScheduleItem } from '@/types/api'
|
||||
|
||||
const snackbar = useSnackbar()
|
||||
@@ -152,17 +151,69 @@ const headers = [
|
||||
{ title: '操作', key: 'actions', width: 200, align: 'end' as const },
|
||||
]
|
||||
|
||||
function parseServerIds(raw: unknown): number[] {
|
||||
if (Array.isArray(raw)) return raw.map((x) => Number(x)).filter((n) => !Number.isNaN(n))
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) return parsed.map((x) => Number(x)).filter((n) => !Number.isNaN(n))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function serverIdCount(raw: unknown): number {
|
||||
return parseServerIds(raw).length
|
||||
}
|
||||
|
||||
function normalizeSchedule(row: ScheduleItem): ScheduleItem {
|
||||
return { ...row, server_ids: parseServerIds(row.server_ids) }
|
||||
}
|
||||
|
||||
function buildSchedulePayload() {
|
||||
const ids = form.value.server_ids
|
||||
if (!form.value.name.trim()) throw new Error('请填写调度名称')
|
||||
if (!form.value.cron.trim()) throw new Error('请填写 Cron 表达式')
|
||||
if (!/^[\d\s*/,\-]+$/.test(form.value.cron.trim())) throw new Error('Cron 表达式格式不正确')
|
||||
if (!ids.length) throw new Error('请至少选择一台服务器')
|
||||
if (!form.value.source_path.trim()) throw new Error('请填写推送源路径')
|
||||
return {
|
||||
name: form.value.name.trim(),
|
||||
cron_expr: form.value.cron.trim(),
|
||||
source_path: form.value.source_path.trim(),
|
||||
server_ids: JSON.stringify(ids),
|
||||
run_mode: 'cron' as const,
|
||||
schedule_type: 'push' as const,
|
||||
enabled: form.value.enabled,
|
||||
sync_mode: 'incremental',
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(p: number) {
|
||||
page.value = p
|
||||
loadSchedules()
|
||||
}
|
||||
|
||||
function onItemsPerPageChange(n: number) {
|
||||
itemsPerPage.value = n
|
||||
page.value = 1
|
||||
loadSchedules()
|
||||
}
|
||||
|
||||
// ── Data loading ──
|
||||
async function loadSchedules() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await http.getList<ScheduleItem>('/schedules/', {
|
||||
page: page.value,
|
||||
per_page: itemsPerPage.value,
|
||||
})
|
||||
schedules.value = res.items
|
||||
totalItems.value = res.total
|
||||
} catch { schedules.value = [] }
|
||||
const res = await http.getList<ScheduleItem>('/schedules/')
|
||||
const all = (res.items || []).map(normalizeSchedule)
|
||||
totalItems.value = all.length
|
||||
const start = (page.value - 1) * itemsPerPage.value
|
||||
schedules.value = all.slice(start, start + itemsPerPage.value)
|
||||
} catch (e: unknown) {
|
||||
schedules.value = []
|
||||
totalItems.value = 0
|
||||
snackbar(e instanceof Error ? e.message : '加载调度列表失败', 'error')
|
||||
}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
@@ -171,8 +222,8 @@ function openEditor(schedule: ScheduleItem | null) {
|
||||
editingSchedule.value = schedule
|
||||
if (schedule) {
|
||||
form.value = {
|
||||
name: schedule.name, cron: schedule.cron_expr, source_path: schedule.source_path || '',
|
||||
target_path: schedule.target_path || '', server_ids: schedule.server_ids || [],
|
||||
name: schedule.name, cron: schedule.cron_expr || '', source_path: schedule.source_path || '',
|
||||
target_path: schedule.target_path || '', server_ids: parseServerIds(schedule.server_ids),
|
||||
enabled: schedule.enabled,
|
||||
}
|
||||
} else {
|
||||
@@ -184,8 +235,9 @@ function openEditor(schedule: ScheduleItem | null) {
|
||||
async function saveSchedule() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingSchedule.value) await http.put(`/schedules/${editingSchedule.value.id}`, form.value)
|
||||
else await http.post('/schedules/', form.value)
|
||||
const payload = buildSchedulePayload()
|
||||
if (editingSchedule.value) await http.put(`/schedules/${editingSchedule.value.id}`, payload)
|
||||
else await http.post('/schedules/', payload)
|
||||
showEditor.value = false
|
||||
loadSchedules()
|
||||
snackbar('保存成功')
|
||||
@@ -209,8 +261,9 @@ async function runNow(item: ScheduleItem) {
|
||||
runningId.value = item.id
|
||||
try {
|
||||
await http.post('/sync/files', {
|
||||
source_path: item.source_path, target_path: item.target_path,
|
||||
server_ids: item.server_ids,
|
||||
source_path: item.source_path,
|
||||
target_path: item.target_path || '/tmp',
|
||||
server_ids: parseServerIds(item.server_ids),
|
||||
})
|
||||
snackbar('已触发推送')
|
||||
} catch (e: any) { snackbar(e.message || '执行失败', 'error') }
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
@click:row="(_e: Event, { item }: { item: ServerApiItem }) => showDetail(item)"
|
||||
>
|
||||
<template #item.status="{ item }">
|
||||
<v-chip :color="statusChipColor(item.status)" size="x-small" variant="tonal" label border="current sm">
|
||||
<v-chip :color="statusChipColor(item.status)" size="x-small" variant="tonal" label border="sm">
|
||||
{{ statusLabel(item.status) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<TerminalQuickCommandsSettings />
|
||||
|
||||
<!-- Telegram Settings -->
|
||||
<v-card elevation="0" border rounded="lg">
|
||||
<v-card-title class="d-flex align-center">
|
||||
@@ -197,6 +199,7 @@ import { http } from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { required, numberRange, isNumber, matchOther } from '@/utils/validation'
|
||||
import TerminalQuickCommandsSettings from '@/components/TerminalQuickCommandsSettings.vue'
|
||||
import type { SettingsResponse, AllowlistResponse, SettingItem } from '@/types/api'
|
||||
|
||||
const snackbar = useSnackbar()
|
||||
|
||||
+282
-197
@@ -4,55 +4,37 @@
|
||||
class="pa-0 d-flex flex-column terminal-root"
|
||||
:class="theme.global.current.value.dark ? 'terminal-root--dark' : 'terminal-root--light'"
|
||||
>
|
||||
<!-- ── Server Sidebar ── -->
|
||||
<v-navigation-drawer v-model="showServerSidebar" width="250" location="start" temporary>
|
||||
<v-list-subheader>服务器列表</v-list-subheader>
|
||||
<v-text-field
|
||||
v-model="sidebarSearch"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
placeholder="搜索..."
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mx-3 mb-2"
|
||||
/>
|
||||
<v-list density="compact" nav>
|
||||
<v-list-item
|
||||
v-for="s in sidebarFilteredServers"
|
||||
:key="s.id"
|
||||
:title="s.name"
|
||||
:subtitle="s.domain"
|
||||
@click="newSession(s.id); showServerSidebar = false"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :color="s.is_online !== false ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="sidebarFilteredServers.length === 0" class="text-center text-medium-emphasis">
|
||||
暂无服务器
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<!-- ── Context Menu ── -->
|
||||
<v-menu v-model="showTermMenu" :target="[menuX, menuY]" location="bottom start">
|
||||
<v-list density="compact" nav>
|
||||
<v-list-item prepend-icon="mdi-content-copy" @click="termCopy">
|
||||
<!-- ── 终端/命令栏右键菜单(中文、加大,避免浏览器英文菜单) ── -->
|
||||
<v-menu
|
||||
v-model="showTermMenu"
|
||||
:target="[menuX, menuY]"
|
||||
:location="termMenuLocation"
|
||||
content-class="terminal-ctx-menu"
|
||||
>
|
||||
<v-list density="comfortable" nav class="py-1">
|
||||
<v-list-item prepend-icon="mdi-content-copy" rounded="lg" @click="termCopy">
|
||||
<v-list-item-title>复制</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="mdi-content-paste" @click="termPaste">
|
||||
<v-list-item prepend-icon="mdi-content-paste" rounded="lg" @click="termPaste">
|
||||
<v-list-item-title>粘贴</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="mdi-select-all" @click="termSelectAll">
|
||||
<v-list-item prepend-icon="mdi-select-all" rounded="lg" @click="termSelectAll">
|
||||
<v-list-item-title>全选</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider />
|
||||
<v-list-item prepend-icon="mdi-delete-sweep" @click="termClear">
|
||||
<v-list-item-title>清屏</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="mdi-logout" @click="termDisconnect" :disabled="!activeSession">
|
||||
<v-list-item-title class="text-error">断开连接</v-list-item-title>
|
||||
</v-list-item>
|
||||
<template v-if="termMenuContext === 'terminal'">
|
||||
<v-divider class="my-1" />
|
||||
<v-list-item prepend-icon="mdi-delete-sweep" rounded="lg" @click="termClear">
|
||||
<v-list-item-title>清屏</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-logout"
|
||||
rounded="lg"
|
||||
:disabled="!activeSession"
|
||||
@click="termDisconnect"
|
||||
>
|
||||
<v-list-item-title class="text-error">断开连接</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
@@ -60,7 +42,6 @@
|
||||
<div class="d-flex flex-column flex-grow-1" style="min-height: 0">
|
||||
<!-- ── Tab Bar ── -->
|
||||
<div class="d-flex align-center px-2 py-1 shrink-0" style="background: rgb(var(--v-theme-surface)); border-bottom: 1px solid rgb(var(--v-theme-surface-variant))">
|
||||
<v-btn icon="mdi-server-network" size="small" variant="text" @click="showServerSidebar = !showServerSidebar" class="mr-1" />
|
||||
<div class="d-flex align-center ga-1 overflow-x-auto flex-grow-1">
|
||||
<v-chip
|
||||
v-for="tab in sessions"
|
||||
@@ -73,19 +54,23 @@
|
||||
@click="switchTab(tab.id)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon start size="8" :color="tab.status === 'connected' ? 'success' : tab.status === 'connecting' ? 'warning' : 'grey'">mdi-circle</v-icon>
|
||||
<v-icon size="8" :color="tab.status === 'connected' ? 'success' : tab.status === 'connecting' ? 'warning' : 'grey'">mdi-circle</v-icon>
|
||||
</template>
|
||||
<span class="text-truncate" style="max-width: 100px">{{ tab.serverName }}</span>
|
||||
<v-icon end size="12" class="ml-1" @click.stop="closeTab(tab.id)">mdi-close</v-icon>
|
||||
</v-chip>
|
||||
<v-btn icon size="x-small" variant="text" @click="newSession()">
|
||||
<v-icon size="14">mdi-plus</v-icon>
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="newSession()">
|
||||
新建会话
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Toolbar ── -->
|
||||
<div class="d-flex align-center justify-between px-4 py-1 shrink-0" style="background: rgb(var(--v-theme-surface)); border-bottom: 1px solid rgb(var(--v-theme-surface-variant)); min-height: 32px">
|
||||
<!-- ── Toolbar(有会话时才显示,避免空页出现「... / 断开」) ── -->
|
||||
<div
|
||||
v-if="sessions.length > 0"
|
||||
class="d-flex align-center justify-space-between px-4 py-1 shrink-0"
|
||||
style="background: rgb(var(--v-theme-surface)); border-bottom: 1px solid rgb(var(--v-theme-surface-variant)); min-height: 32px"
|
||||
>
|
||||
<div class="d-flex align-center ga-3">
|
||||
<span class="text-body-2 font-weight-medium">{{ activeSession?.serverName || '...' }}</span>
|
||||
<span v-if="activeSession?.cwd" class="text-caption text-medium-emphasis font-mono" style="max-width: 200px">{{ activeSession.cwd }}</span>
|
||||
@@ -104,23 +89,27 @@
|
||||
<span v-if="activeSession?.uptime" class="text-caption text-medium-emphasis">{{ activeSession.uptime }}</span>
|
||||
<span v-if="activeSession?.pingMs !== null && activeSession?.pingMs !== undefined" class="text-caption text-medium-emphasis">{{ activeSession.pingMs }}ms</span>
|
||||
</div>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-btn variant="text" size="x-small" density="compact" @click="changeFontSize(-1)">A−</v-btn>
|
||||
<span class="text-caption">{{ fontSize }}</span>
|
||||
<v-btn variant="text" size="x-small" density="compact" @click="changeFontSize(1)">A+</v-btn>
|
||||
<div class="d-flex align-center ga-2 terminal-toolbar-actions">
|
||||
<span class="text-body-2 text-medium-emphasis shrink-0">字号</span>
|
||||
<v-btn variant="tonal" size="small" @click="changeFontSize(-1)">缩小</v-btn>
|
||||
<span class="text-body-2 font-weight-medium" style="min-width: 1.5rem; text-align: center">{{ fontSize }}</span>
|
||||
<v-btn variant="tonal" size="small" @click="changeFontSize(1)">放大</v-btn>
|
||||
<v-select
|
||||
v-model="scrollback"
|
||||
:items="[1000, 5000, 10000, 50000]"
|
||||
density="compact"
|
||||
:items="scrollbackOptions"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
label="回滚行数"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
style="max-width: 90px"
|
||||
class="terminal-toolbar-scrollback"
|
||||
@update:model-value="changeScrollback"
|
||||
/>
|
||||
<v-btn variant="text" size="x-small" density="compact" @click="toggleFullscreen">
|
||||
<v-icon size="14">mdi-fullscreen</v-icon>
|
||||
<v-btn variant="tonal" size="small" prepend-icon="mdi-fullscreen" @click="toggleFullscreen">
|
||||
全屏
|
||||
</v-btn>
|
||||
<v-btn variant="text" size="x-small" density="compact" color="error" @click="disconnect">
|
||||
<v-btn variant="tonal" size="small" color="error" prepend-icon="mdi-link-off" @click="disconnect">
|
||||
断开
|
||||
</v-btn>
|
||||
</div>
|
||||
@@ -141,17 +130,46 @@
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Empty: no sessions -->
|
||||
<!-- Empty: 内嵌服务器列表(设计 §空状态),不与对话框叠层 -->
|
||||
<div
|
||||
v-if="sessions.length === 0 && !showDisconnectOverlay"
|
||||
class="d-flex flex-column align-center justify-center fill-height pa-8 text-center"
|
||||
v-if="sessions.length === 0 && !showDisconnectOverlay && !showServerDialog"
|
||||
class="d-flex flex-column align-center justify-center fill-height pa-6"
|
||||
>
|
||||
<v-icon size="56" color="primary" class="mb-4">mdi-console-network</v-icon>
|
||||
<div class="text-h6 mb-2">WebSSH 终端</div>
|
||||
<div class="text-body-2 text-medium-emphasis mb-6">选择一台服务器开始会话,或从服务器列表点击「终端」进入</div>
|
||||
<v-btn color="primary" variant="flat" prepend-icon="mdi-server" @click="showServerDialog = true">
|
||||
选择服务器
|
||||
</v-btn>
|
||||
<v-icon size="56" color="primary" class="mb-3">mdi-console-network</v-icon>
|
||||
<div class="text-h6 mb-1 text-center">WebSSH 终端</div>
|
||||
<div class="text-body-2 text-medium-emphasis mb-4 text-center">
|
||||
选择一台服务器开始会话,或从服务器页点击「终端」
|
||||
</div>
|
||||
<v-card border rounded="lg" class="empty-server-picker" max-width="520" width="100%">
|
||||
<v-card-text class="pa-3">
|
||||
<v-text-field
|
||||
v-model="serverSearch"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
placeholder="搜索服务器…"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-list density="compact" class="empty-server-picker__list" nav>
|
||||
<v-list-item
|
||||
v-for="s in filteredServers"
|
||||
:key="s.id"
|
||||
:title="s.name"
|
||||
:subtitle="s.domain"
|
||||
rounded="lg"
|
||||
@click="newSession(s.id)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="filteredServers.length === 0" class="text-medium-emphasis">
|
||||
暂无匹配的服务器
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- Disconnect overlay -->
|
||||
@@ -171,47 +189,59 @@
|
||||
</v-overlay>
|
||||
</div>
|
||||
|
||||
<!-- ── Quick Commands ── -->
|
||||
<div class="d-flex align-center ga-1 px-3 py-1 overflow-x-auto shrink-0" style="background: rgb(var(--v-theme-surface)); border-top: 1px solid rgb(var(--v-theme-surface-variant))">
|
||||
<span class="text-caption shrink-0">⚡</span>
|
||||
<v-chip
|
||||
v-for="cmd in quickCmds"
|
||||
:key="cmd.id"
|
||||
size="x-small"
|
||||
:variant="cmd.is_builtin ? 'outlined' : 'tonal'"
|
||||
:color="cmd.is_builtin ? 'default' : 'primary'"
|
||||
label
|
||||
class="cursor-pointer"
|
||||
@click="execQuickCmd(cmd.cmd)"
|
||||
@dblclick="!cmd.is_builtin && editQuickCmd(cmd)"
|
||||
<!-- ── Quick Commands(仅执行;管理见系统设置) ── -->
|
||||
<div class="terminal-quick-bar shrink-0">
|
||||
<span class="terminal-quick-bar__label">快捷命令</span>
|
||||
<div class="d-flex align-center ga-1 flex-grow-1 overflow-x-auto">
|
||||
<v-chip
|
||||
v-for="cmd in quickCmds"
|
||||
:key="cmd.id"
|
||||
size="small"
|
||||
:variant="cmd.is_builtin ? 'outlined' : 'flat'"
|
||||
:color="cmd.is_builtin ? 'default' : 'primary'"
|
||||
label
|
||||
class="cursor-pointer"
|
||||
@click="execQuickCmd(cmd.cmd)"
|
||||
>
|
||||
{{ cmd.name }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
prepend-icon="mdi-cog-outline"
|
||||
:to="{ path: '/settings', hash: '#terminal-quick-commands' }"
|
||||
>
|
||||
{{ cmd.name }}
|
||||
<v-icon v-if="!cmd.is_builtin" end size="10" class="ml-1" @click.stop="deleteQuickCmd(cmd.id)">mdi-close</v-icon>
|
||||
</v-chip>
|
||||
<v-btn icon size="x-small" variant="text" @click="addQuickCmd">
|
||||
<v-icon size="12">mdi-plus</v-icon>
|
||||
管理
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- ── Command Bar(Shell 语法高亮) ── -->
|
||||
<!-- ── Command Bar(Shell 语法高亮,约 3 倍高度) ── -->
|
||||
<div
|
||||
class="d-flex align-center ga-2 px-3 py-1 shrink-0 cmd-bar"
|
||||
class="d-flex align-start ga-2 px-3 py-2 shrink-0 cmd-bar cmd-bar--tall"
|
||||
:class="{ 'opacity-50 pointer-events-none': !activeSession?.ws }"
|
||||
@contextmenu="onCmdContext"
|
||||
>
|
||||
<span class="text-body-2 font-mono text-primary shrink-0">❯</span>
|
||||
<div class="flex-grow-1">
|
||||
<span class="cmd-bar__prompt font-mono text-primary shrink-0">❯</span>
|
||||
<div class="flex-grow-1 cmd-bar__input-wrap">
|
||||
<ShellHighlightField
|
||||
v-model="cmdInput"
|
||||
placeholder="输入命令,Enter 发送…(语法高亮)"
|
||||
multiline
|
||||
:rows="3"
|
||||
size="large"
|
||||
placeholder="输入命令,Enter 发送,Shift+Enter 换行"
|
||||
:disabled="!activeSession?.ws"
|
||||
@keydown="onCmdKeydown"
|
||||
/>
|
||||
</div>
|
||||
<v-btn size="small" variant="tonal" color="primary" @click="sendCmd" :disabled="!activeSession?.ws">发送</v-btn>
|
||||
<div class="d-flex ga-1 pl-2" style="border-left: 1px solid rgb(var(--v-theme-surface-variant))">
|
||||
<v-btn size="x-small" variant="outlined" density="compact" @click="sendCtrl('c')" title="Ctrl+C">^C</v-btn>
|
||||
<v-btn size="x-small" variant="outlined" density="compact" @click="sendCtrl('d')" title="Ctrl+D">^D</v-btn>
|
||||
<v-btn size="x-small" variant="outlined" density="compact" @click="sendKey('\t')" title="Tab补全">Tab</v-btn>
|
||||
<v-btn size="default" variant="tonal" color="primary" class="mt-1" @click="sendCmd" :disabled="!activeSession?.ws">发送</v-btn>
|
||||
<div class="terminal-hotkeys shrink-0 pl-3">
|
||||
<div class="text-body-2 font-weight-medium text-medium-emphasis mb-2">快捷键</div>
|
||||
<div class="d-flex flex-column ga-2">
|
||||
<v-btn size="default" variant="outlined" block @click="sendCtrl('c')">中断 (Ctrl+C)</v-btn>
|
||||
<v-btn size="default" variant="outlined" block @click="sendCtrl('d')">退出 (Ctrl+D)</v-btn>
|
||||
<v-btn size="default" variant="outlined" block @click="sendKey('\t')">补全 (Tab)</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,7 +261,7 @@
|
||||
@click="newSession(s.id); showServerDialog = false"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8" start>mdi-circle</v-icon>
|
||||
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
@@ -239,27 +269,11 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- ── Quick Cmd Dialog ── -->
|
||||
<v-dialog v-model="showQcDialog" max-width="400">
|
||||
<v-card border>
|
||||
<v-card-title>{{ qcEditId ? '编辑命令' : '添加命令' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="qcName" label="命令名称" variant="outlined" density="compact" class="mb-3" />
|
||||
<div class="text-caption text-medium-emphasis mb-1">命令内容(Shell 语法高亮,发送时自动补 \\r)</div>
|
||||
<ShellHighlightField v-model="qcCmd" multiline :rows="4" placeholder="systemctl status nginx" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showQcDialog = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="saveQuickCmd">保存</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick, inject, type Ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { http } from '@/api'
|
||||
@@ -271,12 +285,14 @@ import '@xterm/xterm/css/xterm.css'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { buildWebSocketUrl } from '@/utils/wsUrl'
|
||||
import ShellHighlightField from '@/components/ShellHighlightField.vue'
|
||||
import { sortQuickCommands, type QuickCmdItem } from '@/composables/useTerminalQuickCommands'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const theme = useTheme()
|
||||
const auth = useAuthStore()
|
||||
const snackbar = useSnackbar()
|
||||
const nexusDrawer = inject<Ref<boolean>>('nexusDrawer', ref(true))
|
||||
|
||||
type SessionStatus = 'idle' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
@@ -320,6 +336,24 @@ const activeSessionId = ref('')
|
||||
const termContainer = ref<HTMLElement | null>(null)
|
||||
const termRefs = new Map<string, HTMLElement>()
|
||||
|
||||
function refitAllTerminals() {
|
||||
sessions.value.forEach((s) => {
|
||||
try {
|
||||
s.fitAddon?.fit()
|
||||
} catch {
|
||||
/* container size 0 during transition */
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 侧栏开合 → v-main padding 变化 → 动画结束后 refit xterm */
|
||||
watch(nexusDrawer, () => {
|
||||
nextTick(() => {
|
||||
refitAllTerminals()
|
||||
setTimeout(refitAllTerminals, 220)
|
||||
})
|
||||
})
|
||||
|
||||
const activeSession = computed(() =>
|
||||
sessions.value.find(s => s.id === activeSessionId.value) || null
|
||||
)
|
||||
@@ -342,28 +376,18 @@ const servers = ref<{ id: number; name: string; domain: string; is_online: boole
|
||||
const showTermMenu = ref(false)
|
||||
const menuX = ref(0)
|
||||
const menuY = ref(0)
|
||||
const termMenuContext = ref<'terminal' | 'command'>('terminal')
|
||||
const termMenuLocation = computed(() => (menuY.value > window.innerHeight * 0.55 ? 'top end' : 'bottom end'))
|
||||
|
||||
// ── Server sidebar ──
|
||||
const showServerSidebar = ref(false)
|
||||
const sidebarSearch = ref('')
|
||||
const sidebarFilteredServers = computed(() => {
|
||||
const q = sidebarSearch.value.toLowerCase().trim()
|
||||
return servers.value.filter(s => !q || s.name.toLowerCase().includes(q) || s.domain.toLowerCase().includes(q))
|
||||
})
|
||||
const scrollbackOptions = [
|
||||
{ title: '1 千行', value: 1000 },
|
||||
{ title: '5 千行', value: 5000 },
|
||||
{ title: '1 万行', value: 10000 },
|
||||
{ title: '5 万行', value: 50000 },
|
||||
]
|
||||
|
||||
// ── Quick commands (from MySQL API) ──
|
||||
interface QuickCmdItem {
|
||||
id: number
|
||||
name: string
|
||||
cmd: string
|
||||
is_builtin: boolean
|
||||
sort_order: number
|
||||
}
|
||||
// ── Quick commands(只读展示,管理在系统设置) ──
|
||||
const quickCmds = ref<QuickCmdItem[]>([])
|
||||
const qcName = ref('')
|
||||
const qcCmd = ref('')
|
||||
const qcEditId = ref<number | null>(null)
|
||||
const showQcDialog = ref(false)
|
||||
const filteredServers = computed(() => {
|
||||
const q = serverSearch.value.toLowerCase().trim()
|
||||
return servers.value.filter(s => !q || s.name.toLowerCase().includes(q) || s.domain.toLowerCase().includes(q))
|
||||
@@ -399,10 +423,7 @@ async function applyRouteQuery() {
|
||||
const path = sanitizeRemotePath(String(route.query.path || ''))
|
||||
const forceNew = route.query.new_tab === '1' || route.query.new_tab === 'true'
|
||||
|
||||
if (!id) {
|
||||
if (sessions.value.length === 0) showServerDialog.value = true
|
||||
return
|
||||
}
|
||||
if (!id) return
|
||||
|
||||
if (!forceNew) {
|
||||
const existing = sessions.value.find(s => s.serverId === id)
|
||||
@@ -416,7 +437,7 @@ async function applyRouteQuery() {
|
||||
}
|
||||
|
||||
function onCmdKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendCmd()
|
||||
return
|
||||
@@ -787,18 +808,43 @@ function disconnect() {
|
||||
}
|
||||
|
||||
// ── Context menu handlers ──
|
||||
function onTermContext(e: MouseEvent) {
|
||||
function openTermMenu(e: MouseEvent, context: 'terminal' | 'command') {
|
||||
e.preventDefault()
|
||||
termMenuContext.value = context
|
||||
menuX.value = e.clientX
|
||||
menuY.value = e.clientY
|
||||
showTermMenu.value = true
|
||||
}
|
||||
|
||||
function onTermContext(e: MouseEvent) {
|
||||
openTermMenu(e, 'terminal')
|
||||
}
|
||||
|
||||
function onCmdContext(e: MouseEvent) {
|
||||
openTermMenu(e, 'command')
|
||||
}
|
||||
|
||||
function getCmdInputEl(): HTMLTextAreaElement | null {
|
||||
const root = document.querySelector('.cmd-bar--tall .sh-field__input--multi')
|
||||
return root instanceof HTMLTextAreaElement ? root : null
|
||||
}
|
||||
|
||||
function termCopy() {
|
||||
showTermMenu.value = false
|
||||
if (termMenuContext.value === 'command') {
|
||||
const el = getCmdInputEl()
|
||||
const text = el?.value.substring(el.selectionStart ?? 0, el.selectionEnd ?? 0) || ''
|
||||
if (text) {
|
||||
navigator.clipboard.writeText(text).catch(() => snackbar('复制失败', 'error'))
|
||||
return
|
||||
}
|
||||
}
|
||||
const sel = window.getSelection()?.toString()
|
||||
if (sel) {
|
||||
navigator.clipboard.writeText(sel).catch(() => snackbar('复制失败', 'error'))
|
||||
} else if (activeSession.value?.term) {
|
||||
const buffer = activeSession.value.term.getSelection()
|
||||
if (buffer) navigator.clipboard.writeText(buffer).catch(() => snackbar('复制失败', 'error'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,7 +852,22 @@ async function termPaste() {
|
||||
showTermMenu.value = false
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (activeSession.value?.term && text) {
|
||||
if (!text) return
|
||||
if (termMenuContext.value === 'command') {
|
||||
const el = getCmdInputEl()
|
||||
if (el) {
|
||||
const start = el.selectionStart ?? el.value.length
|
||||
const end = el.selectionEnd ?? start
|
||||
const next = el.value.slice(0, start) + text + el.value.slice(end)
|
||||
cmdInput.value = next
|
||||
await nextTick()
|
||||
el.focus()
|
||||
const pos = start + text.length
|
||||
el.setSelectionRange(pos, pos)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (activeSession.value?.term) {
|
||||
activeSession.value.term.paste(text)
|
||||
}
|
||||
} catch {
|
||||
@@ -816,6 +877,14 @@ async function termPaste() {
|
||||
|
||||
function termSelectAll() {
|
||||
showTermMenu.value = false
|
||||
if (termMenuContext.value === 'command') {
|
||||
const el = getCmdInputEl()
|
||||
if (el) {
|
||||
el.focus()
|
||||
el.select()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (activeSession.value?.term) {
|
||||
activeSession.value.term.selectAll()
|
||||
}
|
||||
@@ -976,16 +1045,15 @@ function toggleFullscreen() {
|
||||
async function loadQuickCmds() {
|
||||
try {
|
||||
const res = await http.get<QuickCmdItem[]>('/terminal/quick-commands')
|
||||
quickCmds.value = res || []
|
||||
quickCmds.value = sortQuickCommands(res || [])
|
||||
} catch {
|
||||
// Fallback: use built-in commands only
|
||||
quickCmds.value = [
|
||||
{ id: 1, name: '系统状态', cmd: "systemctl status --no-pager 2>&1 | head -20\r", is_builtin: true, sort_order: 1 },
|
||||
{ id: 2, name: '系统日志', cmd: "journalctl -n 30 --no-pager 2>&1\r", is_builtin: true, sort_order: 2 },
|
||||
{ id: 3, name: '磁盘使用', cmd: "df -h\r", is_builtin: true, sort_order: 3 },
|
||||
{ id: 4, name: '内存使用', cmd: "free -h\r", is_builtin: true, sort_order: 4 },
|
||||
{ id: 5, name: '进程列表', cmd: "ps aux --sort=-%mem 2>&1 | head -15\r", is_builtin: true, sort_order: 5 },
|
||||
]
|
||||
quickCmds.value = sortQuickCommands([
|
||||
{ id: 1, name: '系统状态', cmd: "systemctl status --no-pager 2>&1 | head -20\r", is_builtin: true, sort_order: 1001 },
|
||||
{ id: 2, name: '系统日志', cmd: "journalctl -n 30 --no-pager 2>&1\r", is_builtin: true, sort_order: 1002 },
|
||||
{ id: 3, name: '磁盘使用', cmd: "df -h\r", is_builtin: true, sort_order: 1003 },
|
||||
{ id: 4, name: '内存使用', cmd: "free -h\r", is_builtin: true, sort_order: 1004 },
|
||||
{ id: 5, name: '进程列表', cmd: "ps aux --sort=-%mem 2>&1 | head -15\r", is_builtin: true, sort_order: 1005 },
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -999,53 +1067,6 @@ function execQuickCmd(cmd: string) {
|
||||
s.term?.focus()
|
||||
}
|
||||
|
||||
function addQuickCmd() {
|
||||
qcEditId.value = null
|
||||
qcName.value = ''
|
||||
qcCmd.value = ''
|
||||
showQcDialog.value = true
|
||||
}
|
||||
|
||||
function editQuickCmd(cmd: QuickCmdItem) {
|
||||
qcEditId.value = cmd.id
|
||||
qcName.value = cmd.name
|
||||
qcCmd.value = cmd.cmd
|
||||
showQcDialog.value = true
|
||||
}
|
||||
|
||||
async function saveQuickCmd() {
|
||||
if (!qcName.value.trim() || !qcCmd.value.trim()) {
|
||||
snackbar('名称和命令不能为空', 'warning')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (qcEditId.value) {
|
||||
await http.put(`/terminal/quick-commands/${qcEditId.value}`, {
|
||||
name: qcName.value,
|
||||
cmd: qcCmd.value,
|
||||
})
|
||||
} else {
|
||||
await http.post('/terminal/quick-commands', {
|
||||
name: qcName.value,
|
||||
cmd: qcCmd.value,
|
||||
})
|
||||
}
|
||||
showQcDialog.value = false
|
||||
await loadQuickCmds()
|
||||
} catch (e: any) {
|
||||
snackbar(e?.message || '保存失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteQuickCmd(id: number) {
|
||||
try {
|
||||
await http.delete(`/terminal/quick-commands/${id}`)
|
||||
await loadQuickCmds()
|
||||
} catch (e: any) {
|
||||
snackbar(e?.message || '删除失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load server list ──
|
||||
async function loadServers() {
|
||||
try {
|
||||
@@ -1107,24 +1128,88 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped>
|
||||
.terminal-root {
|
||||
background: #0b1120;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.terminal-root--dark {
|
||||
background: #0b1120;
|
||||
}
|
||||
.terminal-root--light {
|
||||
background: rgb(var(--v-theme-background));
|
||||
}
|
||||
.empty-server-picker__list {
|
||||
max-height: min(42vh, 320px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.fill-height {
|
||||
flex: 1 1 auto;
|
||||
min-height: 200px;
|
||||
}
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
|
||||
/* 服务器列表抽屉须高于终端内容,且从主内容区左缘弹出(非全局侧栏位) */
|
||||
.font-mono { font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace; }
|
||||
:deep(.xterm) { padding: 4px; height: 100%; }
|
||||
:deep(.xterm-viewport) { overflow-y: auto !important; }
|
||||
|
||||
.terminal-quick-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-top: 1px solid rgb(var(--v-theme-surface-variant));
|
||||
}
|
||||
.terminal-quick-bar__label {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
.cmd-bar {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-top: 1px solid rgb(var(--v-theme-surface-variant));
|
||||
}
|
||||
.cmd-bar--tall {
|
||||
min-height: 120px;
|
||||
}
|
||||
.cmd-bar__prompt {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.55;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.cmd-bar__input-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.terminal-hotkeys {
|
||||
border-left: 1px solid rgb(var(--v-theme-surface-variant));
|
||||
min-width: 9.5rem;
|
||||
}
|
||||
|
||||
.terminal-toolbar-scrollback {
|
||||
min-width: 8.5rem;
|
||||
max-width: 10rem;
|
||||
}
|
||||
|
||||
:deep(.terminal-ctx-menu) {
|
||||
min-width: 11.5rem;
|
||||
}
|
||||
|
||||
:deep(.terminal-ctx-menu .v-list-item-title) {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.terminal-ctx-menu .v-icon) {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
/* Shell 语法高亮(命令栏 + 快捷命令编辑) */
|
||||
.terminal-root--dark :deep(.sh-keyword) { color: #60a5fa; font-weight: 500; }
|
||||
|
||||
@@ -20,6 +20,19 @@ const routes = [
|
||||
|
||||
const router = createRouter({ history: createWebHashHistory(), routes })
|
||||
|
||||
/** 懒加载 chunk 失败时提示强刷(多因 index 与 assets 哈希不一致) */
|
||||
router.onError((err, to) => {
|
||||
const msg = err?.message || ''
|
||||
if (
|
||||
msg.includes('Failed to fetch dynamically imported module')
|
||||
|| msg.includes('Importing a module script failed')
|
||||
|| msg.includes('error loading dynamically imported module')
|
||||
) {
|
||||
const w = window as Window & { $snackbar?: (text: string, color?: string) => void }
|
||||
w.$snackbar?.(`页面「${String(to.name || to.path)}」加载失败,请 Ctrl+F5 强制刷新`, 'error')
|
||||
}
|
||||
})
|
||||
|
||||
// Auth guard — restore session from HttpOnly refresh cookie when needed
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
@@ -7,6 +7,21 @@ import { ApiError, TotpRequiredError, api, http } from '@/api'
|
||||
|
||||
export { TotpRequiredError }
|
||||
|
||||
/** 从 JWT 不验签地解码 exp 字段(秒)。失败返回 0。 */
|
||||
function decodeJwtExp(jwt: string): number {
|
||||
try {
|
||||
let part = jwt.split('.')[1]
|
||||
if (!part) return 0
|
||||
const pad = part.length % 4
|
||||
if (pad) part += '='.repeat(4 - pad)
|
||||
const json = atob(part.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
const obj = JSON.parse(json) as { exp?: number }
|
||||
return typeof obj.exp === 'number' ? obj.exp : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(null)
|
||||
const admin = ref<AdminProfile | null>(null)
|
||||
@@ -14,6 +29,15 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
/** token 过期时间(unix 秒),无 token 时为 0 */
|
||||
const tokenExp = computed(() => (token.value ? decodeJwtExp(token.value) : 0))
|
||||
|
||||
/** token 是否即将过期(默认 5 分钟内) */
|
||||
function isTokenExpiringSoon(thresholdSec = 300): boolean {
|
||||
const exp = tokenExp.value
|
||||
return exp > 0 && exp - Math.floor(Date.now() / 1000) < thresholdSec
|
||||
}
|
||||
|
||||
function setAccessToken(accessToken: string) {
|
||||
token.value = accessToken
|
||||
}
|
||||
@@ -89,6 +113,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
admin,
|
||||
isLoggedIn,
|
||||
bootstrapped,
|
||||
tokenExp,
|
||||
isTokenExpiringSoon,
|
||||
setAccessToken,
|
||||
tryRestoreSession,
|
||||
login,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Pinia — 文件管理器共享状态(路径/列表/权限/目录缓存)
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { FileEntry, FilesCapabilityResult } from '@/types/api'
|
||||
import { DEFAULT_FILES_ROOT } from '@/utils/remotePath'
|
||||
import { FILES_CACHE_MAX_ENTRIES, FILES_CACHE_TTL_MS } from '@/composables/files/constants'
|
||||
|
||||
interface FilesCacheEntry {
|
||||
items: FileEntry[]
|
||||
at: number
|
||||
}
|
||||
|
||||
function cacheKey(serverId: number, path: string): string {
|
||||
return `${serverId}:${path}`
|
||||
}
|
||||
|
||||
export const useFilesStore = defineStore('files', () => {
|
||||
const selectedServer = ref<number | null>(null)
|
||||
const currentPath = ref(DEFAULT_FILES_ROOT)
|
||||
const files = ref<FileEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const browseError = ref('')
|
||||
const filesCapability = ref<FilesCapabilityResult | null>(null)
|
||||
const lastReadDeniedPath = ref<string | null>(null)
|
||||
const accessHintDismissed = ref(false)
|
||||
const setupSudoLoading = ref(false)
|
||||
const setupSudoDone = ref(false)
|
||||
const pageReady = ref(false)
|
||||
/** 路由 watch 与 onMounted 协调 */
|
||||
const routeReady = ref(false)
|
||||
/** 阻止 v-select 初始化触发 onServerChange */
|
||||
const initing = ref(false)
|
||||
|
||||
const fileCache = new Map<string, FilesCacheEntry>()
|
||||
|
||||
function getCached(serverId: number, path: string): FileEntry[] | null {
|
||||
const entry = fileCache.get(cacheKey(serverId, path))
|
||||
if (!entry) return null
|
||||
if (Date.now() - entry.at > FILES_CACHE_TTL_MS) {
|
||||
fileCache.delete(cacheKey(serverId, path))
|
||||
return null
|
||||
}
|
||||
return entry.items
|
||||
}
|
||||
|
||||
function setCached(serverId: number, path: string, items: FileEntry[]) {
|
||||
const key = cacheKey(serverId, path)
|
||||
fileCache.set(key, { items, at: Date.now() })
|
||||
if (fileCache.size > FILES_CACHE_MAX_ENTRIES) {
|
||||
const oldest = [...fileCache.entries()].sort((a, b) => a[1].at - b[1].at)[0]
|
||||
if (oldest) fileCache.delete(oldest[0])
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateCache(serverId?: number | null, path?: string) {
|
||||
if (serverId == null) {
|
||||
fileCache.clear()
|
||||
return
|
||||
}
|
||||
if (path == null) {
|
||||
const prefix = `${serverId}:`
|
||||
for (const key of [...fileCache.keys()]) {
|
||||
if (key.startsWith(prefix)) fileCache.delete(key)
|
||||
}
|
||||
return
|
||||
}
|
||||
fileCache.delete(cacheKey(serverId, path))
|
||||
}
|
||||
|
||||
function resetForServerChange() {
|
||||
lastReadDeniedPath.value = null
|
||||
accessHintDismissed.value = false
|
||||
setupSudoDone.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
selectedServer,
|
||||
currentPath,
|
||||
files,
|
||||
loading,
|
||||
browseError,
|
||||
filesCapability,
|
||||
lastReadDeniedPath,
|
||||
accessHintDismissed,
|
||||
setupSudoLoading,
|
||||
setupSudoDone,
|
||||
pageReady,
|
||||
routeReady,
|
||||
initing,
|
||||
getCached,
|
||||
setCached,
|
||||
invalidateCache,
|
||||
resetForServerChange,
|
||||
}
|
||||
})
|
||||
@@ -211,8 +211,11 @@ export interface AuditItem {
|
||||
id: number
|
||||
admin_username: string
|
||||
action: string
|
||||
/** API 字段为 target_type;兼容旧字段名 */
|
||||
resource_type: string
|
||||
target_type?: string
|
||||
resource_id: number | null
|
||||
target_id?: number | null
|
||||
detail: string | null
|
||||
ip_address: string | null
|
||||
created_at: string | null
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/** 审计日志 action / target_type 中文展示(与后端 AuditLog.action 对齐) */
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
// 通用 CRUD
|
||||
create: '创建',
|
||||
update: '更新',
|
||||
delete: '删除',
|
||||
login: '登录',
|
||||
logout: '登出',
|
||||
execute: '执行',
|
||||
push: '推送',
|
||||
|
||||
// 认证 / 会话
|
||||
login_success: '登录成功',
|
||||
login_locked: '登录锁定',
|
||||
login_ip_blocked: '登录 IP 被拒绝',
|
||||
refresh_invalid_format: '刷新令牌格式无效',
|
||||
refresh_admin_not_found: '刷新令牌用户不存在',
|
||||
refresh_version_mismatch: '刷新令牌版本失效',
|
||||
refresh_token_not_found: '刷新令牌会话不存在',
|
||||
refresh_legacy_format: '旧版刷新令牌已废弃',
|
||||
change_password: '修改密码',
|
||||
setup_totp: '初始化双因素认证',
|
||||
enable_totp: '启用双因素认证',
|
||||
disable_totp: '禁用双因素认证',
|
||||
webssh_token: '签发 WebSSH 令牌',
|
||||
|
||||
// 服务器 / Agent
|
||||
create_server: '创建服务器',
|
||||
update_server: '更新服务器',
|
||||
delete_server: '删除服务器',
|
||||
import_servers: '导入服务器',
|
||||
generate_agent_key: '生成 Agent 密钥',
|
||||
install_agent: '安装 Agent',
|
||||
uninstall_agent: '卸载 Agent',
|
||||
upgrade_agent: '升级 Agent',
|
||||
reveal_install_cmd: '查看安装命令',
|
||||
batch_install_agent: '批量安装 Agent',
|
||||
batch_upgrade_agent: '批量升级 Agent',
|
||||
batch_uninstall_agent: '批量卸载 Agent',
|
||||
batch_detect_path: '批量检测路径',
|
||||
|
||||
// 资产
|
||||
create_platform: '创建平台',
|
||||
update_platform: '更新平台',
|
||||
delete_platform: '删除平台',
|
||||
create_node: '创建节点',
|
||||
update_node: '更新节点',
|
||||
delete_node: '删除节点',
|
||||
|
||||
// 脚本 / 凭据 / 执行
|
||||
create_script: '创建脚本',
|
||||
update_script: '更新脚本',
|
||||
delete_script: '删除脚本',
|
||||
create_credential: '创建数据库凭据',
|
||||
update_credential: '更新数据库凭据',
|
||||
delete_credential: '删除数据库凭据',
|
||||
execute_started: '开始执行脚本',
|
||||
execute_command: '执行脚本',
|
||||
stop_execution: '停止脚本执行',
|
||||
mark_stuck: '标记执行卡住',
|
||||
retry_execution: '重试脚本执行',
|
||||
server_auto_stuck: '服务器执行超时卡住',
|
||||
auto_stuck: '执行超时自动标记',
|
||||
script_job_callback: '脚本任务回调',
|
||||
|
||||
// 终端 / WebSSH
|
||||
create_quick_command: '添加快捷命令',
|
||||
update_quick_command: '更新快捷命令',
|
||||
delete_quick_command: '删除快捷命令',
|
||||
reorder_quick_commands: '调整快捷命令顺序',
|
||||
webssh_connect: 'WebSSH 连接',
|
||||
webssh_disconnect: 'WebSSH 断开',
|
||||
|
||||
// 系统设置
|
||||
reveal_api_key: '查看 API 密钥',
|
||||
update_setting: '更新系统设置',
|
||||
create_schedule: '创建推送调度',
|
||||
update_schedule: '更新推送调度',
|
||||
delete_schedule: '删除推送调度',
|
||||
create_preset: '创建密码预设',
|
||||
update_preset: '更新密码预设',
|
||||
delete_preset: '删除密码预设',
|
||||
reveal_preset: '查看密码预设',
|
||||
create_ssh_key_preset: '创建 SSH 密钥预设',
|
||||
update_ssh_key_preset: '更新 SSH 密钥预设',
|
||||
delete_ssh_key_preset: '删除 SSH 密钥预设',
|
||||
reveal_ssh_key_preset: '查看 SSH 密钥预设',
|
||||
reveal_telegram_token: '查看 Telegram Token',
|
||||
parse_subscription: '解析订阅节点',
|
||||
update_ip_allowlist: '更新登录 IP 白名单',
|
||||
toggle_ip_allowlist: '开关登录 IP 白名单',
|
||||
add_manual_ips: '添加白名单 IP',
|
||||
remove_allowlist_ip: '移除白名单 IP',
|
||||
retry_job: '手动重试推送',
|
||||
delete_retry: '删除重试任务',
|
||||
|
||||
// 同步 / 文件
|
||||
sync_files: '文件推送',
|
||||
sync_preview: '推送预览',
|
||||
sync_cancel: '取消推送',
|
||||
sync_verify: '推送校验',
|
||||
validate_source_path: '验证源路径',
|
||||
sync_push: '文件推送',
|
||||
file_copy: '复制远程文件',
|
||||
file_move: '移动远程文件',
|
||||
file_delete: '删除远程文件',
|
||||
file_rename: '重命名远程文件',
|
||||
file_mkdir: '新建远程目录',
|
||||
browse_directory: '浏览远程目录',
|
||||
file_diff: '对比远程文件',
|
||||
file_upload: '上传文件到服务器',
|
||||
file_download: '从服务器下载文件',
|
||||
file_read: '读取远程文件',
|
||||
file_write: '写入远程文件',
|
||||
file_permission_change: '修改文件权限',
|
||||
file_compress: '压缩远程文件',
|
||||
file_decompress: '解压远程文件',
|
||||
local_file_delete: '删除本地文件',
|
||||
local_file_rename: '重命名本地文件',
|
||||
local_file_preview: '预览本地文件',
|
||||
upload_zip: '上传并解压 ZIP',
|
||||
diagnose: '服务器诊断',
|
||||
chmod: '修改权限',
|
||||
chown: '修改属主',
|
||||
}
|
||||
|
||||
const TARGET_LABELS: Record<string, string> = {
|
||||
server: '服务器',
|
||||
schedule: '调度',
|
||||
script: '脚本',
|
||||
credential: '数据库凭据',
|
||||
db_credential: '数据库凭据',
|
||||
admin: '管理员',
|
||||
setting: '系统设置',
|
||||
quick_command: '快捷命令',
|
||||
retry: '重试任务',
|
||||
sync: '同步',
|
||||
file: '文件',
|
||||
webssh: '终端会话',
|
||||
platform: '平台',
|
||||
node: '节点',
|
||||
script_execution: '脚本执行',
|
||||
preset: '密码预设',
|
||||
ssh_key_preset: 'SSH 密钥预设',
|
||||
}
|
||||
|
||||
/** 常见英文片段 → 中文(用于未登记 action 的兜底) */
|
||||
const TOKEN_ZH: Record<string, string> = {
|
||||
create: '创建',
|
||||
update: '更新',
|
||||
delete: '删除',
|
||||
batch: '批量',
|
||||
reveal: '查看',
|
||||
install: '安装',
|
||||
uninstall: '卸载',
|
||||
upgrade: '升级',
|
||||
import: '导入',
|
||||
export: '导出',
|
||||
generate: '生成',
|
||||
execute: '执行',
|
||||
retry: '重试',
|
||||
stop: '停止',
|
||||
mark: '标记',
|
||||
auto: '自动',
|
||||
stuck: '卡住',
|
||||
server: '服务器',
|
||||
script: '脚本',
|
||||
file: '文件',
|
||||
sync: '同步',
|
||||
push: '推送',
|
||||
quick: '快捷',
|
||||
command: '命令',
|
||||
commands: '命令',
|
||||
preset: '预设',
|
||||
schedule: '调度',
|
||||
setting: '设置',
|
||||
platform: '平台',
|
||||
node: '节点',
|
||||
credential: '凭据',
|
||||
admin: '管理员',
|
||||
login: '登录',
|
||||
logout: '登出',
|
||||
webssh: 'WebSSH',
|
||||
connect: '连接',
|
||||
disconnect: '断开',
|
||||
token: '令牌',
|
||||
permission: '权限',
|
||||
change: '变更',
|
||||
reorder: '排序',
|
||||
local: '本地',
|
||||
remote: '远程',
|
||||
preview: '预览',
|
||||
verify: '验证',
|
||||
validate: '验证',
|
||||
browse: '浏览',
|
||||
directory: '目录',
|
||||
upload: '上传',
|
||||
download: '下载',
|
||||
read: '读取',
|
||||
write: '写入',
|
||||
copy: '复制',
|
||||
move: '移动',
|
||||
rename: '重命名',
|
||||
mkdir: '新建目录',
|
||||
compress: '压缩',
|
||||
decompress: '解压',
|
||||
diagnose: '诊断',
|
||||
parse: '解析',
|
||||
subscription: '订阅',
|
||||
allowlist: '白名单',
|
||||
manual: '手动',
|
||||
ips: 'IP',
|
||||
ip: 'IP',
|
||||
telegram: 'Telegram',
|
||||
agent: 'Agent',
|
||||
job: '任务',
|
||||
execution: '执行',
|
||||
started: '开始',
|
||||
success: '成功',
|
||||
locked: '锁定',
|
||||
blocked: '被拒绝',
|
||||
invalid: '无效',
|
||||
format: '格式',
|
||||
version: '版本',
|
||||
mismatch: '不匹配',
|
||||
not: '未',
|
||||
found: '找到',
|
||||
legacy: '旧版',
|
||||
totp: '双因素认证',
|
||||
password: '密码',
|
||||
key: '密钥',
|
||||
api: 'API',
|
||||
ssh: 'SSH',
|
||||
detect: '检测',
|
||||
path: '路径',
|
||||
cancel: '取消',
|
||||
diff: '对比',
|
||||
zip: 'ZIP',
|
||||
callback: '回调',
|
||||
}
|
||||
|
||||
function fallbackActionLabel(action: string): string {
|
||||
const parts = action.toLowerCase().split('_').filter(Boolean)
|
||||
if (parts.length === 0) return '—'
|
||||
const zh = parts.map((p) => TOKEN_ZH[p] ?? p)
|
||||
return zh.join('')
|
||||
}
|
||||
|
||||
export function auditActionLabel(action: string): string {
|
||||
if (!action) return '—'
|
||||
const key = action.trim()
|
||||
if (ACTION_LABELS[key]) return ACTION_LABELS[key]
|
||||
return fallbackActionLabel(key)
|
||||
}
|
||||
|
||||
export function auditTargetLabel(type: string): string {
|
||||
if (!type) return '—'
|
||||
const key = type.trim()
|
||||
if (TARGET_LABELS[key]) return TARGET_LABELS[key]
|
||||
return fallbackActionLabel(key)
|
||||
}
|
||||
|
||||
/** 审计页「操作类型」筛选(中文标签 + 英文 value) */
|
||||
export const auditActionFilterOptions: { label: string; value: string }[] = [
|
||||
{ label: '登录成功', value: 'login_success' },
|
||||
{ label: '登出', value: 'logout' },
|
||||
{ label: '修改密码', value: 'change_password' },
|
||||
{ label: '创建服务器', value: 'create_server' },
|
||||
{ label: '更新服务器', value: 'update_server' },
|
||||
{ label: '删除服务器', value: 'delete_server' },
|
||||
{ label: '文件推送', value: 'sync_files' },
|
||||
{ label: '执行脚本', value: 'execute_command' },
|
||||
{ label: 'WebSSH 连接', value: 'webssh_connect' },
|
||||
{ label: '写入远程文件', value: 'file_write' },
|
||||
{ label: '更新系统设置', value: 'update_setting' },
|
||||
{ label: '手动重试推送', value: 'retry_job' },
|
||||
]
|
||||
|
||||
export function auditSummary(item: {
|
||||
action: string
|
||||
resource_type?: string
|
||||
target_type?: string
|
||||
detail?: string | null
|
||||
}): string {
|
||||
const type = item.resource_type || item.target_type || ''
|
||||
const base = `${auditActionLabel(item.action)} · ${auditTargetLabel(type)}`
|
||||
const detail = (item.detail || '').trim()
|
||||
return detail ? `${base}(${detail})` : base
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { AuditItem } from '@/types/api'
|
||||
|
||||
/** 统一后端 target_* 与前端 resource_* 字段 */
|
||||
export function normalizeAuditItem(raw: Record<string, unknown>): AuditItem {
|
||||
const targetType = String(raw.target_type ?? raw.resource_type ?? '')
|
||||
const targetId = raw.target_id ?? raw.resource_id
|
||||
return {
|
||||
id: Number(raw.id),
|
||||
admin_username: String(raw.admin_username ?? ''),
|
||||
action: String(raw.action ?? ''),
|
||||
resource_type: targetType,
|
||||
target_type: targetType,
|
||||
resource_id: targetId != null && targetId !== '' ? Number(targetId) : null,
|
||||
target_id: targetId != null && targetId !== '' ? Number(targetId) : null,
|
||||
detail: raw.detail != null ? String(raw.detail) : null,
|
||||
ip_address: raw.ip_address != null ? String(raw.ip_address) : null,
|
||||
created_at: raw.created_at != null ? String(raw.created_at) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAuditList(items: unknown[]): AuditItem[] {
|
||||
return items.map((row) => normalizeAuditItem(row as Record<string, unknown>))
|
||||
}
|
||||
@@ -67,35 +67,57 @@ export function isEntryLikelyUnreadable(
|
||||
return !otherTri.includes('r') && !groupTri.includes('r')
|
||||
}
|
||||
|
||||
/** 文件页提示末尾:仓库内 sudoers 示例路径(需运维安装到目标机)。 */
|
||||
export function filesSudoersDocHint(): string {
|
||||
return `目标机安装示例:${SUDOERS_DOC}(复制到 /etc/sudoers.d/nexus-files 后执行 visudo -cf 校验)`
|
||||
}
|
||||
|
||||
export function buildFilesAccessHint(params: {
|
||||
sshUser?: string | null
|
||||
canSudo: boolean
|
||||
whitelistOk?: boolean
|
||||
isRootSsh: boolean
|
||||
filesElevation?: 'off' | 'auto_sudo' | 'always_sudo'
|
||||
capabilityMessage?: string | null
|
||||
lastReadDeniedPath?: string | null
|
||||
unreadableCount?: number
|
||||
}): { type: 'warning' | 'error'; text: string } | null {
|
||||
// root SSH 用户无需任何提示
|
||||
if (params.isRootSsh) return null
|
||||
|
||||
const lines: string[] = []
|
||||
if (params.lastReadDeniedPath) {
|
||||
lines.push(`无法读取「${params.lastReadDeniedPath}」:当前 SSH 用户可能无读权限。`)
|
||||
}
|
||||
const n = params.unreadableCount ?? 0
|
||||
|
||||
// 只在有实际权限问题时显示提示
|
||||
if (params.lastReadDeniedPath) {
|
||||
lines.push(`无法读取「${params.lastReadDeniedPath}」:当前 SSH 用户无权限。`)
|
||||
}
|
||||
if (n > 0 && params.sshUser) {
|
||||
lines.push(`当前目录约 ${n} 个文件对 SSH 用户「${params.sshUser}」可能不可读(根据 ls 权限推断)。`)
|
||||
lines.push(`当前目录约 ${n} 个文件对 SSH 用户「${params.sshUser}」不可读。`)
|
||||
}
|
||||
if (!params.isRootSsh && !params.canSudo) {
|
||||
lines.push(
|
||||
'写权限、chmod、读取 root 属主文件等可能失败。请在目标机配置免密 sudo,或改用 root SSH。',
|
||||
)
|
||||
} else if (!params.isRootSsh && params.canSudo) {
|
||||
lines.push('部分操作将自动尝试 sudo -n(需白名单含 chmod/chown/cat 等)。')
|
||||
|
||||
// 确认有实际错误后,再给出配置建议
|
||||
if (lines.length > 0) {
|
||||
if (params.filesElevation === 'off') {
|
||||
lines.push('此服务器「文件提权」已关闭,不会自动 sudo。')
|
||||
} else if (!params.canSudo) {
|
||||
lines.push('请配置免密 sudo(NOPASSWD)或使用「一键配置」,或改用 root SSH 用户。')
|
||||
} else if (!params.whitelistOk) {
|
||||
lines.push('已有 sudo 但白名单不完整(须含 bash、chmod、chown、tee、cat 等)。')
|
||||
} else {
|
||||
lines.push('系统已自动尝试 sudo -n 提权,如仍失败请检查 sudoers 白名单。')
|
||||
}
|
||||
}
|
||||
|
||||
if (!lines.length) return null
|
||||
|
||||
const needsSudoersDoc =
|
||||
params.filesElevation === 'off' || !params.canSudo || !params.whitelistOk
|
||||
const suffix = needsSudoersDoc ? ` ${filesSudoersDocHint()}` : ''
|
||||
const type: 'warning' | 'error' =
|
||||
!params.isRootSsh && !params.canSudo ? 'error' : 'warning'
|
||||
return {
|
||||
type,
|
||||
text: `${lines.join(' ')} 示例:${SUDOERS_DOC}`,
|
||||
}
|
||||
(!params.canSudo || params.filesElevation === 'off') ? 'error' : 'warning'
|
||||
const body = lines.join(' ')
|
||||
return { type, text: `${body}${suffix}` }
|
||||
}
|
||||
|
||||
export { SUDOERS_DOC }
|
||||
|
||||
@@ -2,9 +2,12 @@ import { http } from '@/api'
|
||||
import { joinRemotePath, normalizeRemotePath } from '@/utils/remotePath'
|
||||
import type { FileEntry } from '@/types/api'
|
||||
|
||||
/** 浏览器内编辑 / 预加载上限(300KB) */
|
||||
/** 后台预加载上限(300KB)—— 超过此大小的文件不自动预读 */
|
||||
export const FILE_PRELOAD_MAX_BYTES = 300 * 1024
|
||||
|
||||
/** 浏览器内编辑 / 查看上限(2MB)—— 超过此大小则提示下载 */
|
||||
export const FILE_EDITOR_MAX_BYTES = 2 * 1024 * 1024
|
||||
|
||||
const MAX_PRELOAD_FILES_PER_DIR = 40
|
||||
const PRELOAD_CONCURRENCY = 4
|
||||
|
||||
@@ -51,7 +54,7 @@ export function isPreloadEligible(item: Pick<FileEntry, 'type' | 'size'>): boole
|
||||
}
|
||||
|
||||
export function exceedsEditorMaxSize(size: number | undefined): boolean {
|
||||
return size != null && size > 0 && size > FILE_PRELOAD_MAX_BYTES
|
||||
return size != null && size > 0 && size > FILE_EDITOR_MAX_BYTES
|
||||
}
|
||||
|
||||
function parseReadFileResponse(res: { content?: string } | string): string {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
import json, urllib.request, sys
|
||||
base = 'https://api.synaglobal.vip'
|
||||
req = urllib.request.Request(
|
||||
base + '/api/auth/login',
|
||||
data=json.dumps({'username': 'admin', 'password': 'Nexus@2026'}).encode(),
|
||||
method='POST',
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
tok = json.loads(urllib.request.urlopen(req).read())['access_token']
|
||||
h = {'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json'}
|
||||
|
||||
# get all servers
|
||||
srv_data = json.loads(urllib.request.urlopen(
|
||||
urllib.request.Request(base + '/api/servers/?per_page=50', headers=h)
|
||||
).read())
|
||||
servers = srv_data.get('items', [])
|
||||
print(f'Total servers: {len(servers)}')
|
||||
for s in servers:
|
||||
print(f" id={s['id']} name={s.get('name','')} user={s.get('username','')} target={s.get('target_path','')} domain={s.get('domain','')}")
|
||||
|
||||
print()
|
||||
# browse server 8 several paths
|
||||
for path in ['/', '/www', '/www/wwwroot', '/tmp']:
|
||||
b = json.dumps({'server_id': 8, 'path': path}).encode()
|
||||
try:
|
||||
d = json.loads(urllib.request.urlopen(
|
||||
urllib.request.Request(base + '/api/sync/browse', data=b, method='POST', headers=h),
|
||||
timeout=20
|
||||
).read())
|
||||
n = len(d.get('items', []))
|
||||
err = (d.get('error') or '')[:120]
|
||||
print(f"server8 {path}: items={n} error={repr(err)}")
|
||||
if n > 0:
|
||||
for it in d['items'][:6]:
|
||||
print(f" {it['type']:9} {it['name']}")
|
||||
except Exception as e:
|
||||
print(f"server8 {path}: EXCEPTION {e}")
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nexus 全功能验收(API + 可选 UI)
|
||||
# 用法:
|
||||
# bash scripts/run_full_acceptance.sh
|
||||
# bash scripts/run_full_acceptance.sh --with-ui
|
||||
# bash scripts/run_full_acceptance.sh --with-unit --json reports/acceptance.json
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
EXTRA=()
|
||||
if [[ "${1:-}" == "--with-ui" ]]; then
|
||||
shift
|
||||
export NEXUS_E2E_BASE="${NEXUS_E2E_BASE:-https://api.synaglobal.vip}"
|
||||
export NEXUS_E2E_USER="${NEXUS_E2E_USER:-admin}"
|
||||
if [[ -z "${NEXUS_E2E_PASSWORD:-}" && -z "${NEXUS_TEST_ADMIN_PASSWORD:-}" ]]; then
|
||||
if command -v ssh >/dev/null 2>&1; then
|
||||
export NEXUS_E2E_PASSWORD
|
||||
NEXUS_E2E_PASSWORD="$(ssh -o ConnectTimeout=10 nexus 'grep ^NEXUS_TEST_ADMIN_PASSWORD= /www/wwwroot/api.synaglobal.vip/.env | cut -d= -f2-' | tr -d '"' || true)"
|
||||
export NEXUS_E2E_PASSWORD
|
||||
fi
|
||||
fi
|
||||
EXTRA+=(--with-ui)
|
||||
fi
|
||||
exec python3 scripts/run_nexus_acceptance.py "${EXTRA[@]}" "$@"
|
||||
@@ -0,0 +1,567 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Nexus 全功能自动化验收 — 统一入口
|
||||
|
||||
自动执行 API 端到端、扩展 API、可选单元测试与 Playwright UI,按功能模块汇总 PASS/FAIL/SKIP。
|
||||
|
||||
用法:
|
||||
python scripts/run_nexus_acceptance.py
|
||||
python scripts/run_nexus_acceptance.py --base http://127.0.0.1:8600
|
||||
python scripts/run_nexus_acceptance.py --with-ui
|
||||
python scripts/run_nexus_acceptance.py --with-unit
|
||||
python scripts/run_nexus_acceptance.py --json reports/acceptance.json
|
||||
|
||||
环境变量(与 tests/test_api.py 相同):
|
||||
NEXUS_TEST_BASE 默认 http://127.0.0.1:8600
|
||||
NEXUS_TEST_ADMIN_USER 默认 admin
|
||||
NEXUS_TEST_ADMIN_PASSWORD 可从仓库根 .env 读取
|
||||
NEXUS_E2E_BASE / NEXUS_E2E_USER / NEXUS_E2E_PASSWORD (--with-ui 时)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
TESTS_DIR = REPO_ROOT / "tests"
|
||||
sys.path.insert(0, str(TESTS_DIR))
|
||||
|
||||
from acceptance_catalog import FEATURES, match_feature # noqa: E402
|
||||
|
||||
PASS_RE = re.compile(r"\[PASS\]\s*(.+)")
|
||||
FAIL_RE = re.compile(r"\[FAIL\]\s*(.+)")
|
||||
|
||||
|
||||
def _safe_console(text: str) -> str:
|
||||
"""Avoid UnicodeEncodeError on Windows GBK consoles."""
|
||||
return text.encode("ascii", errors="replace").decode("ascii")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckRecord:
|
||||
name: str
|
||||
status: str # pass | fail | skip
|
||||
feature_id: str
|
||||
layer: str
|
||||
message: str = ""
|
||||
duration_ms: float = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureRollup:
|
||||
id: str
|
||||
name: str
|
||||
layer: str
|
||||
description: str
|
||||
status: str = "skip" # pass | fail | skip | warn
|
||||
passed: int = 0
|
||||
failed: int = 0
|
||||
skipped: int = 0
|
||||
checks: list[CheckRecord] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AcceptanceReport:
|
||||
started_at: str
|
||||
base_url: str
|
||||
phases: dict[str, dict]
|
||||
features: list[dict]
|
||||
summary: dict
|
||||
|
||||
|
||||
def _load_env_from_dotenv() -> None:
|
||||
env_path = REPO_ROOT / ".env"
|
||||
if not env_path.is_file():
|
||||
return
|
||||
for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip().strip("\"'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _run_subprocess_script(
|
||||
script_rel: str,
|
||||
env: dict[str, str],
|
||||
timeout: int = 600,
|
||||
) -> tuple[int, str, str, float]:
|
||||
script = REPO_ROOT / script_rel
|
||||
t0 = time.perf_counter()
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(script)],
|
||||
cwd=str(REPO_ROOT),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
out = (proc.stdout or "") + (proc.stderr or "")
|
||||
return proc.returncode, out, proc.stderr or "", elapsed
|
||||
|
||||
|
||||
def _parse_api_output(output: str, layer: str = "api") -> list[CheckRecord]:
|
||||
records: list[CheckRecord] = []
|
||||
for line in output.splitlines():
|
||||
m_pass = PASS_RE.search(line)
|
||||
m_fail = FAIL_RE.search(line)
|
||||
if m_pass:
|
||||
name = m_pass.group(1).strip()
|
||||
fid = match_feature(name)
|
||||
records.append(CheckRecord(name, "pass", fid, layer))
|
||||
elif m_fail:
|
||||
name = m_fail.group(1).strip()
|
||||
fid = match_feature(name)
|
||||
msg = line.split("—", 1)[-1].strip() if "—" in line else ""
|
||||
records.append(CheckRecord(name, "fail", fid, layer, message=msg))
|
||||
return records
|
||||
|
||||
|
||||
def _http_json(
|
||||
method: str,
|
||||
url: str,
|
||||
body: dict | None = None,
|
||||
headers: dict | None = None,
|
||||
timeout: int = 30,
|
||||
) -> tuple[int, str]:
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.getcode(), resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _extra_api_checks(base: str, token: str) -> list[CheckRecord]:
|
||||
"""补充 test_full_site_api 未覆盖或需单独断言的检查。"""
|
||||
records: list[CheckRecord] = []
|
||||
auth = {"Authorization": f"Bearer {token}"} if token else {}
|
||||
|
||||
# 静态 SPA
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
req = urllib.request.Request(f"{base.rstrip('/')}/app/")
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
html = resp.read().decode("utf-8", errors="replace")[:8000]
|
||||
ok = resp.getcode() == 200 and ("id=\"app\"" in html or "id='app'" in html or "/assets/" in html)
|
||||
records.append(
|
||||
CheckRecord(
|
||||
"GET /app/ (SPA 入口)",
|
||||
"pass" if ok else "fail",
|
||||
"infra_app",
|
||||
"infra",
|
||||
message="" if ok else "缺少 #app 或 assets",
|
||||
duration_ms=(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
)
|
||||
except Exception as ex:
|
||||
records.append(CheckRecord("GET /app/", "fail", "infra_app", "infra", message=str(ex)))
|
||||
|
||||
# 登录后探测第一台服务器的 files-capability
|
||||
code, raw = _http_json("GET", f"{base}/api/servers/?per_page=5", headers=auth)
|
||||
first_id = None
|
||||
if code == 200:
|
||||
try:
|
||||
items = json.loads(raw).get("items") or []
|
||||
if items:
|
||||
first_id = items[0].get("id")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if first_id:
|
||||
t0 = time.perf_counter()
|
||||
code2, raw2 = _http_json(
|
||||
"GET",
|
||||
f"{base}/api/servers/{first_id}/files-capability",
|
||||
headers=auth,
|
||||
)
|
||||
ok = code2 == 200 and "ssh_user" in raw2
|
||||
records.append(
|
||||
CheckRecord(
|
||||
f"GET /api/servers/{first_id}/files-capability",
|
||||
"pass" if ok else "fail",
|
||||
"files",
|
||||
"api",
|
||||
message="" if ok else f"HTTP {code2}",
|
||||
duration_ms=(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
)
|
||||
t0 = time.perf_counter()
|
||||
code3, _ = _http_json(
|
||||
"POST",
|
||||
f"{base}/api/sync/browse",
|
||||
body={"server_id": first_id, "path": "/tmp"},
|
||||
headers=auth,
|
||||
)
|
||||
records.append(
|
||||
CheckRecord(
|
||||
"POST /api/sync/browse /tmp",
|
||||
"pass" if code3 == 200 else "fail",
|
||||
"files",
|
||||
"api",
|
||||
message=f"HTTP {code3}" if code3 != 200 else "",
|
||||
duration_ms=(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
)
|
||||
else:
|
||||
records.append(
|
||||
CheckRecord(
|
||||
"files-capability & browse (skip)",
|
||||
"skip",
|
||||
"files",
|
||||
"api",
|
||||
message="无可用服务器,跳过 SSH 相关检查",
|
||||
)
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def _run_pytest_unit(timeout: int = 300) -> tuple[list[CheckRecord], int]:
|
||||
"""运行不依赖运行中后端的单元测试子集。"""
|
||||
patterns = [
|
||||
"test_posix_paths.py",
|
||||
"test_remote_path_validation.py",
|
||||
"test_unix_ls.py",
|
||||
"test_files_elevation.py",
|
||||
"test_schema_path_validators.py",
|
||||
"test_file_permissions.py",
|
||||
"test_security_unit.py",
|
||||
]
|
||||
paths = [str(TESTS_DIR / p) for p in patterns if (TESTS_DIR / p).is_file()]
|
||||
if not paths:
|
||||
return [CheckRecord("pytest unit", "skip", "unit_core", "unit", message="无单元测试文件")], 0
|
||||
|
||||
t0 = time.perf_counter()
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", *paths, "-q", "--tb=no"],
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
out = (proc.stdout or "") + (proc.stderr or "")
|
||||
# pytest -q: "12 passed" or "1 failed, 11 passed"
|
||||
m = re.search(r"(\d+)\s+passed", out)
|
||||
passed_n = int(m.group(1)) if m else 0
|
||||
m_fail = re.search(r"(\d+)\s+failed", out)
|
||||
failed_n = int(m_fail.group(1)) if m_fail else 0
|
||||
status = "pass" if proc.returncode == 0 else "fail"
|
||||
return [
|
||||
CheckRecord(
|
||||
f"pytest unit ({passed_n} passed, {failed_n} failed)",
|
||||
status,
|
||||
"unit_core",
|
||||
"unit",
|
||||
message=out.strip()[-500:] if status == "fail" else "",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
], proc.returncode
|
||||
|
||||
|
||||
def _run_playwright(env: dict[str, str], timeout: int = 900) -> tuple[list[CheckRecord], int]:
|
||||
frontend = REPO_ROOT / "frontend"
|
||||
spec = frontend / "e2e" / "full-acceptance.spec.mjs"
|
||||
if not spec.is_file():
|
||||
return [CheckRecord("playwright", "skip", "ui_dashboard", "ui", message="缺少 e2e spec")], 0
|
||||
if not (frontend / "node_modules").is_dir():
|
||||
return [
|
||||
CheckRecord(
|
||||
"playwright",
|
||||
"skip",
|
||||
"ui_dashboard",
|
||||
"ui",
|
||||
message="请先在 frontend/ 执行 npm install",
|
||||
)
|
||||
], 0
|
||||
if not env.get("NEXUS_E2E_PASSWORD"):
|
||||
return [
|
||||
CheckRecord(
|
||||
"playwright UI",
|
||||
"skip",
|
||||
"ui_dashboard",
|
||||
"ui",
|
||||
message="未设置 NEXUS_E2E_PASSWORD",
|
||||
)
|
||||
], 0
|
||||
|
||||
t0 = time.perf_counter()
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"npx",
|
||||
"playwright",
|
||||
"test",
|
||||
"e2e/full-acceptance.spec.mjs",
|
||||
"--reporter=line",
|
||||
],
|
||||
cwd=str(frontend),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
shell=os.name == "nt",
|
||||
)
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
out = (proc.stdout or "") + (proc.stderr or "")
|
||||
records: list[CheckRecord] = []
|
||||
# Playwright line reporter: " ok 1 [chromium] › ..."
|
||||
for line in out.splitlines():
|
||||
if " ok " in line and "›" in line:
|
||||
title = line.split("›", 1)[-1].strip()
|
||||
fid = match_feature(title)
|
||||
records.append(CheckRecord(title, "pass", fid, "ui"))
|
||||
elif (" x " in line or "✘" in line) and "›" in line:
|
||||
title = line.split("›", 1)[-1].strip()
|
||||
fid = match_feature(title)
|
||||
records.append(CheckRecord(title, "fail", fid, "ui", message=line.strip()))
|
||||
if not records:
|
||||
records.append(
|
||||
CheckRecord(
|
||||
"playwright suite",
|
||||
"pass" if proc.returncode == 0 else "fail",
|
||||
"ui_dashboard",
|
||||
"ui",
|
||||
message=out[-400:] if proc.returncode != 0 else "",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
)
|
||||
return records, proc.returncode
|
||||
|
||||
|
||||
def _rollup_features(all_checks: list[CheckRecord]) -> list[FeatureRollup]:
|
||||
by_id: dict[str, FeatureRollup] = {}
|
||||
spec_map = {s.id: s for s in FEATURES}
|
||||
for spec in FEATURES:
|
||||
by_id[spec.id] = FeatureRollup(
|
||||
spec.id, spec.name, spec.layer, spec.description,
|
||||
)
|
||||
|
||||
for chk in all_checks:
|
||||
fid = chk.feature_id if chk.feature_id in by_id else "other"
|
||||
if fid not in by_id:
|
||||
by_id[fid] = FeatureRollup(fid, fid, chk.layer, "")
|
||||
roll = by_id[fid]
|
||||
roll.checks.append(chk)
|
||||
if chk.status == "pass":
|
||||
roll.passed += 1
|
||||
elif chk.status == "fail":
|
||||
roll.failed += 1
|
||||
else:
|
||||
roll.skipped += 1
|
||||
|
||||
for roll in by_id.values():
|
||||
if roll.failed > 0:
|
||||
roll.status = "fail"
|
||||
elif roll.passed > 0:
|
||||
roll.status = "pass"
|
||||
elif roll.skipped > 0:
|
||||
roll.status = "skip"
|
||||
else:
|
||||
roll.status = "skip"
|
||||
|
||||
order = {s.id: i for i, s in enumerate(FEATURES)}
|
||||
return sorted(
|
||||
by_id.values(),
|
||||
key=lambda r: (order.get(r.id, 999), r.id),
|
||||
)
|
||||
|
||||
|
||||
def _print_report(rollups: list[FeatureRollup], summary: dict) -> None:
|
||||
# ASCII 标记,避免 Windows GBK 控制台 UnicodeEncodeError
|
||||
icons = {"pass": "[OK]", "fail": "[FAIL]", "skip": "[SKIP]", "warn": "[WARN]"}
|
||||
print("\n" + "=" * 60)
|
||||
print("Nexus 功能验收报告")
|
||||
print("=" * 60)
|
||||
for roll in rollups:
|
||||
if roll.status == "skip" and roll.passed == 0 and roll.failed == 0:
|
||||
continue
|
||||
icon = icons.get(roll.status, "?")
|
||||
detail = f" ({roll.passed} 通过"
|
||||
if roll.failed:
|
||||
detail += f", {roll.failed} 失败"
|
||||
if roll.skipped:
|
||||
detail += f", {roll.skipped} 跳过"
|
||||
detail += ")"
|
||||
print(_safe_console(f" {icon} [{roll.layer:5}] {roll.name}{detail}"))
|
||||
for c in roll.checks:
|
||||
if c.status == "fail":
|
||||
msg = _safe_console((c.message or "")[:120])
|
||||
print(_safe_console(f" - {c.name}: {msg}"))
|
||||
print("-" * 60)
|
||||
print(
|
||||
f"合计: {summary['passed_checks']} 通过, "
|
||||
f"{summary['failed_checks']} 失败, "
|
||||
f"{summary['skipped_checks']} 跳过 | "
|
||||
f"功能模块 {summary['features_pass']}/{summary['features_total']} 正常"
|
||||
)
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Nexus 全功能自动化验收")
|
||||
parser.add_argument("--base", default=None, help="API 基址,默认 NEXUS_TEST_BASE")
|
||||
parser.add_argument("--with-ui", action="store_true", help="运行 Playwright UI 验收(需 Node)")
|
||||
parser.add_argument("--with-unit", action="store_true", help="运行 pytest 单元测试子集")
|
||||
parser.add_argument("--skip-api", action="store_true", help="跳过 test_api / test_full_site_api")
|
||||
parser.add_argument("--json", dest="json_path", default=None, help="写入 JSON 报告路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
_load_env_from_dotenv()
|
||||
base = (args.base or os.environ.get("NEXUS_TEST_BASE", "http://127.0.0.1:8600")).rstrip("/")
|
||||
env = os.environ.copy()
|
||||
env["NEXUS_TEST_BASE"] = base
|
||||
|
||||
started = datetime.now(timezone.utc).isoformat()
|
||||
all_checks: list[CheckRecord] = []
|
||||
phases: dict[str, dict] = {}
|
||||
|
||||
print("=" * 60)
|
||||
print("Nexus 全功能自动化验收")
|
||||
print(f" 目标: {base}")
|
||||
print(f" 时间: {started}")
|
||||
print("=" * 60)
|
||||
|
||||
exit_codes: list[int] = []
|
||||
|
||||
if not args.skip_api:
|
||||
for label, script in (
|
||||
("core_api", "tests/test_api.py"),
|
||||
("extended_api", "tests/test_full_site_api.py"),
|
||||
):
|
||||
print(f"\n>>> 阶段: {label} ({script})")
|
||||
code, out, err, ms = _run_subprocess_script(script, env)
|
||||
checks = _parse_api_output(out)
|
||||
all_checks.extend(checks)
|
||||
phases[label] = {
|
||||
"exit_code": code,
|
||||
"checks": len(checks),
|
||||
"duration_ms": ms,
|
||||
"stdout_tail": out[-1500:] if code != 0 else "",
|
||||
}
|
||||
exit_codes.append(code)
|
||||
if err and code != 0:
|
||||
print(err[-500:])
|
||||
|
||||
# 登录拿 token 做补充检查
|
||||
import test_api as api_base # noqa: E402
|
||||
|
||||
api_base._load_credentials_from_env_file()
|
||||
api_base.BASE = base
|
||||
login = api_base.api_test(
|
||||
"acceptance-login",
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
body={
|
||||
"username": api_base.ADMIN_USER,
|
||||
"password": api_base.ADMIN_PASSWORD,
|
||||
},
|
||||
expect_code=200,
|
||||
)
|
||||
token = (login or {}).get("access_token", "")
|
||||
if token:
|
||||
print("\n>>> 阶段: extra_api")
|
||||
extra = _extra_api_checks(base, token)
|
||||
all_checks.extend(extra)
|
||||
phases["extra_api"] = {"checks": len(extra)}
|
||||
else:
|
||||
print(" [WARN] 登录失败,跳过 extra_api(请配置 NEXUS_TEST_ADMIN_PASSWORD)")
|
||||
all_checks.append(
|
||||
CheckRecord("extra_api", "skip", "auth", "api", message="登录失败"),
|
||||
)
|
||||
|
||||
if args.with_unit:
|
||||
print("\n>>> 阶段: unit_tests")
|
||||
unit_checks, code = _run_pytest_unit()
|
||||
all_checks.extend(unit_checks)
|
||||
phases["unit_tests"] = {"exit_code": code}
|
||||
exit_codes.append(code)
|
||||
|
||||
if args.with_ui:
|
||||
print("\n>>> 阶段: playwright_ui")
|
||||
ui_env = env.copy()
|
||||
ui_env.setdefault("NEXUS_E2E_BASE", base)
|
||||
ui_env.setdefault("NEXUS_E2E_USER", os.environ.get("NEXUS_TEST_ADMIN_USER", "admin"))
|
||||
if not ui_env.get("NEXUS_E2E_PASSWORD"):
|
||||
ui_env["NEXUS_E2E_PASSWORD"] = os.environ.get("NEXUS_TEST_ADMIN_PASSWORD", "")
|
||||
ui_checks, code = _run_playwright(ui_env)
|
||||
all_checks.extend(ui_checks)
|
||||
phases["playwright_ui"] = {"exit_code": code, "checks": len(ui_checks)}
|
||||
exit_codes.append(code)
|
||||
|
||||
rollups = _rollup_features(all_checks)
|
||||
passed = sum(1 for c in all_checks if c.status == "pass")
|
||||
failed = sum(1 for c in all_checks if c.status == "fail")
|
||||
skipped = sum(1 for c in all_checks if c.status == "skip")
|
||||
features_pass = sum(1 for r in rollups if r.status == "pass")
|
||||
features_fail = sum(1 for r in rollups if r.status == "fail")
|
||||
features_total = sum(
|
||||
1 for r in rollups if r.status in ("pass", "fail") and (r.passed or r.failed)
|
||||
)
|
||||
|
||||
summary = {
|
||||
"passed_checks": passed,
|
||||
"failed_checks": failed,
|
||||
"skipped_checks": skipped,
|
||||
"features_pass": features_pass,
|
||||
"features_fail": features_fail,
|
||||
"features_total": features_total,
|
||||
"ok": failed == 0 and all(ec == 0 for ec in exit_codes),
|
||||
}
|
||||
|
||||
def _rollup_dict(r: FeatureRollup) -> dict:
|
||||
d = asdict(r)
|
||||
d["checks"] = [asdict(c) for c in r.checks]
|
||||
return d
|
||||
|
||||
report = AcceptanceReport(
|
||||
started_at=started,
|
||||
base_url=base,
|
||||
phases=phases,
|
||||
features=[_rollup_dict(r) for r in rollups],
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
_print_report(rollups, summary)
|
||||
|
||||
if args.json_path:
|
||||
out_path = Path(args.json_path)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = asdict(report)
|
||||
payload["checks"] = [asdict(c) for c in all_checks]
|
||||
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"\nJSON 报告: {out_path}")
|
||||
|
||||
if not summary["ok"]:
|
||||
print("\n验收未通过:请根据上方 [FAIL] 项修复后重试。")
|
||||
return 1
|
||||
print("\n验收通过:各已测功能表现正常。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
清除登录防暴破锁定(删除 login_attempts 中近期失败记录)。
|
||||
|
||||
锁定规则见 server/application/services/auth_service.py:
|
||||
MAX_LOGIN_FAILURES=5,LOCKOUT_MINUTES=15(按 username + ip_address 统计)
|
||||
|
||||
用法:
|
||||
python scripts/unlock_login_lockout.py
|
||||
python scripts/unlock_login_lockout.py --username admin
|
||||
python scripts/unlock_login_lockout.py --all-failures # 删除该用户全部失败记录
|
||||
|
||||
需在仓库根目录有 .env(含 NEXUS_DATABASE_URL 或 DATABASE_URL),或在生产机部署目录执行。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
env_path = ROOT / ".env"
|
||||
if not env_path.is_file():
|
||||
return
|
||||
for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip().strip("\"'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _parse_database_url(url: str) -> dict:
|
||||
"""Parse mysql+aiomysql://user:pass@host:port/db"""
|
||||
u = urlparse(url.replace("mysql+aiomysql://", "mysql://", 1))
|
||||
return {
|
||||
"host": u.hostname or "127.0.0.1",
|
||||
"port": u.port or 3306,
|
||||
"user": unquote(u.username or ""),
|
||||
"password": unquote(u.password or ""),
|
||||
"db": (u.path or "/").lstrip("/") or "nexus",
|
||||
}
|
||||
|
||||
|
||||
async def _unlock(username: str, all_failures: bool) -> int:
|
||||
import aiomysql
|
||||
|
||||
raw = (
|
||||
os.environ.get("NEXUS_DATABASE_URL")
|
||||
or os.environ.get("DATABASE_URL")
|
||||
or ""
|
||||
)
|
||||
if not raw:
|
||||
print("ERROR: 未找到 NEXUS_DATABASE_URL / DATABASE_URL,请在 .env 中配置或在生产目录执行", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
cfg = _parse_database_url(raw)
|
||||
conn = await aiomysql.connect(
|
||||
host=cfg["host"],
|
||||
port=int(cfg["port"]),
|
||||
user=cfg["user"],
|
||||
password=cfg["password"],
|
||||
db=cfg["db"],
|
||||
connect_timeout=20,
|
||||
)
|
||||
try:
|
||||
async with conn.cursor() as cur:
|
||||
if all_failures:
|
||||
await cur.execute(
|
||||
"DELETE FROM login_attempts WHERE username = %s AND success = 0",
|
||||
(username,),
|
||||
)
|
||||
else:
|
||||
await cur.execute(
|
||||
"""
|
||||
DELETE FROM login_attempts
|
||||
WHERE username = %s AND success = 0
|
||||
AND attempted_at >= UTC_TIMESTAMP() - INTERVAL 20 MINUTE
|
||||
""",
|
||||
(username,),
|
||||
)
|
||||
deleted = cur.rowcount
|
||||
await conn.commit()
|
||||
print(f"OK: 已删除用户「{username}」失败登录记录 {deleted} 条,锁定已解除。")
|
||||
return 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="解除 Nexus 登录防暴破锁定")
|
||||
parser.add_argument("--username", default="admin", help="管理员用户名(默认 admin)")
|
||||
parser.add_argument(
|
||||
"--all-failures",
|
||||
action="store_true",
|
||||
help="删除该用户全部历史失败记录(默认仅近 20 分钟)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
_load_dotenv()
|
||||
return asyncio.run(_unlock(args.username, args.all_failures))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+1
-1
@@ -301,7 +301,7 @@ async def change_password(
|
||||
|
||||
current_admin.password_hash = new_hash
|
||||
# Security: invalidate all existing sessions (refresh tokens + JWT token_version)
|
||||
current_admin.token_version += 1
|
||||
current_admin.token_version = (current_admin.token_version or 0) + 1
|
||||
await admin_repo.update(current_admin)
|
||||
|
||||
# Clear all refresh tokens from Redis (all devices logged out)
|
||||
|
||||
+10
-7
@@ -636,23 +636,26 @@ async def lock_install():
|
||||
"""
|
||||
_reject_if_installed()
|
||||
|
||||
# Verify admin account exists before allowing lock
|
||||
# Verify at least one active admin account exists before allowing lock
|
||||
try:
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
from sqlalchemy import text as _text
|
||||
async with AsyncSessionLocal() as session:
|
||||
repo = AdminRepositoryImpl(session)
|
||||
admin = await repo.get_by_username("admin")
|
||||
if not admin:
|
||||
result = await session.execute(
|
||||
_text("SELECT COUNT(*) FROM admins WHERE is_active = 1")
|
||||
)
|
||||
count = result.scalar() or 0
|
||||
if count == 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="无法锁定:管理员账户尚未创建。请先完成安装步骤4。",
|
||||
detail="无法锁定:尚无活跃管理员账户。请先完成安装步骤4。",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("Failed to verify admin account before locking: %s", e)
|
||||
# If DB is unreachable, allow lock (admin may have been created but DB is temporarily down)
|
||||
# If DB is temporarily unreachable, conservatively reject lock
|
||||
raise HTTPException(status_code=503, detail="数据库暂时不可用,无法验证管理员账户,请稍后重试。")
|
||||
|
||||
# Create lock marker file
|
||||
INSTALL_LOCK.write_text(
|
||||
|
||||
+97
-1
@@ -5,6 +5,7 @@ Live server status comes from Redis (real-time), base info from MySQL.
|
||||
All write operations require JWT authentication and produce audit logs.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
@@ -205,6 +206,8 @@ async def list_servers(
|
||||
# changed a server's status vs what MySQL reported. Remove mismatches.
|
||||
if is_online is not None:
|
||||
result = [s for s in result if s.get("is_online") == is_online]
|
||||
# Recalculate total/pages to reflect actual filtered count
|
||||
total = len(result)
|
||||
# Trim to per_page (we over-fetched from DB to compensate)
|
||||
result = result[:per_page]
|
||||
|
||||
@@ -213,7 +216,7 @@ async def list_servers(
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
"pages": (total + per_page - 1) // per_page if total > 0 else 1,
|
||||
}
|
||||
|
||||
|
||||
@@ -951,6 +954,99 @@ async def get_files_capability(
|
||||
return await probe_files_capability(server)
|
||||
|
||||
|
||||
@router.post("/{id}/setup-files-sudo", response_model=dict)
|
||||
async def setup_files_sudo(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
):
|
||||
"""Auto-configure passwordless sudoers for file manager on non-root servers.
|
||||
|
||||
Uses the stored SSH password to run `sudo -S` and write
|
||||
/etc/sudoers.d/nexus-files on the target server.
|
||||
Requires the SSH user to have sudo access with password.
|
||||
"""
|
||||
import shlex
|
||||
import textwrap
|
||||
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
|
||||
server = await service.get_server(id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
ssh_user = (server.username or "root").strip() or "root"
|
||||
if ssh_user == "root":
|
||||
return {"ok": True, "message": "root 用户无需配置 sudoers,已拥有完整权限"}
|
||||
|
||||
if not server.password:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="该服务器未存储 SSH 密码,无法自动配置。请手动在目标机配置 sudoers。",
|
||||
)
|
||||
|
||||
plain_password = decrypt_value(server.password)
|
||||
sudoers_content = textwrap.dedent(f"""\
|
||||
# nexus-files: auto-configured by Nexus file manager
|
||||
{ssh_user} ALL=(root) NOPASSWD: \\
|
||||
/bin/ls, /usr/bin/ls, \\
|
||||
/bin/cat, /usr/bin/cat, \\
|
||||
/usr/bin/tee, /bin/tee, \\
|
||||
/bin/mkdir, /usr/bin/mkdir, \\
|
||||
/bin/cp, /usr/bin/cp, \\
|
||||
/bin/mv, /usr/bin/mv, \\
|
||||
/bin/rm, /usr/bin/rm, \\
|
||||
/bin/chmod, /usr/bin/chmod, \\
|
||||
/bin/chown, /usr/bin/chown, \\
|
||||
/bin/bash, /usr/bin/bash
|
||||
""")
|
||||
|
||||
sudoers_path = "/etc/sudoers.d/nexus-files"
|
||||
# Use heredoc via sudo -S to avoid exposing password in command arguments
|
||||
write_cmd = (
|
||||
f"printf %s {shlex.quote(sudoers_content)}"
|
||||
f" | sudo -S tee {shlex.quote(sudoers_path)} > /dev/null"
|
||||
f" && sudo chmod 440 {shlex.quote(sudoers_path)}"
|
||||
f" && sudo visudo -cf {shlex.quote(sudoers_path)}"
|
||||
)
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = await ssh_pool.acquire(server)
|
||||
# Feed password via stdin for sudo -S (avoids exposing password in process args)
|
||||
result = await asyncio.wait_for(
|
||||
conn.run(write_cmd, input=plain_password + "\n", timeout=15),
|
||||
timeout=20,
|
||||
)
|
||||
exit_code = result.exit_status if result.exit_status is not None else -1
|
||||
stderr_out = (result.stderr or "").strip()
|
||||
stdout_out = (result.stdout or "").strip()
|
||||
if exit_code != 0:
|
||||
detail = stderr_out or stdout_out or f"命令退出码 {exit_code}"
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"sudoers 写入失败:{detail[:300]}",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"SSH 执行失败:{exc}",
|
||||
) from exc
|
||||
finally:
|
||||
if conn is not None:
|
||||
await ssh_pool.release(server.id)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"message": (
|
||||
f"sudoers 已配置:{sudoers_path}。"
|
||||
"现在文件管理器可使用 sudo -n 自动提权浏览/操作文件。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/", response_model=dict, status_code=201)
|
||||
async def create_server(
|
||||
request: Request,
|
||||
|
||||
@@ -1370,6 +1370,9 @@ def _audit_to_dict(log: AuditLog) -> dict:
|
||||
"action": log.action,
|
||||
"target_type": log.target_type,
|
||||
"target_id": log.target_id,
|
||||
# 前端历史字段别名
|
||||
"resource_type": log.target_type,
|
||||
"resource_id": log.target_id,
|
||||
"detail": log.detail,
|
||||
"ip_address": log.ip_address,
|
||||
"created_at": str(log.created_at) if log.created_at else None,
|
||||
|
||||
+28
-10
@@ -268,21 +268,25 @@ async def browse_directory(
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
import shlex
|
||||
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
|
||||
from server.utils.unix_ls import parse_ls_la_line, perms_to_mode_octal
|
||||
|
||||
path_q = shlex.quote(path)
|
||||
result = await exec_ssh_command(
|
||||
# 与写文件/chmod 一致:非 root SSH 在 Permission denied 时按 files_elevation 尝试 sudo -n
|
||||
result = await exec_ssh_command_with_fallback(
|
||||
server,
|
||||
f"ls -la --time-style=long-iso {path_q}",
|
||||
timeout=10,
|
||||
)
|
||||
if result["exit_code"] != 0:
|
||||
result = await exec_ssh_command(server, f"ls -la {path_q}", timeout=10)
|
||||
result = await exec_ssh_command_with_fallback(
|
||||
server, f"ls -la {path_q}", timeout=10
|
||||
)
|
||||
|
||||
await _audit_sync("browse_directory", "server", server_id, f"Browse {server.name}:{path}", admin.username, request)
|
||||
|
||||
if result["exit_code"] != 0:
|
||||
return {"path": path, "items": [], "error": result["stderr"][:500]}
|
||||
return {"path": path, "items": [], "error": (result.get("stderr") or "")[:500]}
|
||||
|
||||
raw_entries = []
|
||||
for line in result["stdout"].split("\n"):
|
||||
@@ -644,7 +648,7 @@ async def diagnose_push(
|
||||
if r["exit_code"] == 0 and "ok" in r["stdout"]:
|
||||
result["ssh_ok"] = True
|
||||
else:
|
||||
result["errors"].append(f"SSH 连接异常: {r['stderr'][:200] or 'exit code ' + str(r['exit_code'])}")
|
||||
result["errors"].append(f"SSH 连接异常: {(r.get('stderr') or '')[:200] or 'exit code ' + str(r['exit_code'])}")
|
||||
# Cannot continue if SSH fails
|
||||
await _audit_sync("diagnose", "server", payload.server_id, "诊断: SSH不通", admin.username, request)
|
||||
return result
|
||||
@@ -696,7 +700,7 @@ async def diagnose_push(
|
||||
)
|
||||
result["path_writable"] = r["exit_code"] == 0
|
||||
if r["exit_code"] != 0:
|
||||
result["errors"].append(f"目标路径不可写: {r['stderr'][:200]}")
|
||||
result["errors"].append(f"目标路径不可写: {(r.get('stderr') or '')[:200]}")
|
||||
except Exception as e:
|
||||
result["errors"].append(f"写入测试失败: {e}")
|
||||
result["path_writable"] = False
|
||||
@@ -1233,22 +1237,29 @@ async def download_file(
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
conn = await ssh_pool.acquire(server)
|
||||
# NOTE: connection is released INSIDE file_generator (after stream finishes),
|
||||
# not in a finally block here — releasing before the generator runs would close
|
||||
# the SFTP channel mid-download.
|
||||
try:
|
||||
async with sftp_session(conn) as sftp:
|
||||
# Check file exists and size
|
||||
try:
|
||||
stat = await sftp.stat(remote_path)
|
||||
except FileNotFoundError:
|
||||
await ssh_pool.release(server.id)
|
||||
raise HTTPException(status_code=404, detail="文件不存在") from None
|
||||
except Exception as e:
|
||||
await ssh_pool.release(server.id)
|
||||
raise HTTPException(status_code=502, detail=f"无法访问文件: {e}") from e
|
||||
|
||||
if stat.type not in ("regular file", "unknown"):
|
||||
await ssh_pool.release(server.id)
|
||||
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:
|
||||
await ssh_pool.release(server.id)
|
||||
raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB")
|
||||
|
||||
# H-02: Sanitize filename for Content-Disposition (RFC 6266)
|
||||
@@ -1256,9 +1267,13 @@ async def download_file(
|
||||
filename = _re.sub(r'[^\w.\-]', '_', posix_basename(remote_path)) or "download"
|
||||
|
||||
async def file_generator():
|
||||
async with sftp.open(remote_path, "rb") as f:
|
||||
while chunk := await f.read(65536):
|
||||
yield chunk
|
||||
try:
|
||||
async with sftp.open(remote_path, "rb") as f:
|
||||
while chunk := await f.read(65536):
|
||||
yield chunk
|
||||
finally:
|
||||
# Release after stream is fully consumed (or on client disconnect)
|
||||
await ssh_pool.release(server.id)
|
||||
|
||||
await _audit_sync(
|
||||
"file_download", "server", payload.server_id,
|
||||
@@ -1271,8 +1286,11 @@ async def download_file(
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
finally:
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await ssh_pool.release(server.id)
|
||||
raise HTTPException(status_code=502, detail=f"下载失败: {e}") from e
|
||||
|
||||
|
||||
@router.post("/read-file")
|
||||
@@ -1555,7 +1573,7 @@ async def decompress_file(
|
||||
|
||||
result = await exec_ssh_command_with_fallback(server, cmd, timeout=120)
|
||||
if result["exit_code"] != 0:
|
||||
raise HTTPException(status_code=502, detail=f"解压失败: {result['stderr'][:300]}")
|
||||
raise HTTPException(status_code=502, detail=f"解压失败: {(result.get('stderr') or '')[:300]}")
|
||||
|
||||
await _audit_sync(
|
||||
"file_decompress", "server", payload.server_id,
|
||||
|
||||
+63
-14
@@ -28,14 +28,22 @@ class QuickCommandUpdate(BaseModel):
|
||||
cmd: str | None = Field(None, min_length=1, max_length=500, description="命令内容")
|
||||
|
||||
|
||||
class QuickCommandReorder(BaseModel):
|
||||
"""自定义命令 id 列表,按展示顺序(靠前 = 优先级更高)"""
|
||||
ids: list[int] = Field(..., min_length=1)
|
||||
|
||||
|
||||
# ── Built-in commands seed data ──
|
||||
|
||||
# 内置命令 sort_order 从 1000 起,保证自定义命令(0…)始终排在前面
|
||||
BUILTIN_SORT_BASE = 1000
|
||||
|
||||
BUILTIN_COMMANDS = [
|
||||
{"name": "系统状态", "cmd": "systemctl status --no-pager 2>&1 | head -20\r", "sort_order": 1},
|
||||
{"name": "系统日志", "cmd": "journalctl -n 30 --no-pager 2>&1\r", "sort_order": 2},
|
||||
{"name": "磁盘使用", "cmd": "df -h\r", "sort_order": 3},
|
||||
{"name": "内存使用", "cmd": "free -h\r", "sort_order": 4},
|
||||
{"name": "进程列表", "cmd": "ps aux --sort=-%mem 2>&1 | head -15\r", "sort_order": 5},
|
||||
{"name": "系统状态", "cmd": "systemctl status --no-pager 2>&1 | head -20\r", "sort_order": BUILTIN_SORT_BASE + 1},
|
||||
{"name": "系统日志", "cmd": "journalctl -n 30 --no-pager 2>&1\r", "sort_order": BUILTIN_SORT_BASE + 2},
|
||||
{"name": "磁盘使用", "cmd": "df -h\r", "sort_order": BUILTIN_SORT_BASE + 3},
|
||||
{"name": "内存使用", "cmd": "free -h\r", "sort_order": BUILTIN_SORT_BASE + 4},
|
||||
{"name": "进程列表", "cmd": "ps aux --sort=-%mem 2>&1 | head -15\r", "sort_order": BUILTIN_SORT_BASE + 5},
|
||||
]
|
||||
|
||||
|
||||
@@ -67,10 +75,13 @@ async def list_quick_commands(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all quick commands (built-in + custom), sorted by sort_order then id"""
|
||||
"""List all quick commands: 自定义优先(sort_order 小在前),其次内置"""
|
||||
result = await db.execute(
|
||||
select(QuickCommand)
|
||||
.order_by(QuickCommand.sort_order.asc(), QuickCommand.id.asc())
|
||||
select(QuickCommand).order_by(
|
||||
QuickCommand.is_builtin.asc(),
|
||||
QuickCommand.sort_order.asc(),
|
||||
QuickCommand.id.asc(),
|
||||
)
|
||||
)
|
||||
commands = result.scalars().all()
|
||||
return [_qc_to_dict(qc) for qc in commands]
|
||||
@@ -83,16 +94,15 @@ async def create_quick_command(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a custom quick command"""
|
||||
# Get max sort_order for custom commands to append at end
|
||||
"""Create a custom quick command(插入队首:sort_order 比现有自定义最小值再小 1)"""
|
||||
result = await db.execute(
|
||||
select(QuickCommand)
|
||||
select(QuickCommand.sort_order)
|
||||
.where(QuickCommand.is_builtin.is_(False))
|
||||
.order_by(QuickCommand.sort_order.desc())
|
||||
.order_by(QuickCommand.sort_order.asc())
|
||||
.limit(1)
|
||||
)
|
||||
last = result.scalar_one_or_none()
|
||||
next_order = (last.sort_order + 1) if last else (len(BUILTIN_COMMANDS) + 1)
|
||||
min_order = result.scalar_one_or_none()
|
||||
next_order = (min_order - 1) if min_order is not None else 0
|
||||
|
||||
qc = QuickCommand(
|
||||
name=payload.name,
|
||||
@@ -149,6 +159,45 @@ async def update_quick_command(
|
||||
return _qc_to_dict(qc)
|
||||
|
||||
|
||||
@router.put("/quick-commands/reorder", response_model=dict)
|
||||
async def reorder_quick_commands(
|
||||
payload: QuickCommandReorder,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""重排自定义快捷命令顺序(ids[0] 优先级最高)"""
|
||||
for idx, cmd_id in enumerate(payload.ids):
|
||||
result = await db.execute(select(QuickCommand).where(QuickCommand.id == cmd_id))
|
||||
qc = result.scalar_one_or_none()
|
||||
if not qc:
|
||||
raise HTTPException(status_code=404, detail=f"快捷命令 #{cmd_id} 不存在")
|
||||
if qc.is_builtin:
|
||||
raise HTTPException(status_code=403, detail="系统内置命令不可排序")
|
||||
qc.sort_order = idx
|
||||
|
||||
await db.commit()
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="reorder_quick_commands",
|
||||
target_type="quick_command",
|
||||
target_id=0,
|
||||
detail=f"顺序={','.join(str(i) for i in payload.ids)}",
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
result = await db.execute(
|
||||
select(QuickCommand).order_by(
|
||||
QuickCommand.is_builtin.asc(),
|
||||
QuickCommand.sort_order.asc(),
|
||||
QuickCommand.id.asc(),
|
||||
)
|
||||
)
|
||||
return {"items": [_qc_to_dict(qc) for qc in result.scalars().all()]}
|
||||
|
||||
|
||||
@router.delete("/quick-commands/{id}", status_code=204)
|
||||
async def delete_quick_command(
|
||||
id: int,
|
||||
|
||||
@@ -336,8 +336,9 @@ async def _verify_ws_token(token: str):
|
||||
return None
|
||||
|
||||
# Primary: token_version must match (immediate invalidation on password change)
|
||||
token_tv = payload.get("tv", -1)
|
||||
if token_tv != admin.token_version:
|
||||
token_tv = payload.get("tv") or 0
|
||||
admin_tv = admin.token_version or 0
|
||||
if token_tv != admin_tv:
|
||||
return None
|
||||
|
||||
# Secondary: updated_at timestamp (defense in depth)
|
||||
|
||||
@@ -98,6 +98,7 @@ class AuthService:
|
||||
# ── IP allowlist check ──
|
||||
# Only enforced when login_allowlist_enabled = "true"
|
||||
allowlist_on = (getattr(settings, "LOGIN_ALLOWLIST_ENABLED", "false") or "false").lower()
|
||||
is_whitelisted_ip = False
|
||||
if allowlist_on in ("true", "1", "yes", "on") and ip_address and ip_address not in ("", "unknown"):
|
||||
# Combine subscription IPs + manual IPs
|
||||
sub_ips_raw = (getattr(settings, "LOGIN_SUBSCRIPTION_IPS", "") or "").strip()
|
||||
@@ -115,29 +116,38 @@ class AuthService:
|
||||
"reason": "ip_blocked",
|
||||
"message": f"当前 IP ({ip_address}) 不在登录白名单中,请检查代理或联系管理员",
|
||||
}
|
||||
if allowed_ips and _ip_in_allowlist(ip_address, allowed_ips):
|
||||
is_whitelisted_ip = True
|
||||
|
||||
# Check brute-force lockout
|
||||
failures = await self.attempt_repo.count_recent_failures(username, ip_address, LOCKOUT_MINUTES)
|
||||
if failures >= MAX_LOGIN_FAILURES:
|
||||
await self._audit(
|
||||
"login_locked", "admin", 0, f"账号已锁定: {username}",
|
||||
ip_address=ip_address, admin_username=username,
|
||||
)
|
||||
return {"success": False, "reason": "account_locked", "message": "登录尝试过多,请15分钟后重试"}
|
||||
# Check brute-force lockout (trusted whitelist IPs skip lockout counting)
|
||||
if not is_whitelisted_ip:
|
||||
failures = await self.attempt_repo.count_recent_failures(username, ip_address, LOCKOUT_MINUTES)
|
||||
if failures >= MAX_LOGIN_FAILURES:
|
||||
await self._audit(
|
||||
"login_locked", "admin", 0, f"账号已锁定: {username}",
|
||||
ip_address=ip_address, admin_username=username,
|
||||
)
|
||||
return {"success": False, "reason": "account_locked", "message": "登录尝试过多,请15分钟后重试"}
|
||||
|
||||
# Find admin
|
||||
admin = await self.admin_repo.get_by_username(username)
|
||||
if not admin:
|
||||
await self._record_attempt(username, ip_address, False)
|
||||
await self._handle_login_failure(
|
||||
username, ip_address, is_whitelisted_ip, "login_failed", "用户名不存在",
|
||||
)
|
||||
return {"success": False, "reason": "invalid_credentials", "message": "用户名或密码错误"}
|
||||
|
||||
if not admin.is_active:
|
||||
await self._record_attempt(username, ip_address, False)
|
||||
await self._handle_login_failure(
|
||||
username, ip_address, is_whitelisted_ip, "login_failed", "账户已禁用",
|
||||
)
|
||||
return {"success": False, "reason": "account_disabled", "message": "账户已被禁用"}
|
||||
|
||||
# Verify password
|
||||
if not self._verify_password(password, admin.password_hash):
|
||||
await self._record_attempt(username, ip_address, False)
|
||||
await self._handle_login_failure(
|
||||
username, ip_address, is_whitelisted_ip, "login_failed", "密码错误",
|
||||
)
|
||||
return {"success": False, "reason": "invalid_credentials", "message": "用户名或密码错误"}
|
||||
|
||||
# Verify TOTP (if enabled)
|
||||
@@ -145,7 +155,9 @@ class AuthService:
|
||||
if not totp_code:
|
||||
return {"success": False, "reason": "totp_required", "message": "需要TOTP验证码"}
|
||||
if not self._verify_totp(totp_code, admin.totp_secret):
|
||||
await self._record_attempt(username, ip_address, False)
|
||||
await self._handle_login_failure(
|
||||
username, ip_address, is_whitelisted_ip, "login_failed", "TOTP验证码错误",
|
||||
)
|
||||
return {"success": False, "reason": "invalid_totp", "message": "TOTP验证码错误"}
|
||||
|
||||
# Success — update admin first (so JWT captures latest updated_at/token_version)
|
||||
@@ -159,6 +171,8 @@ class AuthService:
|
||||
# Store refresh token in Redis (supports multi-device login)
|
||||
await self._store_refresh_token(admin.id, refresh_token)
|
||||
|
||||
if is_whitelisted_ip:
|
||||
await self.attempt_repo.clear_failures_for_username(username)
|
||||
await self._record_attempt(username, ip_address, True)
|
||||
await self._audit(
|
||||
"login_success", "admin", admin.id, f"登录: {username}",
|
||||
@@ -283,7 +297,7 @@ class AuthService:
|
||||
"""
|
||||
admin = await self.admin_repo.get_by_id(admin_id)
|
||||
if admin:
|
||||
admin.token_version += 1 # Invalidate all existing tokens
|
||||
admin.token_version = (admin.token_version or 0) + 1 # Invalidate all existing tokens
|
||||
await self.admin_repo.update(admin)
|
||||
await self._delete_all_refresh_tokens(admin.id)
|
||||
return {"success": True, "message": "已登出所有设备"}
|
||||
@@ -378,7 +392,7 @@ class AuthService:
|
||||
|
||||
admin.totp_enabled = False
|
||||
admin.totp_secret = None
|
||||
admin.token_version += 1
|
||||
admin.token_version = (admin.token_version or 0) + 1
|
||||
await self.admin_repo.update(admin)
|
||||
# Invalidate all refresh tokens (all devices must re-login)
|
||||
await self._delete_all_refresh_tokens(admin.id)
|
||||
@@ -481,7 +495,10 @@ class AuthService:
|
||||
|
||||
def _verify_password(self, password: str, password_hash: str) -> bool:
|
||||
"""Verify password against stored bcrypt hash"""
|
||||
return bcrypt.checkpw(password.encode(), password_hash.encode())
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode(), password_hash.encode())
|
||||
except (ValueError, Exception):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _verify_totp(code: str, secret: str) -> bool:
|
||||
@@ -504,6 +521,24 @@ class AuthService:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _handle_login_failure(
|
||||
self,
|
||||
username: str,
|
||||
ip_address: str,
|
||||
is_whitelisted_ip: bool,
|
||||
audit_action: str,
|
||||
audit_detail: str,
|
||||
) -> None:
|
||||
"""Record failed login: audit always for whitelist IPs; brute-force count only otherwise."""
|
||||
if is_whitelisted_ip:
|
||||
await self._audit(
|
||||
audit_action, "admin", 0,
|
||||
f"{audit_detail} (用户: {username}, 白名单IP)",
|
||||
ip_address=ip_address, admin_username=username,
|
||||
)
|
||||
else:
|
||||
await self._record_attempt(username, ip_address, False)
|
||||
|
||||
async def _record_attempt(self, username: str, ip_address: str, success: bool):
|
||||
"""Record login attempt for brute-force tracking"""
|
||||
attempt = LoginAttempt(username=username, ip_address=ip_address, success=success)
|
||||
|
||||
@@ -149,30 +149,42 @@ class SyncEngineV2:
|
||||
sync_log.duration_seconds = int(
|
||||
(sync_log.finished_at - sync_log.started_at).total_seconds()
|
||||
) if sync_log.started_at else 0
|
||||
sync_log.error_message = result["stderr"][:1000] if result["exit_code"] != 0 else None
|
||||
sync_log.error_message = (result.get("stderr") or "")[:1000] if result["exit_code"] != 0 else None
|
||||
sync_log.diff_summary = result["stdout"][:2000] if result["exit_code"] == 0 else None
|
||||
sync_log = await self.sync_log_repo.update(sync_log)
|
||||
|
||||
# Auto-create retry job for failed pushes
|
||||
# Auto-create retry job for failed pushes (dedup: one pending per server+path)
|
||||
retry_job_id = None
|
||||
if sync_log.status == "failed":
|
||||
try:
|
||||
from server.domain.models import PushRetryJob
|
||||
retry_job = PushRetryJob(
|
||||
server_id=server.id,
|
||||
server_name=server.name,
|
||||
operator=operator,
|
||||
source_path=source_path,
|
||||
target_path=dest,
|
||||
status="pending",
|
||||
retry_count=0,
|
||||
max_retries=3,
|
||||
next_retry_at=datetime.now(timezone.utc),
|
||||
last_error=sync_log.error_message[:500] if sync_log.error_message else None,
|
||||
from sqlalchemy import select as _select
|
||||
existing = await self.retry_repo.session.execute(
|
||||
_select(PushRetryJob).where(
|
||||
PushRetryJob.server_id == server.id,
|
||||
PushRetryJob.source_path == source_path,
|
||||
PushRetryJob.target_path == dest,
|
||||
PushRetryJob.status == "pending",
|
||||
)
|
||||
)
|
||||
retry_job = await self.retry_repo.create(retry_job)
|
||||
retry_job_id = retry_job.id
|
||||
logger.info(f"Auto-created retry job #{retry_job.id} for server {server.id}")
|
||||
if existing.scalars().first() is None:
|
||||
retry_job = PushRetryJob(
|
||||
server_id=server.id,
|
||||
server_name=server.name,
|
||||
operator=operator,
|
||||
source_path=source_path,
|
||||
target_path=dest,
|
||||
status="pending",
|
||||
retry_count=0,
|
||||
max_retries=3,
|
||||
next_retry_at=datetime.now(timezone.utc),
|
||||
last_error=sync_log.error_message[:500] if sync_log.error_message else None,
|
||||
)
|
||||
retry_job = await self.retry_repo.create(retry_job)
|
||||
retry_job_id = retry_job.id
|
||||
logger.info(f"Auto-created retry job #{retry_job.id} for server {server.id}")
|
||||
else:
|
||||
logger.debug(f"Retry job already pending for server {server.id}, skipping duplicate")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create retry job for server {server.id}: {e}")
|
||||
|
||||
@@ -274,7 +286,7 @@ class SyncEngineV2:
|
||||
return {
|
||||
"server_id": server_id,
|
||||
"server_name": server.name,
|
||||
"error": result["stderr"][:500] or f"rsync exited with code {result['exit_code']}",
|
||||
"error": (result.get("stderr") or "")[:500] or f"rsync exited with code {result['exit_code']}",
|
||||
"exit_code": result["exit_code"],
|
||||
}
|
||||
|
||||
|
||||
@@ -53,45 +53,44 @@ async def heartbeat_flush_loop():
|
||||
continue
|
||||
|
||||
flushed = 0
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
for key in keys:
|
||||
skipped = 0
|
||||
for key in keys:
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
server_id = int(key.replace(REDIS_KEY_PREFIX, ""))
|
||||
data = await redis.hgetall(key)
|
||||
|
||||
if not data:
|
||||
continue
|
||||
|
||||
is_online = data.get("is_online") == "True"
|
||||
system_info = data.get("system_info", "{}")
|
||||
agent_version = data.get("agent_version", "")
|
||||
|
||||
# Parse last_heartbeat — ensure timezone-aware
|
||||
last_hb_str = data.get("last_heartbeat", "")
|
||||
try:
|
||||
server_id = int(key.replace(REDIS_KEY_PREFIX, ""))
|
||||
data = await redis.hgetall(key)
|
||||
last_heartbeat = datetime.fromisoformat(last_hb_str)
|
||||
if last_heartbeat.tzinfo is None:
|
||||
last_heartbeat = last_heartbeat.replace(tzinfo=timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
last_heartbeat = datetime.now(timezone.utc)
|
||||
|
||||
if not data:
|
||||
continue
|
||||
|
||||
is_online = data.get("is_online") == "True"
|
||||
system_info = data.get("system_info", "{}")
|
||||
agent_version = data.get("agent_version", "")
|
||||
|
||||
# Parse last_heartbeat
|
||||
last_hb_str = data.get("last_heartbeat", "")
|
||||
try:
|
||||
last_heartbeat = datetime.fromisoformat(last_hb_str)
|
||||
except (ValueError, TypeError):
|
||||
last_heartbeat = datetime.now(timezone.utc)
|
||||
|
||||
await session.execute(
|
||||
update(Server)
|
||||
.where(Server.id == server_id)
|
||||
.values(
|
||||
is_online=is_online,
|
||||
system_info=system_info,
|
||||
last_heartbeat=last_heartbeat,
|
||||
agent_version=agent_version,
|
||||
)
|
||||
await session.execute(
|
||||
update(Server)
|
||||
.where(Server.id == server_id)
|
||||
.values(
|
||||
is_online=is_online,
|
||||
system_info=system_info,
|
||||
last_heartbeat=last_heartbeat,
|
||||
agent_version=agent_version,
|
||||
)
|
||||
flushed += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to flush server {key}: {e}")
|
||||
# Don't rollback here — just skip this server and continue.
|
||||
# A partial UPDATE on a single server won't corrupt others.
|
||||
# The final commit() will persist all successful updates.
|
||||
)
|
||||
await session.commit()
|
||||
flushed += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to flush server {key}: {e}")
|
||||
skipped += 1
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"Heartbeat flush: {flushed}/{len(keys)} servers updated to MySQL")
|
||||
except Exception as e:
|
||||
logger.error(f"Heartbeat flush batch failed: {e}")
|
||||
logger.info(f"Heartbeat flush: {flushed}/{len(keys)} servers updated to MySQL" +
|
||||
(f", {skipped} failed" if skipped else ""))
|
||||
@@ -67,6 +67,10 @@ async def retry_runner_loop():
|
||||
if sync_result["failed"] == 0:
|
||||
job.status = "completed"
|
||||
logger.info(f"Retry job {job.id}: success after {job.retry_count} attempts")
|
||||
elif job.retry_count >= job.max_retries:
|
||||
job.status = "failed"
|
||||
job.last_error = f"Retry exhausted after {job.retry_count} attempts"
|
||||
logger.warning(f"Retry job {job.id}: max retries reached, marking failed")
|
||||
else:
|
||||
# Still failing — schedule next retry with backoff
|
||||
backoff = RETRY_BACKOFF_BASE * (2 ** min(job.retry_count - 1, 6))
|
||||
@@ -83,9 +87,13 @@ async def retry_runner_loop():
|
||||
except Exception as e:
|
||||
logger.error(f"Retry job {job.id} execution error: {e}")
|
||||
job.retry_count += 1
|
||||
backoff = RETRY_BACKOFF_BASE * (2 ** min(job.retry_count - 1, 6))
|
||||
job.next_retry_at = now + timedelta(seconds=backoff)
|
||||
job.last_error = str(e)[:500]
|
||||
if job.retry_count >= job.max_retries:
|
||||
job.status = "failed"
|
||||
logger.warning(f"Retry job {job.id}: max retries reached (exception), marking failed")
|
||||
else:
|
||||
backoff = RETRY_BACKOFF_BASE * (2 ** min(job.retry_count - 1, 6))
|
||||
job.next_retry_at = now + timedelta(seconds=backoff)
|
||||
await session.commit()
|
||||
|
||||
if retried:
|
||||
|
||||
@@ -84,9 +84,9 @@ async def schedule_runner_loop():
|
||||
triggered = 0
|
||||
|
||||
for schedule in schedules:
|
||||
try:
|
||||
run_mode = getattr(schedule, 'run_mode', None) or 'cron'
|
||||
run_mode = getattr(schedule, 'run_mode', None) or 'cron'
|
||||
|
||||
try:
|
||||
# ── One-time schedule: check fire_at ──
|
||||
if run_mode == 'once':
|
||||
fire_at = getattr(schedule, 'fire_at', None)
|
||||
@@ -123,91 +123,103 @@ async def schedule_runner_loop():
|
||||
continue
|
||||
|
||||
# Mark as running BEFORE execution to prevent double-trigger
|
||||
# (if execution takes longer than SCHEDULE_CHECK_INTERVAL)
|
||||
# (if execution takes longer than SCHEDULE_CHECK_INTERVAL).
|
||||
# On failure we reset last_run_at so the schedule can retry.
|
||||
prev_last_run_at = schedule.last_run_at
|
||||
schedule.last_run_at = now
|
||||
if run_mode == 'once':
|
||||
schedule.enabled = False
|
||||
await session.commit()
|
||||
|
||||
schedule_type = getattr(schedule, 'schedule_type', 'push') or 'push'
|
||||
execution_ok = True
|
||||
|
||||
if schedule_type == 'script':
|
||||
# ── Script execution schedule ──
|
||||
from server.application.services.script_service import ScriptService
|
||||
from server.infrastructure.database.script_repo import (
|
||||
ScriptRepositoryImpl, ScriptExecutionRepositoryImpl,
|
||||
)
|
||||
from server.infrastructure.database.db_credential_repo import DbCredentialRepositoryImpl
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
try:
|
||||
if schedule_type == 'script':
|
||||
# ── Script execution schedule ──
|
||||
from server.application.services.script_service import ScriptService
|
||||
from server.infrastructure.database.script_repo import (
|
||||
ScriptRepositoryImpl, ScriptExecutionRepositoryImpl,
|
||||
)
|
||||
from server.infrastructure.database.db_credential_repo import DbCredentialRepositoryImpl
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
|
||||
cmd = getattr(schedule, 'script_content', None) or ''
|
||||
script_id = getattr(schedule, 'script_id', None)
|
||||
cmd = getattr(schedule, 'script_content', None) or ''
|
||||
script_id = getattr(schedule, 'script_id', None)
|
||||
|
||||
# If no inline command, load from script library
|
||||
if not cmd and script_id:
|
||||
script = await ScriptRepositoryImpl(session).get_by_id(script_id)
|
||||
if script:
|
||||
cmd = script.content or ''
|
||||
# If no inline command, load from script library
|
||||
if not cmd and script_id:
|
||||
script = await ScriptRepositoryImpl(session).get_by_id(script_id)
|
||||
if script:
|
||||
cmd = script.content or ''
|
||||
|
||||
if not cmd:
|
||||
logger.error(f"Schedule '{schedule.name}': no command or script content, skipping")
|
||||
continue
|
||||
if not cmd:
|
||||
logger.error(f"Schedule '{schedule.name}': no command or script content, skipping")
|
||||
execution_ok = False
|
||||
else:
|
||||
svc = ScriptService(
|
||||
script_repo=ScriptRepositoryImpl(session),
|
||||
execution_repo=ScriptExecutionRepositoryImpl(session),
|
||||
credential_repo=DbCredentialRepositoryImpl(session),
|
||||
server_repo=ServerRepositoryImpl(session),
|
||||
audit_repo=AuditLogRepositoryImpl(session),
|
||||
)
|
||||
timeout = getattr(schedule, 'exec_timeout', None) or 60
|
||||
long_task = bool(getattr(schedule, 'long_task', False))
|
||||
execution = await svc.execute_command(
|
||||
command=cmd,
|
||||
server_ids=server_ids,
|
||||
script_id=script_id,
|
||||
timeout=timeout,
|
||||
operator=f"schedule:{schedule.name}",
|
||||
long_task=long_task,
|
||||
)
|
||||
logger.info(
|
||||
f"Schedule '{schedule.name}' (script) triggered: "
|
||||
f"execution_id={execution.id} on {len(server_ids)} servers"
|
||||
)
|
||||
else:
|
||||
# ── File push schedule (original behaviour) ──
|
||||
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
|
||||
|
||||
svc = ScriptService(
|
||||
script_repo=ScriptRepositoryImpl(session),
|
||||
execution_repo=ScriptExecutionRepositoryImpl(session),
|
||||
credential_repo=DbCredentialRepositoryImpl(session),
|
||||
server_repo=ServerRepositoryImpl(session),
|
||||
audit_repo=AuditLogRepositoryImpl(session),
|
||||
)
|
||||
timeout = getattr(schedule, 'exec_timeout', None) or 60
|
||||
long_task = bool(getattr(schedule, 'long_task', False))
|
||||
execution = await svc.execute_command(
|
||||
command=cmd,
|
||||
server_ids=server_ids,
|
||||
script_id=script_id,
|
||||
timeout=timeout,
|
||||
operator=f"schedule:{schedule.name}",
|
||||
long_task=long_task,
|
||||
)
|
||||
logger.info(
|
||||
f"Schedule '{schedule.name}' (script) triggered: "
|
||||
f"execution_id={execution.id} on {len(server_ids)} servers"
|
||||
)
|
||||
engine = SyncEngineV2(
|
||||
server_repo=ServerRepositoryImpl(session),
|
||||
sync_log_repo=SyncLogRepositoryImpl(session),
|
||||
audit_repo=AuditLogRepositoryImpl(session),
|
||||
retry_repo=PushRetryJobRepositoryImpl(session),
|
||||
)
|
||||
|
||||
result = await engine.sync_files(
|
||||
server_ids=server_ids,
|
||||
source_path=schedule.source_path or '/tmp',
|
||||
sync_mode=getattr(schedule, 'sync_mode', None) or "incremental",
|
||||
trigger_type="schedule",
|
||||
operator=f"schedule:{schedule.name}",
|
||||
)
|
||||
logger.info(
|
||||
f"Schedule '{schedule.name}' (push) triggered: "
|
||||
f"{result['completed']}/{result['total']} succeeded"
|
||||
)
|
||||
except Exception as exec_err:
|
||||
logger.error(f"Schedule {schedule.id} execution failed: {exec_err}")
|
||||
execution_ok = False
|
||||
|
||||
if not execution_ok and run_mode != 'once':
|
||||
# Roll back last_run_at so the schedule can re-fire next cycle
|
||||
schedule.last_run_at = prev_last_run_at
|
||||
await session.commit()
|
||||
else:
|
||||
# ── File push schedule (original behaviour) ──
|
||||
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
|
||||
await session.commit()
|
||||
|
||||
engine = SyncEngineV2(
|
||||
server_repo=ServerRepositoryImpl(session),
|
||||
sync_log_repo=SyncLogRepositoryImpl(session),
|
||||
audit_repo=AuditLogRepositoryImpl(session),
|
||||
retry_repo=PushRetryJobRepositoryImpl(session),
|
||||
)
|
||||
|
||||
result = await engine.sync_files(
|
||||
server_ids=server_ids,
|
||||
source_path=schedule.source_path or '/tmp',
|
||||
sync_mode=getattr(schedule, 'sync_mode', None) or "incremental",
|
||||
trigger_type="schedule",
|
||||
operator=f"schedule:{schedule.name}",
|
||||
)
|
||||
logger.info(
|
||||
f"Schedule '{schedule.name}' (push) triggered: "
|
||||
f"{result['completed']}/{result['total']} succeeded"
|
||||
)
|
||||
|
||||
# last_run_at + enabled already set before execution (prevent double-trigger)
|
||||
await session.commit()
|
||||
triggered += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Schedule {schedule.id} execution failed: {e}")
|
||||
logger.error(f"Schedule {schedule.id} setup/parse failed: {e}")
|
||||
|
||||
if triggered:
|
||||
logger.info(f"Schedule runner: {triggered} schedules triggered")
|
||||
|
||||
@@ -47,6 +47,7 @@ class LoginAttemptRepository(Protocol):
|
||||
|
||||
async def create(self, attempt: LoginAttempt) -> LoginAttempt: ...
|
||||
async def count_recent_failures(self, username: str, ip_address: str, minutes: int = 15) -> int: ...
|
||||
async def clear_failures_for_username(self, username: str) -> int: ...
|
||||
|
||||
|
||||
class SettingRepository(Protocol):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Nexus — Admin & LoginAttempt Repository (Async SQLAlchemy)"""
|
||||
|
||||
from typing import Optional
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import select, func, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.domain.models import Admin, LoginAttempt
|
||||
@@ -41,11 +41,22 @@ class LoginAttemptRepositoryImpl:
|
||||
async def count_recent_failures(self, username: str, ip_address: str, minutes: int = 15) -> int:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
|
||||
# Lock by username (primary) OR by IP (secondary) — whichever is over threshold.
|
||||
# Using IP-only for counting means an attacker can rotate IPs to bypass;
|
||||
# we count all IPs that attempted this username so rotating IPs doesn't help.
|
||||
result = await self.session.execute(
|
||||
select(func.count(LoginAttempt.id))
|
||||
.where(LoginAttempt.username == username)
|
||||
.where(LoginAttempt.ip_address == ip_address)
|
||||
.where(LoginAttempt.success == False)
|
||||
.where(LoginAttempt.attempted_at >= cutoff)
|
||||
)
|
||||
return result.scalar_one() or 0
|
||||
return result.scalar_one() or 0
|
||||
|
||||
async def clear_failures_for_username(self, username: str) -> int:
|
||||
result = await self.session.execute(
|
||||
delete(LoginAttempt)
|
||||
.where(LoginAttempt.username == username)
|
||||
.where(LoginAttempt.success == False)
|
||||
)
|
||||
await self.session.commit()
|
||||
return result.rowcount or 0
|
||||
@@ -392,22 +392,25 @@ async def exec_ssh_command(server: Server, command: str, timeout: int = 30) -> d
|
||||
W2: Replaces paramiko-based exec_command from pool.py.
|
||||
Returns structured result dict with stdout/stderr/exit_code.
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
conn = await ssh_pool.acquire(server)
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
conn.run(command, timeout=timeout),
|
||||
timeout=timeout + 5,
|
||||
)
|
||||
return {
|
||||
"status": "success" if result.exit_status == 0 else "failed",
|
||||
"stdout": result.stdout[:10000],
|
||||
"stderr": result.stderr[:10000],
|
||||
"exit_code": result.exit_status,
|
||||
}
|
||||
finally:
|
||||
await ssh_pool.release(server.id)
|
||||
result = await asyncio.wait_for(
|
||||
conn.run(command, timeout=timeout),
|
||||
timeout=timeout + 5,
|
||||
)
|
||||
await ssh_pool.release(server.id)
|
||||
return {
|
||||
"status": "success" if result.exit_status == 0 else "failed",
|
||||
"stdout": result.stdout[:10000],
|
||||
"stderr": result.stderr[:10000],
|
||||
"exit_code": result.exit_status,
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
# Timed-out connection may be in an unknown state — evict so it is NOT
|
||||
# returned to the pool and potentially reused for a future command.
|
||||
if conn is not None:
|
||||
await ssh_pool.close_connection(server.id)
|
||||
return {
|
||||
"status": "timeout",
|
||||
"stdout": "",
|
||||
@@ -415,6 +418,8 @@ async def exec_ssh_command(server: Server, command: str, timeout: int = 30) -> d
|
||||
"exit_code": -1,
|
||||
}
|
||||
except ConnectionError as e:
|
||||
if conn is not None:
|
||||
await ssh_pool.close_connection(server.id)
|
||||
return {
|
||||
"status": "connection_error",
|
||||
"stdout": "",
|
||||
@@ -422,6 +427,8 @@ async def exec_ssh_command(server: Server, command: str, timeout: int = 30) -> d
|
||||
"exit_code": -1,
|
||||
}
|
||||
except Exception as e:
|
||||
if conn is not None:
|
||||
await ssh_pool.release(server.id)
|
||||
return {
|
||||
"status": "error",
|
||||
"stdout": "",
|
||||
|
||||
+32
-4
@@ -20,18 +20,46 @@ def modified_from_ls_parts(parts: list[str]) -> str:
|
||||
|
||||
|
||||
def parse_ls_la_line(line: str) -> dict | None:
|
||||
"""Parse one ``ls -la`` line; returns None for total/skip lines."""
|
||||
"""Parse one ``ls -la`` line; returns None for total/skip lines.
|
||||
|
||||
Handles two timestamp formats produced by different ``ls`` implementations:
|
||||
- Standard (3-token date): ``perms nlinks owner group size Mon DD HH:MM name`` → 9+ fields
|
||||
- GNU long-iso (2-token): ``perms nlinks owner group size YYYY-MM-DD HH:MM name`` → 8+ fields
|
||||
|
||||
With ``--time-style=long-iso`` the date collapses from 3 tokens to 2, so
|
||||
the total token count drops from 9 to 8. Directories and regular files were
|
||||
silently dropped (only symlinks survived because their names contain
|
||||
``" -> target"`` which pushed the count back to 9).
|
||||
"""
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("total "):
|
||||
return None
|
||||
|
||||
# Split into at most 9 pieces so filenames with spaces are kept intact.
|
||||
parts = stripped.split(None, 8)
|
||||
if len(parts) < 9:
|
||||
if len(parts) < 8:
|
||||
return None
|
||||
|
||||
perms = parts[0]
|
||||
is_dir = perms.startswith("d")
|
||||
is_link = perms.startswith("l")
|
||||
name = parts[8]
|
||||
|
||||
# Detect format by checking whether field[5] is an ISO date (YYYY-MM-DD).
|
||||
# long-iso: [perms nlinks owner group size DATE TIME name] → name at index 7
|
||||
# standard: [perms nlinks owner group size MON DAY TIME name] → name at index 8
|
||||
if _ISO_DATE.match(parts[5]):
|
||||
# long-iso format — name is at index 7 (or rest of string)
|
||||
if len(parts) < 8:
|
||||
return None
|
||||
name = parts[7]
|
||||
modified = f"{parts[5]} {parts[6].split('.', 1)[0]}"
|
||||
else:
|
||||
# standard format — need at least 9 tokens for the name at index 8
|
||||
if len(parts) < 9:
|
||||
return None
|
||||
name = parts[8]
|
||||
modified = modified_from_ls_parts(parts)
|
||||
|
||||
link_target = None
|
||||
if is_link and " -> " in name:
|
||||
name, link_target = name.split(" -> ", 1)
|
||||
@@ -47,7 +75,7 @@ def parse_ls_la_line(line: str) -> dict | None:
|
||||
"perms": perms,
|
||||
"owner": parts[2],
|
||||
"group": parts[3] if len(parts) > 3 else "",
|
||||
"modified": modified_from_ls_parts(parts),
|
||||
"modified": modified,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Nexus 功能验收目录 — 将自动化检查项映射到产品功能(供 run_nexus_acceptance 使用)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureSpec:
|
||||
id: str
|
||||
name: str
|
||||
layer: str # api | ui | unit | infra
|
||||
description: str
|
||||
keywords: tuple[str, ...] # 匹配子进程输出中的 [PASS]/[FAIL] 行
|
||||
|
||||
|
||||
# 顺序即报告展示顺序
|
||||
FEATURES: tuple[FeatureSpec, ...] = (
|
||||
FeatureSpec("infra_health", "健康检查 /health", "infra", "Shell 守护与负载均衡探活", ("/health", "GET /health")),
|
||||
FeatureSpec("infra_app", "前端静态 /app/", "infra", "SPA 入口可访问", ("/app/", "static")),
|
||||
FeatureSpec("auth", "认证与 JWT", "api", "登录、me、刷新", ("/api/auth/", "Auth")),
|
||||
FeatureSpec("dashboard", "仪表盘统计", "api", "服务器统计与概览", ("/api/servers/stats", "stats")),
|
||||
FeatureSpec("servers", "服务器管理", "api", "CRUD、分类、日志", ("/api/servers/", "Server")),
|
||||
FeatureSpec("files", "文件管理", "api", "browse、files-capability", ("browse", "files-capability", "sync/")),
|
||||
FeatureSpec("terminal", "Web 终端", "api", "快捷命令、webssh-token", ("terminal", "webssh", "quick-commands")),
|
||||
FeatureSpec("push_sync", "推送与同步", "api", "validate-source、sync", ("sync", "validate-source", "Push")),
|
||||
FeatureSpec("scripts", "脚本库", "api", "脚本 CRUD、执行记录", ("/api/scripts/", "Scripts")),
|
||||
FeatureSpec("schedules", "调度任务", "api", "调度 CRUD", ("/api/schedules/", "Schedules")),
|
||||
FeatureSpec("credentials", "凭据预设", "api", "密码/SSH 密钥预设", ("/api/presets/", "ssh-key", "Password")),
|
||||
FeatureSpec("retries", "重试队列", "api", "失败任务重试列表", ("/api/retries/", "Retry")),
|
||||
FeatureSpec("audit", "审计日志", "api", "操作审计分页", ("/api/audit/", "Audit")),
|
||||
FeatureSpec("alerts", "告警历史", "api", "告警列表与统计", ("alert-history", "Alerts")),
|
||||
FeatureSpec("assets", "资产管理", "api", "平台/节点/命令日志", ("/api/assets/", "Assets")),
|
||||
FeatureSpec("search", "全局搜索", "api", "跨资源搜索", ("/api/search/", "Search")),
|
||||
FeatureSpec("settings", "系统设置", "api", "动态配置、IP 白名单", ("/api/settings/", "Settings")),
|
||||
FeatureSpec("health_detail", "健康详情", "api", "/health/detail", ("/health/detail",)),
|
||||
FeatureSpec("ui_dashboard", "页面·仪表盘", "ui", "路由 /", ("01 login", "dashboard")),
|
||||
FeatureSpec("ui_servers", "页面·服务器", "ui", "路由 /servers", ("02 servers",)),
|
||||
FeatureSpec("ui_terminal", "页面·终端", "ui", "路由 /terminal", ("04 terminal",)),
|
||||
FeatureSpec("ui_files", "页面·文件", "ui", "路由 /files", ("05 files",)),
|
||||
FeatureSpec("ui_push", "页面·推送", "ui", "路由 /push", ("06 push",)),
|
||||
FeatureSpec("ui_scripts", "页面·脚本", "ui", "路由 /scripts", ("07 scripts",)),
|
||||
FeatureSpec("ui_credentials", "页面·凭据", "ui", "路由 /credentials", ("08 credentials",)),
|
||||
FeatureSpec("ui_schedules", "页面·调度", "ui", "路由 /schedules", ("09 schedules",)),
|
||||
FeatureSpec("ui_retries", "页面·重试", "ui", "路由 /retries", ("10 retries",)),
|
||||
FeatureSpec("ui_commands", "页面·命令日志", "ui", "路由 /commands", ("11 commands",)),
|
||||
FeatureSpec("ui_alerts", "页面·告警", "ui", "路由 /alerts", ("12 alerts",)),
|
||||
FeatureSpec("ui_audit", "页面·审计", "ui", "路由 /audit", ("13 audit",)),
|
||||
FeatureSpec("ui_settings", "页面·设置", "ui", "路由 /settings", ("14 settings",)),
|
||||
FeatureSpec("ui_theme", "页面·主题与登出", "ui", "主题切换、退出", ("15 theme",)),
|
||||
FeatureSpec("unit_core", "单元测试(核心)", "unit", "路径校验、权限、提权逻辑", ("test_posix", "test_files", "test_unix", "test_remote", "test_schema")),
|
||||
)
|
||||
|
||||
|
||||
def match_feature(check_name: str) -> str:
|
||||
"""Return feature id for a single check label."""
|
||||
low = check_name.lower()
|
||||
for spec in FEATURES:
|
||||
for kw in spec.keywords:
|
||||
if kw.lower() in low:
|
||||
return spec.id
|
||||
return "other"
|
||||
@@ -68,9 +68,9 @@ def main() -> int:
|
||||
print("=" * 50)
|
||||
|
||||
base._load_credentials_from_env_file()
|
||||
base.test_plain_text("GET /health", "GET", "/health", expect_body="ok")
|
||||
base.api_test_plain_text("GET /health", "GET", "/health", expect_body="ok")
|
||||
|
||||
login = base.test("POST /api/auth/login", "POST", "/api/auth/login", body={
|
||||
login = base.api_test("POST /api/auth/login", "POST", "/api/auth/login", body={
|
||||
"username": base.ADMIN_USER,
|
||||
"password": base.ADMIN_PASSWORD,
|
||||
}, expect_code=200)
|
||||
|
||||
Reference in New Issue
Block a user