feat(ui): implement DBX-inspired workspace and connection controls

Add resource navigation, workspace tabs, configurable tables, connection URL parameters and runtime connection options. Include feature documentation and regression coverage.

Prior validation passed Go tests and vet, Vue type checks, and seven domain checks. Production frontend build and browser acceptance were blocked by spawn EPERM and remain documented as pending.
This commit is contained in:
曾志威
2026-09-20 13:35:56 +08:00
parent f5ab31bfde
commit b339c55630
76 changed files with 2607 additions and 576 deletions
+2
View File
@@ -216,6 +216,8 @@ Windows 开发构建和发布打包共用构建锁;已有构建运行时,第
## 文档
- [UI 实现状态与待验收事项](docs/todo.md)
- [资源搜索、资源树、列设置与工作区标签](docs/workspace-ui.md)
- [变更原因与设计记录](docs/change-rationale.md)
- [未保存配置的连接测试](docs/target-connection-test.md)
- [配置操作与易用性](docs/usability.md)
+34
View File
@@ -2,6 +2,40 @@
本文件记录本轮累计改动的原因、最终行为和验证边界。2026-09-18 为本次归档日期,不代表所有功能都在当天完成。具体操作方式以链接的功能文档为准;后续行为变更应同步补充原因,避免只留下“改了什么”。
## UI 待办十一项实现(2026-09-20)
原因:用户指出仅统一 DBX 风格仍缺少 URL 参数等功能,随后要求把待办全部实现。本次从视觉参考扩展为工作区交互和具有后端运行语义的连接能力,不把“有输入框”当作功能生效。
结果与范围:
- UI-001/002/006:从已有 API 资源摘要构造安全索引,提供范围感知的统一搜索、键盘/手机入口、资源树、侧栏缩放、最近资源和异常网关入口。复用现有资源刷新与导航保护,不索引凭据/资产正文;最近索引只保存类型、ID、时间。
- UI-003:新增通用表格包装器与列设置状态,接入网关、连接/命令审计和资产;显隐、排序、表头/数值宽度、必选列、旧偏好合并和重置统一处理。布局存储只包含列 ID 和尺寸,不存业务行数据。
- UI-004/005:标签以页面/资源 ID 去重,支持固定、关闭其他、编辑器内切换、筛选和滚动记忆。只挂载活动页面,含修改的编辑器在切换前确认放弃,不缓存隐藏凭据草稿或重复轮询。审计分页在标签间保留,概览活动不覆盖审计结果;会话失效清理索引/视图并使迟到刷新失效。统一点击/右键菜单复用现有操作、权限和确认;删除绑定开始时的资源类型,避免等待确认期间导航改变删除路径。
- UI-007/008:独立 URL 参数与完整 URL 复用白名单解析;参数同步结构化字段,未知/重复/冲突或非法编码原子失败。测试/保存先处理未解析 URL,不再测试旧值;同时有两种待处理输入时提示先应用一项。MySQL `charset` 进入上游握手,PostgreSQL `application_name` 进入启动参数;TLS 仍验证身份,不接受随意降级。草稿与保存共用同一载荷构造器。
- UI-009:新增目标 `connectionOptions` 与后端严格校验。连接超时进入出口拨号及支持的数据库握手;查询超时仅使用 PG 原生 `statement_timeout`;Mongo 空闲期限仅等待下一请求,不中断正在执行的查询;TCP 保活作用于直连、SOCKS5、SSH 底层连接,K8s 不支持并拒绝覆盖。前端禁用不支持项,避免把数据库工具的连接池/读超时直接套到 SSH、订阅或多协议会话。
- UI-010/011:目标/网关分别保存颜色、备注与显示开关,复用资产原有备注,不让目标元数据覆盖网关。草稿测试新增内联摘要、失效提示、完整结果入口及白名单诊断复制;自由文本错误、地址、账号和密钥不进入复制诊断或浏览器偏好。
兼容:新增字段随既有加密 JSON 配置、历史、备份/恢复和同步快照流转,没有 SQLite 表结构迁移。旧配置的零值/缺省保持原行为;网关只引用目标选项,不持久化上游副本。数值范围及语义见 [连接编辑器](connection-editor.md)。修复 PATCH 深合并下“使用默认保活”无法清除旧值的问题:前端明确发送 `keepAliveSeconds:null`,模型只允许可空指针字段接受 `null`,普通字段/配置对象仍拒绝 `null`。使用新字段前应升级所有同步节点,不能保证旧版本保留未知字段。没有新增运行依赖、没有修改真实数据库或部署迁移。
实际验证:
- `GOTOOLCHAIN=go1.25.0`、`GOFLAGS=-p=1`,临时目录和缓存位于工作区 `tmp`;最终 `go test ./... -count=1 -timeout=120s` 全包通过,`go vet ./...` 通过。首次完整回归发现可空保活清除被旧 `exactFields` 拒绝,修复后定向测试与完整回归均通过。
- 新增 model/API/store 回归覆盖边界值、协议限制、精确字段校验、目标运行时继承、网关元数据隔离、草稿与已保存配置一致性、清除保活、加密备份恢复和同步替换。协议使用本地 fixture 验证 MySQL 握手字符集字节、PG 启动参数、Mongo 空闲关闭及长请求不被该空闲期限打断;未连接真实服务验证兼容性。
- 锁定前端依赖下 `vue-tsc --noEmit` 通过,7 项新增纯逻辑测试函数在单进程通过,`git diff --check` 通过。新增浏览器场景能被 Playwright 发现,全套共 88 项用例;测试发现不是执行通过。
- `npm.cmd --prefix web run build` 在 Vite 启动 esbuild 时被 `spawn EPERM` 阻塞;Playwright 域测试 runner 的 worker 也因 `spawn EPERM` 无法启动。没有新前端构建、没有新嵌入程序、没有完整 E2E 或本轮截图。`web/dist/.gitkeep` 保留,未用旧二进制替代验收,未启动或替换用户本地服务,未接触实际 `data`。
文档:操作与生命周期见 [工作区交互](workspace-ui.md),参数/协议/标识见 [连接编辑器](connection-editor.md),[实现清单](todo.md) 将十一项标为“代码完成、待浏览器验收”,UI-000 继续未勾选。下节是前序视觉阶段的历史记录,其早期依赖缺失不代表本轮环境和验证结果。
## 全站界面参考 DBX 统一
原因:用户指定本地 `C:\work\github\dbx` 作为全部 UI 的参考,原来的深绿侧栏、较大的卡片留白和圆角与 DBX 的紧凑桌面风格不一致。
结果:引入共享中性灰/石墨主题令牌与 Element Plus 控件外观,统一 13 个页面、登录、工具栏、导航图标、表格、表单、通知、抽屉和弹窗。侧栏与顶部工具栏缩小,主题选择移入工具栏;连接编辑器、命令输出、导入和同步面板统一字体及圆角。手机继续使用可滚动图标导航、独立表格滚动和固定弹窗操作区,保留可见焦点与减少动画设置。
范围与兼容:仅改前端展示、相应测试和文档;App 保留业务状态与事件,新增 AppIcon 和 ThemePicker 可复用组件。不增加运行依赖,不引入 DBX 的 Tauri 或数据库业务逻辑,不改变后端 API、账号、连接或持久化结构。主题存储键和四个偏好值沿用,只调整浅色/深色显示名称与色板;无需数据库或配置迁移。
实际验证:`git diff --check` 通过;离线缓存 Vue SFC 3.5.39/TypeScript 5.9.3 检查 109 个前端源码及测试文件的模板、脚本与 CSS 语法,错误为 0,24 个应用设计令牌引用均有定义;这不是锁定依赖下的生产构建、完整类型检查或测试用例执行。已新增全菜单主题、弹窗和登录回归用例,并更新原手机图标断言。`npm ci` 因锁定依赖未缓存及网络拒绝连接而失败;构建找不到 vue-tsc,Playwright 找不到可执行命令。Go 1.25.0 的测试和 vet 阻塞于工具链下载,未生成新二进制,未运行完整 E2E,也未生成或验收实际界面截图。未启动服务,未接触真实目标与数据目录。详细范围、维护入口及后续验证要求见 [DBX 风格界面](ui-design.md)。
## 连接配置改为居中弹窗(2026-09-19)
原因:用户希望新建连接参照示例在弹窗中显示。目标与网关的新建、编辑改用统一居中弹窗,顶部固定标题和分组页签,底部固定测试、取消及保存,中间表单独立滚动;桌面提供更宽的编辑空间,手机按可视窗口缩放。账号页隐藏基础公共配置,沿用原有多账号编辑、字段定位、草稿连接测试和未保存确认。
+44 -8
View File
@@ -2,23 +2,33 @@
目标的新建、编辑弹窗提供“连接信息、TLS/SSL、SSH 隧道/代理、高级、账号”页签。SSH/SFTP 使用自身的加密传输,不显示 TLS/SSL 页。页签切换保留未保存字段及尚未解析的 URL;保存和测试连接使用所有页签中的当前配置。网关仍使用“网关与访问、账号”分组。
- 连接信息:URL 导入、名称、服务类型、主机、端口、数据库和集群配置。
- 连接信息:完整 URL 导入、独立 URL 参数、名称、颜色/备注、服务类型、主机、端口、数据库和集群配置。
- TLS/SSL:上游 TLS 与 CA 证书资产,开启时验证证书与主机名。
- SSH 隧道/代理:选择已维护的 SSH、SOCKS5、Kubernetes 出口或直接连接。出口凭据在代理管理维护。
- 高级:项目、环境、目标分组、MySQL/MongoDB 压缩,以及 SSH 主机指纹校验设置。
- 高级:有实际运行支持的超时/保活、项目、环境、目标分组、MySQL/MongoDB 压缩,以及 SSH 主机指纹校验设置。
- 账号:既有多账号编辑及凭据资产。网关只读权限仍在网关配置中维护。
高级页不新增可配置的连接、查询、空闲超时或保活间隔。
连接与网关的标识各自保存。目标连接选项由引用它的网关继承,包括账号独立目标映射;网关不能直接覆盖目标的 `connectionOptions`。
## 独立 URL 参数与提交一致性
完整 URL 下方提供独立“URL 参数”输入区,示例为 MySQL `charset=utf8mb4&tls=true`、PostgreSQL `application_name=serviceport&sslmode=verify-full`。参数区使用与完整 URL 一致的协议白名单,把值规范化到已有字段及 `connectionOptions`,不保存任意驱动参数字符串,也不透传未支持选项。
- 点击“应用参数”后同步表单;测试和保存也会先应用待处理输入。显式参数覆盖对应字段,缺省的数据库、TLS、认证来源、压缩或集群参数保持表单当前值;删除 `charset` / `application_name` 则恢复协议默认值。参数应用后可继续在其他页签修改字段,回读文本由当前结构化字段生成。
- 重复参数(包含大小写变体)、冲突同义参数、非法编码和未支持项统一报错;解析失败不部分修改配置,不发送测试或保存请求。百分号编码用于特殊字符;允许的空值遵循协议语义,例如 MySQL `database=` 清除默认库、MongoDB `compressors=` 关闭压缩,`charset=` 无效。
- 独立参数区拒绝 `user` / `username` / `password`;凭据只从完整 URL 导入或在账号页编辑。完整 URL 和参数区同时有待应用修改时,要求先手动应用其中一项并核对,避免两个输入源静默覆盖。
- 完整 URL 显示“待解析 / 已填入 / 解析错误”;测试/保存前自动解析仍未应用的 URL,不再使用旧主机或旧凭据。成功解析后清空原始 URL,关闭或切换工作区前仍有未保存确认。
- 切换协议清空协议相关参数及待处理输入,保留名称、范围、出口和颜色/备注。所有原始 URL、未处理参数和草稿只放内存,不写入浏览器偏好。
## URL 支持范围
填写 URL 后点击“解析并填入配置”,检查各页签,再测试或保存。输入默认隐藏,成功解析后清空原始 URL;只保存解析后的配置和按现有方式加密的凭据。不支持的参数、重复参数和相互冲突的值会报错,失败不会部分修改表单。部署模式手动选择,不提供自动识别模式;MongoDB URL 未包含 replicaSet 或 directConnection=true 时,需手动选择模式后再保存或测试。
填写 URL 后可点击“解析并填入配置”,也可直接测试或保存,由统一提交准备流程解析。输入默认隐藏;只保存解析后的配置和按现有方式加密的凭据。部署模式手动选择,不提供自动识别模式;MongoDB 完整 URL 未包含 replicaSet 或 directConnection=true 时,需手动选择模式后再保存或测试。独立参数未修改集群项时保留原部署模式。
| 服务 | URL 示例 | 支持的查询参数 |
| --- | --- | --- |
| MySQL | `mysql://user:password@host:3306/app` | `database`、`tls`、`ssl` |
| MySQL | `mysql://user:password@host:3306/app?charset=utf8mb4` | `database`、`tls`、`ssl`、`charset`(`utf8mb4` / `utf8` / `latin1` / `ascii`) |
| Redis | `redis://:password@host:6379/3` 或 `rediss://user:password@host/0` | `database`、`db`、`tls`、`ssl` |
| PostgreSQL | `postgresql://user:password@host/app?sslmode=verify-full` | `database`、`tls`、`ssl`、`sslmode` |
| PostgreSQL | `postgresql://user:password@host/app?sslmode=verify-full` | `database`、`tls`、`ssl`、`sslmode`、`application_name`(1–63 个 UTF-8 字节) |
| SQL Server | `sqlserver://user:password@host?database=master&encrypt=true` | `database`、`tls`、`ssl`、`encrypt` |
| RabbitMQ | `amqps://user:password@host/%2F` | `vhost`、`tls`、`ssl` |
| SSH/SFTP | `ssh://user:password@host:22`、`sftp://user:password@host:22` | 无协议专用参数 |
@@ -31,8 +41,34 @@
MySQL 的默认数据库用于测试连接和客户端未指定数据库的新连接;客户端指定数据库优先。Redis 在认证成功后发送 `SELECT`,服务端拒绝时连接失败,空值/0 保持默认库;Redis Cluster 仅支持 0。PostgreSQL、SQL Server 继续使用现有 `authSource` 字段保存默认库;RabbitMQ 的 vhost 用于连接测试,网关客户端仍自行指定 vhost。
MySQL `charset` 映射为上游握手的 `utf8mb4_general_ci` / `utf8_general_ci` / `latin1_swedish_ci` / `ascii_general_ci`;空设置沿用原来的 utf8mb4。PostgreSQL `application_name` 进入上游启动参数,缺省为 `serviceport`。客户端后续会话语句仍可能修改这些设置,它们不是权限策略。
## 高级选项能力矩阵
所有数值单位为秒,仅作用于当前目标;没有虚构的全局选项来源。没有实际支持的控件明确禁用,后端同样拒绝不适用于该协议的非默认值。
| 配置字段 | 支持范围 | 边界与实际语义 |
| --- | --- | --- |
| `connectTimeoutSeconds` | 全部目标协议及出口 | 0–120;0 沿用默认。设置直连、SOCKS5、SSH 转发及 K8s 建链上下文期限;默认普通拨号 5 秒、K8s 30 秒。MySQL/MongoDB 上游握手及 PostgreSQL 连接配置也使用此覆盖值;其他协议认证阶段保留原有限制。不是整个会话总时限,也不延长外层测试接口或调用方更短的期限。 |
| `queryTimeoutSeconds` | 仅 PostgreSQL | 0–86400;非零作为新上游会话 `statement_timeout`(转换为毫秒)。0 不覆盖服务端默认。由 PostgreSQL 执行查询超时,客户端仍可调整;没有把原始套接字读超时冒充多协议查询超时。 |
| `idleTimeoutSeconds` | 仅 MongoDB 网关会话 | 0–86400;0 关闭。在认证后等待下一条完整请求时设置下游读期限,收到请求后立即清除,不中断正在执行的查询。不是连接池回收,也不是目标测试的空闲设置。 |
| `keepAliveSeconds` | 全部目标协议的直连 / SOCKS5 / SSH 底层 TCP | 缺省/`null` 使用 Go / 系统默认,0 禁用,1–3600 为覆盖值;“使用默认”会明确保存为 `null`,不保留之前的覆盖。Kubernetes port-forward 不支持,必须使用默认。不是数据库心跳、SSH 应用层探测或会话有效性保证。 |
旧配置省略 `connectionOptions` 时不改变连接行为。高级页清空数值恢复默认语义;改变协议会清空不兼容的参数。新值仅影响随后建立的连接,仍遵守修改目标前停止引用网关的规则。
## 连接标识与测试反馈
- 目标和网关各有 `color`(空或六位十六进制色)、`remark`(不含 NUL、最多 2000 个 UTF-8 字节;UI 限制为 600 个字符)及 `showRemark`。提供预设色、自定义选色和清除;旧配置缺省为空且不显示备注。
- 名称旁显示颜色点,目标卡片、网关桌面/手机列表、资源树和最近访问按开关展示备注。搜索只显示名称及颜色,不检索备注;连接色不替代运行状态色。文件资产备注继续使用原有字段,不与目标/网关混用。备注不应填写凭据。
- 编辑器底部保留草稿测试账号选择和加载状态,完成后内联显示成功/失败、协议、TLS 和耗时;仍可打开完整结果。修改配置、账号或待处理 URL/参数后标记“需重新测试”。关闭编辑器后的迟到结果不会回填到其他编辑器。
- “复制脱敏诊断”仅包含协议、成功标志、耗时、TLS、是否过期及草稿范围;不复制名称、目标地址、账号、原始错误、服务器自由文本、URL、密码、私钥或资产正文。完整结果仍走原有弹窗,不写入偏好。
## 兼容和验证
新增可选 `database` 配置字段,仅 MySQL/Redis 使用。旧配置为空时行为不变;字段随目标配置、关联网关运行时及现有备份一起保存,无表结构迁移。现有 MongoDB URI、多账号、集群及代理配置继续复用。
沿用 MySQL/Redis 的 `database`,新增目标的可选 `connectionOptions` 及目标/网关的标识字段。通过已有加密配置、配置历史、备份/恢复和多端同步保存,无 SQLite 表结构迁移。网关运行快照继承关联目标选项,但网关记录只保存自身标识与目标引用,不复制上游配置;目标标识不会覆盖网关标识。
验证覆盖 URL 参数矩阵、错误拒绝及凭据不泄漏,浏览器切换页签保留草稿、测试请求、保存和重开。MySQL/Redis 使用本地协议 fixture 验证数据库选择,不是实际数据库服务器验收。实际命令和结果见 [变更原因](change-rationale.md)。
结构化 PATCH 仍拒绝未知字段与普通字段的 `null`;新增可空指针选项允许 `keepAliveSeconds:null` 恢复默认,其他字段的空值规则没有放开。使用新字段前应升级参与同步的所有客户端,旧版本不能保证保留其不认识的配置字段。
2026-09-20 实测:Go 1.25.0 全部后端测试及 vet 通过,前端全量类型检查通过,7 项前端纯逻辑测试函数在单进程执行通过。新增 fixture 验证 MySQL 握手字符集字节、PostgreSQL 启动参数、MongoDB 空闲关闭但不中断执行中请求;API 与 store 测试验证草稿、保存回读、清除保活、元数据隔离、加密备份恢复和同步快照替换。上述协议验证均为本地 fixture,不是实际数据库服务器验收。
已补充浏览器用例,但当前 Vite/esbuild 与 Playwright worker 均遇到 `spawn EPERM`,生产构建、全套浏览器执行和截图尚未完成,也没有使用旧二进制替代。完整结果见 [变更原因](change-rationale.md) 与 [工作区交互](workspace-ui.md)。
+2
View File
@@ -2,6 +2,8 @@
本文保留历史设计与阶段计划,部分协议数量及“未包含”描述已随实现演进。当前模块职责和代码入口以 [模块说明](modules.md) 为准,已实现功能及使用方式参见 [README](../README.md) 与 [网关账号与运维](gateway-operations.md)。
当前新增需求、实现状态及待验收事项统一维护在 [UI 实现清单](todo.md),不将本文件中的历史规划视为当前待办状态。UI-001 至 UI-011 已接入代码,使用方式见 [工作区交互](workspace-ui.md) 与 [连接编辑器](connection-editor.md);生产构建和运行态验收仍须完成。
## 0.3.0:Kubernetes、版本与模块拆分
- 目标服务增加可选分组字段,列表支持分组和未分组筛选,网关关联目标下拉显示分组;旧配置无需数据迁移即可读取。
+6
View File
@@ -1,5 +1,11 @@
# 界面自适应
## DBX 风格全站主题
全部工作区页面、登录、通用控件和编辑器改为统一的紧凑中性灰界面,详见 [界面设计与验证边界](ui-design.md)。桌面侧栏默认 224px,可调整至 192–420px,中等宽度为 192px,手机为 52px;手机导航使用可访问名称和线性 SVG 图标,按钮高度仍至少 44px。顶部工具栏容纳资源搜索、版本、环境状态和主题选择,不再使用覆盖内容的悬浮主题控件。资源树在手机通过弹窗展开,工作区标签可横向滚动,编辑器内提供标签切换入口。
`theme.css` 提供深浅色与海蓝令牌,`controls.css` 统一公共控件,`responsive.css` 继续处理断点。连接编辑器保留原有尺寸、固定操作区与键盘交互,补上参数区、标签切换和内联测试反馈。本轮类型检查已通过,但生产构建和完整浏览器验证受 `spawn EPERM` 限制,不能沿用下方历史回归记录声称已通过;实际情况见设计文档和变更记录。
## 连接配置弹窗
目标服务与网关服务的新建、编辑入口统一使用居中弹窗。桌面最大宽度 920px、高度 820px,同时限制在当前可视窗口内;手机保留 6px 外边距。标题、配置分组与底部操作固定,中间表单独立滚动,长表单不会把测试连接或保存按钮推到窗口外。
+152
View File
@@ -0,0 +1,152 @@
# UI 实现状态与待验收事项
更新时间:2026-09-20。
本文维护实现状态和仍需完成的验收。按“全部实现”的要求,UI-001 至 UI-011 已完成代码接入,并新增后端和前端回归用例;不是仅登记计划。复选框代表对应实现已落地,不代表浏览器和真实协议服务验收完成。使用方式见 [工作区交互](workspace-ui.md) 与 [连接编辑器](connection-editor.md),视觉来源见 [DBX 风格界面](ui-design.md)。
## 实施顺序与前置验收
- [ ] **UI-000|待完成:生产构建与运行态验收。** Go 1.25.0 全部后端测试、vet、前端类型检查已通过;Vite/esbuild 与 Playwright worker 均受 `spawn EPERM` 阻塞。仍需构建前端和新嵌入程序,将 `SERVICEPORT_E2E_BINARY` 指向新程序,在隔离数据库/空闲端口运行全套测试,检查全部页面、四类编辑器、登录/退出、主题及 320/390/768/1920px 截图。测试发现与纯逻辑断言不能替代此验收。
- 优先补齐连接配置:独立 URL 参数编辑(UI-007)→ 未解析 URL 与测试/保存的一致性(UI-008)。先明确协议支持和参数生效链路,不交付只有输入框、实际连接不生效的功能。
- 工作区第一阶段:全局资源搜索 → 树形资源导航 → 表格列设置。
- 后续阶段:统一操作菜单、首页快捷入口、连接标识与测试反馈;高级参数须先确认后端语义,多标签工作区须先完成状态与生命周期设计,再分别实施。
- 每项完成后更新状态、相关功能文档、`change-rationale.md` 和实际验证结果;未执行的测试或截图验收不得标记通过。
## 待实现清单
| 编号 | 优先级 | 功能 | 状态 |
| --- | --- | --- | --- |
| UI-001 | 高 | 全局资源搜索 | 代码完成;待浏览器验收 |
| UI-002 | 高 | 树形资源导航 | 代码完成;待浏览器验收 |
| UI-003 | 高 | 表格列设置 | 代码及布局合并断言通过;待浏览器验收 |
| UI-004 | 中 | 多标签工作区 | 生命周期设计及代码完成;待浏览器验收 |
| UI-005 | 中 | 统一操作菜单 | 代码完成;待浏览器验收 |
| UI-006 | 中 | 首页最近资源与快捷入口 | 代码完成;待浏览器验收 |
| UI-007 | 高,优先补齐 | 独立 URL 参数编辑与实际生效 | 代码、解析断言及协议 fixture 通过;待浏览器验收 |
| UI-008 | 高 | URL 输入、表单、测试与保存一致性 | 代码及 API 回归通过;待浏览器验收 |
| UI-009 | 高 | 连接高级参数 UI 与协议支持 | 能力矩阵、代码及 fixture 通过;待浏览器验收 |
| UI-010 | 中 | 连接颜色、备注与展示控制 | 代码、保存/备份/同步回归通过;待浏览器验收 |
| UI-011 | 中 | 连接测试内联状态与诊断复制 | 代码完成;待浏览器验收 |
## 连接配置 UI 差异核对
以下依据 DBX 的 `apps/desktop/src/components/connection/ConnectionDialog.vue` 和 serviceport 当前源码核对;完整 URL 导入、独立 URL 参数和高级连接配置是不同功能。
| 项目 | serviceport 当前状态 | 待补范围 |
| --- | --- | --- |
| 完整连接 URL 导入 | 提供待解析/已填入/错误状态,测试及保存前统一应用;解析后清空原始 URL | 浏览器验证同时存在待处理输入和错误重试,见 UI-008 |
| 独立 URL 参数输入 | 已新增独立输入、原子校验、结构化字段映射和回读,不存任意原始字符串 | 浏览器验证字段联动、保存重开,见 UI-007 |
| 协议参数扩展 | MySQL `charset`、PostgreSQL `application_name` 已贯通握手/启动参数,未知项仍拒绝 | 已验证本地 fixture,不代表真实数据库兼容性验收,见 UI-007 |
| 连接、查询、空闲超时及保活 | 已有单位/范围/默认值控件:网络超时、PG 查询超时、Mongo 空闲超时、非 K8s TCP 保活 | 不支持的选项禁用,能力矩阵见 UI-009 与连接文档 |
| 连接颜色和备注 | 目标/网关各自保存颜色、备注和显示开关;列表、树、最近资源显示 | 保存、备份恢复和同步测试通过;待主题/小屏验收,见 UI-010 |
| 连接测试 | 保留原草稿测试和详细结果,新增内联摘要、过期提示和白名单诊断复制 | 待浏览器生命周期与剪贴板验收,见 UI-011 |
| TLS/SSL、代理、账号页签 | 已有基础配置和资产引用,不是整页缺失 | 复用现有能力;新增参数须与现有字段一致,不降低 TLS 校验要求 |
参数编辑、超时和元数据已同步扩展模型、API、运行时及配置回读;使用已有加密配置,无 SQLite 表结构迁移。支持的 URL 参数、超时默认值和协议边界见 [连接配置页签与 URL](connection-editor.md)。
## 本轮实际验证
- `GOTOOLCHAIN=go1.25.0`,`GOFLAGS=-p=1` 限制并发,临时目录/Go 缓存放工作区 `tmp`:`go test ./... -count=1 -timeout=120s` 全部通过,`go vet ./...` 通过。
- 锁定依赖下 `vue-tsc --noEmit` 和 `git diff --check` 通过;7 项新增前端纯逻辑测试函数在单进程执行通过,覆盖参数/草稿/列布局/搜索脱敏/标签唯一性。
- Playwright 成功发现全套 88 项用例,但 worker 被 `spawn EPERM` 阻止;Vite 在启动 esbuild 时出现同类错误。没有声称生产构建、浏览器断言或截图已通过。
- 协议验证全部为隔离的本地 fixture;未连接真实 MySQL/MongoDB/SSH 测试服务,未修改工作区 `data`,未替换或启动用户实际服务。
## 事项说明
### UI-001:全局资源搜索
- [x] 增加统一搜索入口,按名称、地址和项目查找目标、网关、代理及资产,区分资源类型与所属项目/环境,支持直接进入详情或编辑。
- [x] 支持键盘打开、方向键选择、Enter 确认和 Escape 关闭,提供手机可点击入口、空结果及加载失败反馈。
- 复用与边界:在现有菜单搜索和资源导航能力上扩展,不搜索解密后的密码、密钥或文件正文。明确当前项目/环境搜索范围,跨范围跳转时同步筛选状态,并经过未保存确认。
- 验收:同名资源可区分;键盘和触屏均可操作;切换范围、删除资源及异步迟到响应不会打开错误资源;搜索结果不泄露敏感字段。
- DBX 参考:`apps/desktop/src/composables/useQuickOpen.ts`、`apps/desktop/src/components/quick-open/QuickOpenDialog.vue`。
### UI-002:树形资源导航
- [x] 按“项目 → 环境 → 资源类型”组织资源,提供折叠、状态标记、定位当前资源和清晰的未分组入口。
- [x] 桌面支持调整侧栏宽度;手机保留轻量入口,避免多层树占满工作区。
- 复用与边界:沿用现有项目、环境和资源 ID,不创建平行的分组数据体系;资源计数、树中选择与现有页面筛选保持一致。仅持久化非敏感的展开状态和布局偏好。
- 验收:资源新增、重命名、移动范围和删除后树及时更新;搜索定位可展开祖先节点;长名称、空分组及低高度窗口可用;不丢失现有菜单入口。
- DBX 参考:`apps/desktop/src/components/layout/AppSidebar.vue`、`apps/desktop/src/components/sidebar`。
### UI-003:表格列设置
- [x] 为网关、连接/命令审计和资产列表增加列显隐、拖动排序、宽度调整及恢复默认。
- [x] 按表格稳定标识保存非敏感布局偏好,新增或移除字段后能够合并旧偏好并恢复可用布局。
- 复用与边界:保留现有列表筛选和分页;关键标识、选择列及操作列的可隐藏规则需要明确。窄屏继续采用现有卡片或表格内部滚动,不强制套用桌面列宽。
- 验收:刷新后偏好保留;异常列宽受到限制;键盘或按钮能够完成拖动的等效操作;默认布局可恢复;空数据和长文本下不产生页面级横向溢出。
- DBX 参考:`apps/desktop/src/components/grid/DataGridColumnLayoutPopover.vue`、`apps/desktop/src/composables/useDataGridColumnLayout.ts`。
### UI-004:多标签工作区
- [x] 先明确标签的稳定标识、重复打开规则、组件缓存、轮询暂停及关闭后的资源释放,再支持同时打开目标配置、网关详情和审计。
- [x] 切换标签保留筛选和滚动位置,支持固定标签、关闭其他标签及关闭含未保存修改的标签时确认。
- 复用与边界:复用现有未保存变更保护,不把密码、私钥、解密内容或表单草稿写入浏览器持久存储。标签关闭只关闭视图,不隐式停止实际网关;不活动标签避免重复轮询。
- 验收:切换和关闭标签不串用资源状态;资源删除或范围变化后不保留可误操作的旧视图;关闭其他标签逐项保护未保存修改;手机有可访问的标签切换方式。
- DBX 参考:`apps/desktop/src/components/layout/AppTabBar.vue`、`apps/desktop/src/components/layout/EditorGroupTabBar.vue`。
### UI-005:统一操作菜单
- [x] 将编辑、复制配置、连接示例、查看审计及适用的启停操作整合为统一“更多”菜单,桌面补充右键入口。
- [x] 统一禁用条件、危险操作样式、确认交互和键盘操作;手机保留可点击按钮,不依赖右键。
- 复用与边界:所有入口调用现有业务操作与校验,不复制一套启停或删除逻辑。复制配置沿用现有脱敏规则,不因新增快捷菜单绕过敏感信息查看要求。
- 验收:菜单和原按钮的行为、禁用状态及确认条件一致;资源状态变化后不执行过期操作;菜单靠近窗口边缘时仍可见,触屏和键盘可用。
- DBX 参考:`apps/desktop/src/components/ui/CustomContextMenu.vue`。
### UI-006:首页最近资源与快捷入口
- [x] 在现有运行概览中增加最近访问资源、常用操作和异常网关入口,不另建重复仪表盘。
- [x] 提供新建目标、创建网关、导入配置等适用入口;点击最近资源或异常项可定位对应页面。
- 复用与边界:复用已有统计、监控状态和启动检查能力;最近记录仅保存必要的资源 ID、类型及访问时间,不保存凭据或解密内容。项目/环境筛选与实际资源删除同步生效。
- 验收:没有历史记录时提供明确引导;失效资源可清理;切换范围不展示错误资源;异常状态与原监控页面一致;手机布局保持紧凑。
- DBX 参考:`apps/desktop/src/components/layout/WelcomeScreen.vue`。
### UI-007:独立 URL 参数编辑与实际生效
- [x] 在目标连接配置中提供独立“URL 参数”输入区,支持 `key=value&...` 表达及按协议展示的示例、说明与错误定位;可辅以键值行编辑,但不要求用户先拼接完整连接 URL。
- [x] 定义各协议支持的参数、类型、默认值和作用范围。DBX 的 `charset=utf8mb4` 等示例需逐项评估后端支持;当前未实现的参数应明确拒绝,不能静默丢弃或只保存不生效。
- [x] 协调 URL 参数与数据库、TLS、认证来源、集群等已有表单字段。规定同义参数、重复项和冲突值的处理方式,采用一致的数据来源,避免两个输入区互相覆盖。
- [x] 贯通 URL 导入、草稿测试、保存、重新编辑、目标运行时配置、配置备份及多端同步;敏感参数按现有凭据规则处理,不写入浏览器偏好或未脱敏日志。
- 复用与边界:扩展现有 `ConnectionURLImport.vue`、`connectionURL.ts` 和 MongoDB 专用解析器,不以无约束字符串透传绕过协议校验或 TLS 安全要求。只读策略等网关配置不能因 URL 参数被绕过。
- 验收:编码字符、空值、重复参数、同义参数、协议切换和冲突均有测试;未支持参数报错后不部分修改表单;草稿测试与保存后的实际连接使用同一组有效参数;重开、备份恢复和同步不会丢失配置或泄漏秘密。协议验证必须说明真实服务或 fixture 边界。
- DBX 参考:`apps/desktop/src/components/connection/ConnectionDialog.vue` 中 `supportsGenericUrlParams`、`form.url_params` 及协议专用 URL 参数输入区。
### UI-008:URL 输入、表单、测试与保存一致性
- [x] 明确标示完整 URL 的“未解析 / 已应用 / 错误”状态;当 URL 尚未解析时点击测试或保存,应统一解析或提示先应用,不能让用户误以为新 URL 已经参与操作。
- [x] 定义 URL 解析后继续修改主机、端口、账号或独立 URL 参数的优先级;切换页签、关闭编辑器及失败重试不无声丢弃未处理输入。
- 复用与边界:现有手动“解析并填入配置”和账号选择继续可用;复用表单校验、凭据保留规则及未保存确认。原始 URL 可能含密码,不因新增编辑状态而持久保存明文,也不自动触发连接请求。
- 验收:粘贴 URL 后直接测试/保存有明确且一致的结果;解析失败阻止误用旧配置;多账号写入对象正确;切换页签和返回编辑不串用旧输入;错误信息保持脱敏。
- DBX 参考:`apps/desktop/src/components/connection/ConnectionDialog.vue` 中 `hasPendingConnectionUrlInput`、`ensureConnectionHostResolvedFromUrl` 和 `formValueForSubmit`。
### UI-009:连接高级参数 UI 与协议支持
- [x] 为适用协议设计连接超时、查询超时、空闲超时及保活间隔的控件,显示单位、范围、默认值,以及“使用默认 / 当前目标覆盖”的来源;没有全局配置能力时不展示虚假的全局设置。
- [x] 先区分拨号、认证、查询与长连接各阶段的语义,列出每种协议支持和不支持的项。DBX 的空闲超时等字段有协议条件,不能对所有协议一律展示。
- [x] 贯通目标模型、草稿测试、运行配置、导入导出和同步;明确参数修改对新连接与已有会话的影响。
- 复用与边界:不把客户端数据库管理工具的会话策略直接套到多协议网关。后端无法兑现的设置必须隐藏或明确禁用,不能只增加 UI;不将整个连接读超时冒充查询超时。
- 验收:默认行为兼容旧配置;边界值、继承/覆盖和禁用状态有校验;参数确实作用于声明的阶段,不误伤长查询、订阅、SSH 或 SFTP 会话;各协议分别记录实际验证范围。
- DBX 参考:`apps/desktop/src/components/connection/ConnectionDialog.vue` 中连接/查询超时、`idle_timeout_secs` 与 `keepalive_interval_secs` 控件。
### UI-010:连接颜色、备注与展示控制
- [x] 为目标/网关提供可选颜色标识和备注,并定义列表、详情、未来资源树或标签页中的显示规则;允许关闭备注展示。
- [x] 支持预设颜色和清除标识,限制备注长度;只有确认有必要时才增加自定义色,避免增加配置复杂度。
- 复用与边界:文件资产已有备注,不重复建设;连接色只用于识别,不替代运行状态或告警色。连接元数据如需持久化,应覆盖备份与同步,并明确旧配置默认值。
- 验收:长备注、小屏和深浅主题下可读;颜色以外仍有名称等辨识信息;清除、保存、重开和备份恢复一致;不将资产备注误写到目标或网关。
- DBX 参考:`apps/desktop/src/components/connection/ConnectionDialog.vue` 中 `form.color`、`form.note` 和备注侧栏展示开关。
### UI-011:连接测试内联状态与诊断复制
- [x] 在连接编辑器固定操作区展示测试中、成功或失败的简要状态,并保留已有详细结果入口;可显示已经获得的耗时与失败阶段。
- [x] 提供脱敏诊断复制,修改测试相关配置或切换账号后标记上次结果已过期,不把旧成功状态当作当前配置已验证。
- 复用与边界:不重新实现连接测试接口,沿用 `TargetDraftTest.vue` 的草稿快照、账号选择、凭据保留和 SSH 指纹确认。诊断复制不得包含密码、私钥、原始含密 URL 或资产正文。
- 验收:测试期间按钮状态正确;关闭编辑器后迟到结果不污染其他目标;修改配置后状态失效;键盘和读屏能够获得结果反馈;手机底部状态不挤掉测试、取消与保存按钮。
- DBX 参考:`apps/desktop/src/components/connection/ConnectionDialog.vue` 底部 `testResult`、`copyTestResult` 和测试按钮。
## 实施边界
- DBX 参考路径均相对于本地 `C:\work\github\dbx`;参考交互模式,不直接搬入其业务状态或依赖链。
- 已有连接测试、未保存确认、列表筛选记忆、项目/环境筛选和批量操作优先复用,不重复建设。
- 暂不纳入完整 SQL 编辑器、Tauri 桌面多窗口及 DBX 数据库业务模块。
- 展示和导航增强不预设数据库迁移;URL 参数、高级设置和连接元数据必须先确认后端及存储支持。需要调整接口、运行语义或持久化结构时,应单独记录兼容方案与验证,且不得使用真实目标数据进行烟雾测试。
+52
View File
@@ -0,0 +1,52 @@
# DBX 风格界面
serviceport 的全部工作区页面采用参考本地 `C:\work\github\dbx` 的紧凑桌面工具风格。参考范围是视觉体系与通用控件,而不是把 DBX 的数据库编辑器、Tauri 运行时、连接状态或业务功能移植进来。serviceport 继续使用 Vue 3、Element Plus 和原有管理 API。
2026-09-20 已进一步实现 [清单](todo.md) 中 UI-001 至 UI-011 的代码:资源搜索、资源树、列设置、工作区标签、操作菜单、最近访问,以及独立 URL 参数、高级连接选项、连接标识与测试反馈。工作区操作及生命周期见 [工作区交互](workspace-ui.md),协议生效范围见 [连接配置](connection-editor.md)。代码实现与运行态验收分开记录,当前浏览器验证仍受环境限制。
## 设计来源与对应关系
| DBX 参考位置 | serviceport 实现 |
| --- | --- |
| `apps/desktop/src/styles/tokens.css` | `web/src/theme.css`:中性灰配色、深色石墨背景、文字层级、4px 控件圆角、6px 面板圆角 |
| `apps/desktop/src/components/layout/AppToolbar.vue`、`AppSidebar.vue` | `web/src/App.vue`、`style.css`:紧凑工具栏、浅灰/深灰侧栏、小尺寸线性图标、选中标记 |
| `apps/desktop/src/components/ui/button/index.ts`、`input/Input.vue` | `web/src/controls.css`:按钮、输入框、焦点状态、菜单、表格和浮层的统一外观 |
不增加 UI 框架或图标依赖,不复制 DBX 品牌标识。`AppIcon.vue` 提供 serviceport 自有的线性 SVG 图标。字体优先使用本地 Geist,未安装时回退到系统中文字体和 Segoe UI;没有新增网络字体请求。
## 覆盖范围
- 代理、资产管理、目标服务、运行概览、网关服务、连接与审计、命令审计、拖拽连线、监控运维、配置管理、多端同步、密码解密和 SQLite 文件,共 13 个导航页面。
- 登录页、菜单搜索、版本信息、主题选择、项目/环境筛选和账号状态。
- 表格、卡片、表单、标签、通知、确认框、通用弹窗、抽屉,以及目标/网关连接编辑器。
- SQL 和命令输出、导入区域、集群配置和同步冲突面板使用相同的等宽字体及圆角令牌。
浅色主题采用白色内容区、浅灰侧栏及深色主按钮;深色主题使用分层石墨背景及浅色主按钮,文字、勾选标记和按钮文字同时适配。成功、警告和失败仍以语义颜色区分,不把所有状态改成灰色。
主题选择器位于工作区顶部工具栏中,不再悬浮遮挡页面信息。登录页保留独立选择器。原有 `serviceport-theme` 存储键以及 `light`、`dark`、`ocean`、`system` 值不变;`light` 的显示名称从“浅色 · 森绿”改为“浅色 · 经典”,`dark` 改为“深色 · 石墨”。海蓝继续提供蓝色强调色,“跟随系统”继续响应系统深浅色变化。
## 布局与交互
- 桌面侧栏默认 224px,可调整为 192–420px,中等宽度为 192px;720px 及以下使用 52px 图标侧栏和独立资源树弹窗。手机导航保留至少 44px 的按钮高度和完整可访问名称。
- 顶部工具栏采用约 44px 高度;工作区减少装饰性留白,保留标题、说明、筛选和主要操作。
- 目标/网关编辑器保留居中布局、固定标题/页签/页脚、表单独立滚动和原有键盘切换。外观改为小圆角、紧凑标题和分段页签。
- 宽表格和拖拽画布仍在自身容器内滚动,窄屏保留网关卡片与表单换行。版本与主题工具不占据浮动覆盖层。
- 可见焦点样式覆盖导航、按钮、主题选择及输入控件;系统启用减少动态效果时,关闭不必要的界面动画。
视觉层本身不改变协议安全策略。后续功能扩展增加了有白名单和运行支持的目标参数、连接标识以及导航状态,沿用账号权限、凭据加密、启停和未保存确认;兼容与默认值详见连接配置文档。无表结构迁移,不需重置原主题偏好。
## 维护入口
`main.ts` 按 Element Plus 基础样式、深色变量、`theme.css`、`style.css`、`controls.css`、`responsive.css` 的顺序加载全局样式。颜色和尺寸定义放在 `theme.css`;布局放在 `style.css`;公共 Element Plus 外观放在 `controls.css`;断点规则放在 `responsive.css`。独立组件的局部样式应引用这些令牌,不再定义一套硬编码主题色。
`ThemePicker.vue` 复用原来的 `theme.ts` 状态,不创建第二套偏好存储。`SidebarNavigation.vue` 继续使用 App 提供的菜单、计数、搜索和导航事件。
## 验证与边界
- 新增 `web/tests/dbx-ui.spec.ts`,覆盖 13 个菜单在浅色、深色、海蓝和桌面/手机下的主题颜色、圆角、导航状态、页面宽度及工具栏布局;另覆盖四类编辑器、页签 Home/End 切换、登录主题持久化和系统主题跟随。用例包含截图输出,但本次没有执行成功或生成截图。
- 原 `web/tests/responsive.spec.ts` 的手机图标断言从字符字号调整为 SVG 可见性与实际宽度,仍保留原有尺寸、固定页脚、登录和退出检查。
- 当前锁定依赖已经可用,`vue-tsc --noEmit` 全量类型检查通过,7 项新增纯逻辑测试函数在单进程通过;全套 Playwright 测试发现成功,共 88 项。早期离线语法检查不是当前代码的生产构建证明。
- Go 1.25.0 全部后端测试及 `go vet ./...` 通过。协议新增验证使用本地 fixture;数据使用隔离测试目录,未接触真实目标或工作区 `data`。
- `npm.cmd --prefix web run build` 在启动 esbuild 时失败(`spawn EPERM`),Playwright worker 启动也被拒绝。尚无本轮新前端产物、嵌入程序或浏览器执行/截图;不能使用旧二进制和下方其他文档的历史截图代替验收。
依赖可用后,先运行前端构建,再编译 Go 程序;不要同时替换 `web/dist` 和构建嵌入资产。随后将 `SERVICEPORT_E2E_BINARY` 指向刚生成的可执行文件,使用隔离数据库和空闲端口运行完整 Playwright 套件。
+57
View File
@@ -0,0 +1,57 @@
# 工作区导航与交互
2026-09-20 实现 UI-001 至 UI-006。代码与待验收状态见 [实现清单](todo.md),连接参数、连接标识及测试反馈见 [连接编辑器](connection-editor.md)。本文件描述实现行为,不代表浏览器运行态验收已经完成。
## 资源搜索与资源树
- 工具栏“搜索资源”或 `Ctrl+K` / `⌘K` 打开统一搜索;上下方向键选择,Enter 打开,Esc 关闭。手机保留可点击搜索图标。
- 搜索目标、网关、代理和资产的名称、地址、协议及项目/环境,最多显示 100 条。索引从现有资源摘要构造,不纳入账号密码、私钥、资产正文、原始错误或隐藏备注。
- 结果限定当前项目/环境,显示资源类型、范围和网关状态;空结果、刷新中、刷新失败及重试均有提示。搜索不创建新的后端查询或权限体系。
- 侧栏按“项目 → 环境 → 类型”组织资源,未分配项目/环境有明确名称。选择资源时展开祖先;资源重命名、状态变化、移动或删除随现有刷新更新。
- 桌面侧栏默认 224px,可拖动右边界,在 192–420px 内调整;边界支持左右键调整和 Home 恢复默认。1050px 及以下使用 192px,720px 及以下使用 52px 图标侧栏和“展开资源树”弹窗。
- 跨范围的引用跳转先提示切换项目/环境,并经过未保存确认,避免编辑其他范围的资源却仍显示原筛选。
## 工作区标签
稳定键是页面 ID,或 `资源类型:资源 ID`;同一资源重复打开只激活已有标签,不重复建立编辑器。范围变化清空旧标签,固定状态也不跨范围保留。
- 标签支持固定/取消固定、关闭及关闭其他未固定标签;手机可横向滚动。编辑弹窗/抽屉内另有“切换工作区标签”和“关闭当前标签”,无需操作遮罩后的页面。
- 标签元数据、页面筛选快照、页面/表格滚动位置、连接配置子页签及编辑器滚动位置只放内存。列表筛选沿用原来的非敏感偏好存储。
- 只挂载当前页面,不缓存含凭据的组件实例。切换有修改的编辑器会询问是否放弃,选择继续编辑则不切换;选择放弃后清空草稿,再从当前资源数据打开下一项。不是多个后台草稿编辑器。
- 未活动标签没有额外轮询器。现有共享资源刷新保留;命令/连接审计返回标签时保留已加载分页,手动刷新才重新查询。概览活动使用独立数据,避免覆盖审计筛选和分页。
- 已关闭视图不会停止实际网关。未活动标签没有草稿,因此“关闭其他标签”不会丢弃隐藏编辑内容;活动编辑器的切换/关闭/退出登录仍走统一未保存保护。
- 刷新时清理不存在或移出当前范围的资源标签;有未保存内容时不静默丢弃。再次导航会重新查找当前资源,已删除项不再打开;实际保存和删除仍由后端校验。
- 登录失效或退出登录清理资源数据、标签、最近记录及凭据视图,并使之前的刷新响应失效,避免跨会话串用资源。
## 表格列设置
网关、连接审计、命令审计和资产表格提供统一“列设置”:显示/隐藏、拖动排序、上移/下移等效按钮、80–800px 列宽和恢复默认。也可拖动表头边界调整宽度。
关键标识列、选择列及操作入口不可隐藏;选择列固定在最前,操作列固定在最后。保存的是稳定列 ID、显隐、顺序和宽度,不包含单元格内容。读取旧布局时丢弃删除字段、去重、补齐新字段并限制宽度;损坏存储退回默认。小屏继续使用原有网关/命令卡片或表格内横向滚动。
## 操作菜单与概览
- 目标、网关、代理和资产提供统一“更多”控件及桌面右键菜单。菜单按能力复用现有编辑、测试、复制创建、密码查看、连接示例、审计、启停、指纹及删除处理函数;不支持的动作不展示。
- 右键菜单限制在窗口内,方向键及 Home/End 选择,Enter 执行,Esc 关闭,点击外部或调整窗口关闭。手机使用点击式菜单,不依赖右键。
- 禁用状态跟随最新资源/运行状态和忙碌状态,执行前重新查找资源;删除和停止沿用现有确认与后端保护。复制创建不复制已保存密码、私钥明文或口令,资产引用保留,密码查看仍走原有验证接口。
- 运行概览增加最近访问、异常网关和新建目标/网关、导入配置及监控入口;不再造一套统计或监控。异常状态直接取现有 `runtime.error`。
- 最近访问最多保留 30 条索引、展示当前范围内最近 8 条;仅存类型、ID、访问时间,从最新资源摘要补名称。删除资源后清理索引,也可手动清空;不保存目标地址、备注或凭据。
## 存储与兼容
| 键 | 内容 |
| --- | --- |
| `serviceport.recent.v1` | 最近资源的类型、ID、时间 |
| `serviceport.tree.v1` | 项目/环境/类型分支的展开键 |
| `serviceport.sidebar-width` | 桌面侧栏像素宽度 |
| `serviceport.columns.v1.<表格 ID>` | 列 ID、显隐、顺序、宽度 |
浏览器禁用存储时功能仍可在当前会话使用;这些偏好不进入服务器备份或 S3 同步。所有标签、原始 URL、未保存表单、解密内容和诊断结果不写入浏览器持久存储。不引入新运行依赖、不更换管理 API、不改变网关账号权限或协议安全策略。
## 验证
2026-09-20:锁定依赖下 `vue-tsc --noEmit` 通过;7 项纯逻辑测试函数已在单进程执行,覆盖参数校验/回读、草稿选项、列合并、资源索引脱敏和标签唯一性。新增 `workspace-features.spec.ts` 覆盖未应用 URL 测试/保存、参数回读、结果过期、搜索/树、草稿关闭保护、最近索引、列布局及右键键盘操作;全套 Playwright 能发现 88 项测试。
`npm.cmd --prefix web run build` 在 Vite 启动 esbuild 时遭遇 `spawn EPERM`;Playwright 的 worker 同样无法启动。测试发现与单进程逻辑断言不等于浏览器用例通过。本轮未生成新前端产物/新嵌入二进制,未运行浏览器端到端回归或生成新截图;不能沿用旧二进制或历史截图作为本轮验收。
解除环境限制后,先构建前端,再用 `GOTOOLCHAIN=go1.25.0` 编译新程序;将 `SERVICEPORT_E2E_BINARY` 指向新程序,使用隔离目录、空闲端口运行 `npm.cmd --prefix web test`。纯逻辑用例也可在 `web` 中运行 `npx.cmd playwright test --config playwright.domain.config.ts`。不得并行替换 `web/dist` 与编译 Go 嵌入资源。
+43
View File
@@ -0,0 +1,43 @@
package api
import (
"serviceport/internal/gateway"
"serviceport/internal/model"
"testing"
)
func TestDraftOptionsAndMetadataSavedRoundTrip(testContext *testing.T) {
fixture := newFixture(testContext)
fixture.login()
view := fixture.create("mysql")
handler := &recordingDraftProtocol{}
manager := gateway.New(fixture.store, nil, map[string]model.Handler{"mysql": handler})
fixture.server.manager = manager
testContext.Cleanup(manager.Shutdown)
options := map[string]any{"charset": "latin1", "connectTimeoutSeconds": 3, "keepAliveSeconds": 0}
patch := map[string]any{"connectionOptions": options, "color": "#123456", "remark": "target note", "showRemark": true}
requireStatus(testContext, fixture.request("PUT", "/api/v1/targets/"+view.Config.TargetID, patch, true), 200)
target, err := fixture.store.GetTarget(view.Config.TargetID)
if err != nil {
testContext.Fatal(err)
}
if target.ConnectionOptions.Charset != "latin1" || target.Color != "#123456" || target.Remark != "target note" || !target.ShowRemark {
testContext.Fatal("saved options lost")
}
requireStatus(testContext, fixture.request("POST", "/api/v1/targets/test", map[string]any{"targetId": target.ID, "accountId": target.Accounts[0].ID, "config": patch}, true), 200)
if len(handler.seen) != 1 || handler.seen[0].ConnectionOptions.Charset != "latin1" || handler.seen[0].ConnectionOptions.KeepAliveSeconds == nil || *handler.seen[0].ConnectionOptions.KeepAliveSeconds != 0 {
testContext.Fatal("draft runtime options differ from saved target")
}
requireStatus(testContext, fixture.request("PUT", "/api/v1/proxies/"+view.Config.ID, map[string]any{"color": "#abcdef", "remark": "gateway note", "showRemark": true}, true), 200)
proxy, err := fixture.store.Get(view.Config.ID)
if err != nil || proxy.Color != "#abcdef" || proxy.Remark != "gateway note" {
testContext.Fatal("gateway metadata lost", err)
}
requireStatus(testContext, fixture.request("PUT", "/api/v1/proxies/"+view.Config.ID, map[string]any{"connectionOptions": options}, true), 400)
requireStatus(testContext, fixture.request("PUT", "/api/v1/targets/"+target.ID, map[string]any{"connectionOptions": map[string]any{"queryTimeoutSeconds": 3}}, true), 400)
requireStatus(testContext, fixture.request("PUT", "/api/v1/targets/"+target.ID, map[string]any{"connectionOptions": map[string]any{"keepAliveSeconds": nil}}, true), 200)
cleared, err := fixture.store.GetTarget(target.ID)
if err != nil || cleared.ConnectionOptions.KeepAliveSeconds != nil {
testContext.Fatal("default keepalive could not be restored", err)
}
}
+3
View File
@@ -329,6 +329,9 @@ func (s *Server) resolveTarget(p model.Proxy) (model.Proxy, error) {
return p, err
}
func gatewayFields(patch map[string]any) error {
if _, present := patch["connectionOptions"]; present {
return errors.New("edit connectionOptions through target services")
}
for _, k := range []string{"cluster", "group", "targetHost", "targetPort", "targetUser", "targetPassword", "targetTLS", "targetCA", "targetCAAssetId", "targetCompression", "authSource", "outbound", "outboundId"} {
if _, ok := patch[k]; ok {
return errors.New("edit upstream connection settings through target services")
+80
View File
@@ -0,0 +1,80 @@
package model
import (
"errors"
"regexp"
"strings"
"time"
)
type ConnectionOptions struct {
Charset string `json:"charset,omitempty"`
ApplicationName string `json:"applicationName,omitempty"`
ConnectTimeoutSeconds int `json:"connectTimeoutSeconds,omitempty"`
QueryTimeoutSeconds int `json:"queryTimeoutSeconds,omitempty"`
IdleTimeoutSeconds int `json:"idleTimeoutSeconds,omitempty"`
KeepAliveSeconds *int `json:"keepAliveSeconds,omitempty"`
}
var connectionColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
func (proxy Proxy) ValidateConnectionOptions() error {
if proxy.Color != "" && !connectionColor.MatchString(proxy.Color) {
return errors.New("color must be a six-digit hex color")
}
if len(proxy.Remark) > 2000 || strings.ContainsRune(proxy.Remark, '\x00') {
return errors.New("remark must contain at most 2000 bytes and no NUL")
}
options := proxy.ConnectionOptions
if options.Charset != "" {
if proxy.Type != "mysql" || (options.Charset != "utf8mb4" && options.Charset != "utf8" && options.Charset != "latin1" && options.Charset != "ascii") {
return errors.New("charset supports MySQL utf8mb4, utf8, latin1 or ascii only")
}
}
if options.ApplicationName != "" && (proxy.Type != "pgsql" || len(options.ApplicationName) > 63 || strings.ContainsAny(options.ApplicationName, "\r\n\x00")) {
return errors.New("applicationName supports PostgreSQL only, up to 63 bytes without control characters")
}
if options.ConnectTimeoutSeconds < 0 || options.ConnectTimeoutSeconds > 120 {
return errors.New("connectTimeoutSeconds must be 0-120")
}
if options.QueryTimeoutSeconds < 0 || options.QueryTimeoutSeconds > 86400 || (options.QueryTimeoutSeconds != 0 && proxy.Type != "pgsql") {
return errors.New("queryTimeoutSeconds supports PostgreSQL only, 0-86400")
}
if options.IdleTimeoutSeconds < 0 || options.IdleTimeoutSeconds > 86400 || (options.IdleTimeoutSeconds != 0 && proxy.Type != "mongodb") {
return errors.New("idleTimeoutSeconds supports MongoDB only, 0-86400")
}
if options.KeepAliveSeconds != nil && (*options.KeepAliveSeconds < 0 || *options.KeepAliveSeconds > 3600 || proxy.Outbound.Mode == "k8s") {
return errors.New("keepAliveSeconds must be 0-3600 and is not supported by Kubernetes forwarding")
}
return nil
}
func (options ConnectionOptions) ConnectTimeout(fallback time.Duration) time.Duration {
if options.ConnectTimeoutSeconds > 0 {
return time.Duration(options.ConnectTimeoutSeconds) * time.Second
}
return fallback
}
func (options ConnectionOptions) KeepAlive() time.Duration {
if options.KeepAliveSeconds == nil {
return 0
}
if *options.KeepAliveSeconds == 0 {
return -1
}
return time.Duration(*options.KeepAliveSeconds) * time.Second
}
func (options ConnectionOptions) MySQLCollation() string {
switch options.Charset {
case "utf8":
return "utf8_general_ci"
case "latin1":
return "latin1_swedish_ci"
case "ascii":
return "ascii_general_ci"
default:
return "utf8mb4_general_ci"
}
}
+82
View File
@@ -0,0 +1,82 @@
package model
import (
"encoding/json"
"reflect"
"testing"
"time"
)
func TestConnectionOptionsValidation(testContext *testing.T) {
negative, disabled, positive := -1, 0, 30
for _, testCase := range []struct {
name, protocol string
options ConnectionOptions
outbound string
valid bool
}{
{"default", "mysql", ConnectionOptions{}, "", true},
{"charset", "mysql", ConnectionOptions{Charset: "latin1"}, "", true},
{"unsafe charset", "mysql", ConnectionOptions{Charset: "utf8;select"}, "", false},
{"wrong charset protocol", "pgsql", ConnectionOptions{Charset: "utf8"}, "", false},
{"query", "pgsql", ConnectionOptions{ApplicationName: "工作区", QueryTimeoutSeconds: 10}, "", true},
{"query wrong protocol", "mysql", ConnectionOptions{QueryTimeoutSeconds: 10}, "", false},
{"application injection", "pgsql", ConnectionOptions{ApplicationName: "app\x00"}, "", false},
{"idle", "mongodb", ConnectionOptions{IdleTimeoutSeconds: 1}, "", true},
{"idle wrong protocol", "ssh", ConnectionOptions{IdleTimeoutSeconds: 1}, "", false},
{"negative connect", "mysql", ConnectionOptions{ConnectTimeoutSeconds: -1}, "", false},
{"huge connect", "mysql", ConnectionOptions{ConnectTimeoutSeconds: 121}, "", false},
{"negative keepalive", "mysql", ConnectionOptions{KeepAliveSeconds: &negative}, "", false},
{"disabled keepalive", "mysql", ConnectionOptions{KeepAliveSeconds: &disabled}, "", true},
{"ssh keepalive", "mysql", ConnectionOptions{KeepAliveSeconds: &positive}, "ssh", true},
{"k8s keepalive", "mysql", ConnectionOptions{KeepAliveSeconds: &positive}, "k8s", false},
} {
testContext.Run(testCase.name, func(testContext *testing.T) {
proxy := Proxy{Type: testCase.protocol, ConnectionOptions: testCase.options, Outbound: Outbound{Mode: testCase.outbound}}
if err := proxy.ValidateConnectionOptions(); (err == nil) != testCase.valid {
testContext.Fatalf("valid=%v, error=%v", testCase.valid, err)
}
})
}
if (ConnectionOptions{}).ConnectTimeout(5*time.Second) != 5*time.Second || (ConnectionOptions{ConnectTimeoutSeconds: 2}).ConnectTimeout(5*time.Second) != 2*time.Second {
testContext.Fatal("timeout defaults lost")
}
if (ConnectionOptions{KeepAliveSeconds: &disabled}).KeepAlive() >= 0 || (ConnectionOptions{KeepAliveSeconds: &positive}).KeepAlive() != 30*time.Second {
testContext.Fatal("keepalive semantics lost")
}
}
func TestConnectionOptionsMetadataRoundTripAndTargetIsolation(testContext *testing.T) {
target := TargetDefaults()
target.Name, target.Type, target.TargetHost, target.TargetPort = "options", "mysql", "localhost", 3306
target.Color, target.Remark, target.ShowRemark = "#22c55e", "test environment", true
target.ConnectionOptions = ConnectionOptions{Charset: "latin1", ConnectTimeoutSeconds: 3}
target.Accounts = []TargetAccount{{ID: "account", Name: "test", Username: "fixture", Password: "secret"}}
if err := target.Validate(); err != nil {
testContext.Fatal(err)
}
redacted, _ := target.Redacted()
if redacted.Color != target.Color || redacted.Remark != target.Remark || !redacted.ShowRemark || !reflect.DeepEqual(redacted.ConnectionOptions, target.ConnectionOptions) {
testContext.Fatal("target options/identity lost")
}
gateway := target.Apply(Proxy{Color: "#ef4444", Remark: "gateway", ShowRemark: true})
if gateway.Color != "#ef4444" || gateway.Remark != "gateway" {
testContext.Fatal("target overwrote gateway identity")
}
if gateway.ConnectionOptions.Charset != "latin1" || gateway.GatewayOnly().ConnectionOptions.Charset != "" || gateway.GatewayOnly().Color != "#ef4444" {
testContext.Fatal("gateway options isolation failed")
}
serialized, err := json.Marshal(target)
if err != nil {
testContext.Fatal(err)
}
var restored Target
if err := json.Unmarshal(serialized, &restored); err != nil || !reflect.DeepEqual(restored, target) {
testContext.Fatal("JSON roundtrip failed", err)
}
for _, patch := range []map[string]any{{"connectionOptions": map[string]any{"unsafe": true}}, {"connectionOptions": map[string]any{"connectTimeoutSeconds": 1.5}}, {"name": nil}, {"connectionOptions": nil}, {"connectionOptions": map[string]any{"charset": nil}}} {
if _, err := MergeTarget(target, patch); err == nil {
testContext.Fatal("invalid options accepted")
}
}
}
+4
View File
@@ -11,6 +11,10 @@ import (
// Only GatewayOnly is persisted in the gateway table. HTTP responses must use
// Redacted rather than marshal a snapshot containing decrypted credentials.
type Proxy struct {
ConnectionOptions ConnectionOptions `json:"connectionOptions,omitempty"`
Color string `json:"color,omitempty"`
Remark string `json:"remark,omitempty"`
ShowRemark bool `json:"showRemark,omitempty"`
Project string `json:"project"`
Environment string `json:"environment"`
Cluster ClusterConfig `json:"cluster,omitempty"`
+27 -20
View File
@@ -8,25 +8,29 @@ import (
// Target owns upstream identity and routing. It can be shared by many gateways.
type Target struct {
Project string `json:"project"`
Environment string `json:"environment"`
Cluster ClusterConfig `json:"cluster,omitempty"`
Accounts []TargetAccount `json:"accounts,omitempty"`
TargetCompression string `json:"targetCompression,omitempty"`
ID string `json:"id"`
OutboundID string `json:"outboundId,omitempty"`
Name string `json:"name"`
Group string `json:"group,omitempty"`
Type string `json:"type"`
TargetHost string `json:"targetHost"`
TargetPort int `json:"targetPort"`
TargetTLS bool `json:"targetTLS"`
TargetCA string `json:"targetCA,omitempty"`
TargetCAAssetID string `json:"targetCAAssetId,omitempty"`
AuthSource string `json:"authSource,omitempty"`
Database string `json:"database,omitempty"`
Outbound Outbound `json:"outbound"`
SSH TargetSSH `json:"ssh"`
ConnectionOptions ConnectionOptions `json:"connectionOptions,omitempty"`
Color string `json:"color,omitempty"`
Remark string `json:"remark,omitempty"`
ShowRemark bool `json:"showRemark,omitempty"`
Project string `json:"project"`
Environment string `json:"environment"`
Cluster ClusterConfig `json:"cluster,omitempty"`
Accounts []TargetAccount `json:"accounts,omitempty"`
TargetCompression string `json:"targetCompression,omitempty"`
ID string `json:"id"`
OutboundID string `json:"outboundId,omitempty"`
Name string `json:"name"`
Group string `json:"group,omitempty"`
Type string `json:"type"`
TargetHost string `json:"targetHost"`
TargetPort int `json:"targetPort"`
TargetTLS bool `json:"targetTLS"`
TargetCA string `json:"targetCA,omitempty"`
TargetCAAssetID string `json:"targetCAAssetId,omitempty"`
AuthSource string `json:"authSource,omitempty"`
Database string `json:"database,omitempty"`
Outbound Outbound `json:"outbound"`
SSH TargetSSH `json:"ssh"`
}
type TargetSSH struct {
TargetHostKey string `json:"targetHostKey,omitempty"`
@@ -34,9 +38,10 @@ type TargetSSH struct {
}
func TargetFromProxy(p Proxy) Target {
return Target{Database: p.Database, Project: p.Project, Environment: p.Environment, Cluster: p.Cluster, OutboundID: p.OutboundID, TargetCompression: p.TargetCompression, ID: p.TargetID, Name: p.Name, Group: p.Group, Type: p.Type, TargetHost: p.TargetHost, TargetPort: p.TargetPort, TargetTLS: p.TargetTLS, TargetCA: p.TargetCA, TargetCAAssetID: p.TargetCAAssetID, AuthSource: p.AuthSource, Outbound: p.Outbound, SSH: TargetSSH{TargetHostKey: p.SSH.TargetHostKey, IgnoreHostKey: p.SSH.IgnoreHostKey}}
return Target{ConnectionOptions: p.ConnectionOptions, Color: p.Color, Remark: p.Remark, ShowRemark: p.ShowRemark, Database: p.Database, Project: p.Project, Environment: p.Environment, Cluster: p.Cluster, OutboundID: p.OutboundID, TargetCompression: p.TargetCompression, ID: p.TargetID, Name: p.Name, Group: p.Group, Type: p.Type, TargetHost: p.TargetHost, TargetPort: p.TargetPort, TargetTLS: p.TargetTLS, TargetCA: p.TargetCA, TargetCAAssetID: p.TargetCAAssetID, AuthSource: p.AuthSource, Outbound: p.Outbound, SSH: TargetSSH{TargetHostKey: p.SSH.TargetHostKey, IgnoreHostKey: p.SSH.IgnoreHostKey}}
}
func (t Target) Apply(p Proxy) Proxy {
p.ConnectionOptions = t.ConnectionOptions
p.Database = t.Database
p.Cluster = t.Cluster
p.TargetAccounts = append([]TargetAccount(nil), t.Accounts...)
@@ -83,6 +88,7 @@ func (t *Target) Validate() error {
p = accounts[0].Apply(p)
}
p.Name, p.Type = t.Name, t.Type
p.Color, p.Remark, p.ShowRemark = t.Color, t.Remark, t.ShowRemark
p.Project, p.Environment = t.Project, t.Environment
p.ListenPort = 1
p.AllowCIDRs = []string{"127.0.0.1"}
@@ -104,6 +110,7 @@ func (t *Target) Validate() error {
func (t Target) Runtime() Proxy {
p := t.Apply(Defaults())
p.ID, p.Name, p.Type = t.ID, t.Name, t.Type
p.Color, p.Remark, p.ShowRemark = t.Color, t.Remark, t.ShowRemark
p.Project, p.Environment = t.Project, t.Environment
if IsSSH(p.Type) {
p.AccessMode = ""
+6
View File
@@ -15,6 +15,9 @@ func Defaults() Proxy {
return Proxy{Project: DefaultProject, Environment: DefaultEnvironment, ListenHost: "0.0.0.0", MaxConnections: 100, TargetTLS: true, AccessMode: "readwrite", AuthSource: "admin", Outbound: Outbound{Mode: "direct", Auth: "none"}, SSH: SSHConfig{AuthMethod: "password"}}
}
func (p *Proxy) Validate() error {
if err := p.ValidateConnectionOptions(); err != nil {
return err
}
if len(p.Database) > 128 || strings.ContainsAny(p.Database, "\r\n\x00") {
return errors.New("invalid default database")
}
@@ -244,6 +247,9 @@ func exactFields(patch map[string]any, t reflect.Type) error {
return errors.New("unknown configuration field: " + k)
}
if v == nil {
if ft.Kind() == reflect.Ptr {
continue
}
return errors.New("configuration fields cannot be null")
}
if ft.Kind() == reflect.Struct {
+8 -5
View File
@@ -1,13 +1,13 @@
package outbound
import (
"serviceport/internal/model"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"serviceport/internal/model"
"strconv"
"time"
)
@@ -15,17 +15,20 @@ import (
// Dialer preserves target hostnames through SOCKS5 and never falls back to direct.
func Dialer(p model.Proxy) model.DialFunc {
return func(ctx context.Context, network, address string) (result net.Conn, resultErr error) {
if err := p.ValidateConnectionOptions(); err != nil {
return nil, err
}
if p.Outbound.Mode == "k8s" {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
ctx, cancel := context.WithTimeout(ctx, p.ConnectionOptions.ConnectTimeout(30*time.Second))
defer cancel()
return dialKubernetes(ctx, network, address, p.Outbound)
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
ctx, cancel := context.WithTimeout(ctx, p.ConnectionOptions.ConnectTimeout(5*time.Second))
defer cancel()
if p.Outbound.Mode == "ssh" {
return dialSSH(ctx, network, address, p.Outbound)
return dialSSH(ctx, network, address, p.Outbound, p.ConnectionOptions.KeepAlive())
}
d := net.Dialer{}
d := net.Dialer{KeepAlive: p.ConnectionOptions.KeepAlive()}
if p.Outbound.Mode == "" || p.Outbound.Mode == "direct" {
return d.DialContext(ctx, network, address)
}
+9 -5
View File
@@ -1,12 +1,12 @@
package outbound
import (
"serviceport/internal/model"
"context"
"errors"
"fmt"
"golang.org/x/crypto/ssh"
"net"
"serviceport/internal/model"
"strconv"
"time"
)
@@ -23,11 +23,11 @@ func (c *sshForwardConn) SetDeadline(t time.Time) error { return c.raw.SetD
func (c *sshForwardConn) SetReadDeadline(t time.Time) error { return c.raw.SetReadDeadline(t) }
func (c *sshForwardConn) SetWriteDeadline(t time.Time) error { return c.raw.SetWriteDeadline(t) }
func dialSSH(ctx context.Context, network, address string, o model.Outbound) (net.Conn, error) {
func dialSSH(ctx context.Context, network, address string, o model.Outbound, keepAlive ...time.Duration) (net.Conn, error) {
if network != "tcp" {
return nil, errors.New("SSH forwarding requires TCP")
}
client, raw, err := connectSSH(ctx, o)
client, raw, err := connectSSH(ctx, o, keepAlive...)
if err != nil {
return nil, err
}
@@ -59,12 +59,16 @@ func TestSSH(ctx context.Context, o model.Outbound) (model.TestResult, error) {
return model.TestResult{Version: string(client.ServerVersion()), Fingerprint: o.HostFingerprint}, nil
}
func connectSSH(ctx context.Context, o model.Outbound) (*ssh.Client, net.Conn, error) {
func connectSSH(ctx context.Context, o model.Outbound, keepAlive ...time.Duration) (*ssh.Client, net.Conn, error) {
if o.Auth != "password" || o.HostFingerprint == "" {
return nil, nil, errors.New("SSH forwarding authentication or host fingerprint missing")
}
endpoint := net.JoinHostPort(o.Host, strconv.Itoa(o.Port))
raw, err := (&net.Dialer{}).DialContext(ctx, "tcp", endpoint)
dialer := net.Dialer{}
if len(keepAlive) > 0 {
dialer.KeepAlive = keepAlive[0]
}
raw, err := dialer.DialContext(ctx, "tcp", endpoint)
if err != nil {
return nil, nil, fmt.Errorf("SSH forwarding connection: %w", err)
}
@@ -0,0 +1,57 @@
package mongoproxy
import (
"context"
"net"
"serviceport/internal/model"
"sync/atomic"
"testing"
"time"
)
type slowReadConn struct {
net.Conn
slow *atomic.Bool
}
func (connection slowReadConn) Read(buffer []byte) (int, error) {
if connection.slow.Swap(false) {
time.Sleep(1200 * time.Millisecond)
}
return connection.Conn.Read(buffer)
}
func TestIdleTimeoutDoesNotInterruptActiveQuery(testContext *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var slow atomic.Bool
dial := func(_ context.Context, _, _ string) (net.Conn, error) {
client, backend := net.Pipe()
go fakeMongo(ctx, backend, &atomic.Int32{})
return slowReadConn{Conn: client, slow: &slow}, nil
}
proxy := model.Proxy{Type: "mongodb", TargetHost: "fixture", TargetPort: 27017, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", ConnectionOptions: model.ConnectionOptions{IdleTimeoutSeconds: 1}}
server, client := net.Pipe()
defer client.Close()
done := make(chan error, 1)
go func() { done <- New().Serve(ctx, server, proxy, dial, nil, nil) }()
connection, err := connect(ctx, model.Proxy{TargetHost: "fixture", TargetPort: 27017, TargetUser: "gateway", TargetPassword: "gateway-secret"}, func(context.Context, string, string) (net.Conn, error) { return client, nil })
if err != nil {
testContext.Fatal(err)
}
defer connection.Close()
slow.Store(true)
result, err := exchange(connection, doc(`{"find":"items","filter":{},"$db":"test"}`))
if err != nil || !ok(result) {
testContext.Fatal("active query was interrupted", err)
}
select {
case err := <-done:
failure, valid := err.(net.Error)
if !valid || !failure.Timeout() {
testContext.Fatal("expected idle timeout", err)
}
case <-time.After(3 * time.Second):
testContext.Fatal("idle session stayed open")
}
}
+8 -2
View File
@@ -70,7 +70,7 @@ func connectUpstream(ctx context.Context, p model.Proxy, dial model.DialFunc, au
return nil, err
}
}
dc, cancel := context.WithTimeout(ctx, 5*time.Second)
dc, cancel := context.WithTimeout(ctx, p.ConnectionOptions.ConnectTimeout(5*time.Second))
c, e := dial(dc, "tcp", net.JoinHostPort(p.TargetHost, strconv.Itoa(p.TargetPort)))
cancel()
if e != nil {
@@ -85,7 +85,7 @@ func connectUpstream(ctx context.Context, p model.Proxy, dial model.DialFunc, au
baseConn := c
stop := context.AfterFunc(ctx, func() { baseConn.Close() })
defer stop()
c.SetDeadline(time.Now().Add(15 * time.Second))
c.SetDeadline(time.Now().Add(p.ConnectionOptions.ConnectTimeout(15 * time.Second)))
if p.TargetTLS {
cfg := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: p.TargetHost}
if p.TargetCA != "" {
@@ -331,10 +331,16 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m
}
cursors := map[int64]string{}
for {
if a.authenticated && p.ConnectionOptions.IdleTimeoutSeconds > 0 {
c.SetReadDeadline(time.Now().Add(time.Duration(p.ConnectionOptions.IdleTimeoutSeconds) * time.Second))
}
m, e := readMessage(c)
if e != nil {
return e
}
if a.authenticated {
c.SetReadDeadline(time.Time{})
}
if m.response != 0 || (m.opcode != 2013 && m.opcode != 2004) {
return errors.New("invalid client request header")
}
@@ -0,0 +1,61 @@
package mysqlproxy
import (
"context"
"github.com/go-mysql-org/go-mysql/mysql"
"github.com/go-mysql-org/go-mysql/server"
"net"
"serviceport/internal/model"
"sync/atomic"
"testing"
"time"
)
type captureCharsetConn struct {
net.Conn
charset chan byte
captured bool
}
func (connection *captureCharsetConn) Write(packet []byte) (int, error) {
if !connection.captured && len(packet) >= 13 {
connection.captured = true
connection.charset <- packet[12]
}
return connection.Conn.Write(packet)
}
func TestCharsetSentInHandshake(testContext *testing.T) {
for charset, expected := range map[string]byte{"utf8mb4": 45, "utf8": 33, "latin1": 8, "ascii": 11} {
testContext.Run(charset, func(testContext *testing.T) {
seen := make(chan byte, 1)
dial := func(_ context.Context, _, _ string) (net.Conn, error) {
client, backend := net.Pipe()
go func() {
defer backend.Close()
backend.SetDeadline(time.Now().Add(3 * time.Second))
fixture := server.NewServer("8.0.11", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil)
connection, err := fixture.NewConn(backend, "fixture", "password", &fakeBackend{queries: &atomic.Int32{}})
if err != nil {
return
}
for !connection.Closed() {
if connection.HandleCommand() != nil {
return
}
}
}()
return &captureCharsetConn{Conn: client, charset: seen}, nil
}
proxy := model.Proxy{Type: "mysql", TargetHost: "fixture", TargetPort: 3306, TargetUser: "fixture", TargetPassword: "password", ConnectionOptions: model.ConnectionOptions{Charset: charset}}
connection, err := open(context.Background(), proxy, dial, "")
if err != nil {
testContext.Fatal(err)
}
defer connection.Close()
if got := <-seen; got != expected {
testContext.Fatalf("charset byte=%d, expected=%d", got, expected)
}
})
}
}
+2 -2
View File
@@ -70,7 +70,7 @@ func open(ctx context.Context, p model.Proxy, dial model.DialFunc, db string) (*
return nil, e
}
raw = c
_ = c.SetDeadline(time.Now().Add(15 * time.Second))
_ = c.SetDeadline(time.Now().Add(p.ConnectionOptions.ConnectTimeout(15 * time.Second)))
stop = context.AfterFunc(ctx, func() { _ = c.Close() })
if p.TargetCompression == "zlib" {
var err error
@@ -88,7 +88,7 @@ func open(ctx context.Context, p model.Proxy, dial model.DialFunc, db string) (*
if compress {
c.SetCapability(mysql.CLIENT_COMPRESS)
}
return c.SetCollation("utf8mb4_general_ci")
return c.SetCollation(p.ConnectionOptions.MySQLCollation())
})
if stop != nil {
defer stop()
@@ -0,0 +1,51 @@
package pgproxy
import (
"context"
"github.com/jackc/pgx/v5/pgproto3"
"net"
"serviceport/internal/model"
"testing"
"time"
)
func TestConnectionOptionsSentInStartup(testContext *testing.T) {
seen := make(chan map[string]string, 1)
dial := func(_ context.Context, _, _ string) (net.Conn, error) {
client, backend := net.Pipe()
go func() {
defer backend.Close()
backend.SetDeadline(time.Now().Add(3 * time.Second))
protocol := pgproto3.NewBackend(backend, backend)
message, err := protocol.ReceiveStartupMessage()
if err != nil {
return
}
startup, ok := message.(*pgproto3.StartupMessage)
if !ok {
return
}
seen <- startup.Parameters
protocol.Send(&pgproto3.AuthenticationOk{})
protocol.Send(&pgproto3.ParameterStatus{Name: "server_version", Value: "16.0"})
protocol.Send(&pgproto3.ReadyForQuery{TxStatus: 'I'})
protocol.Flush()
protocol.Receive()
}()
return client, nil
}
proxy := model.Proxy{Type: "pgsql", TargetHost: "fixture", TargetPort: 5432, TargetUser: "fixture", ConnectionOptions: model.ConnectionOptions{ApplicationName: "serviceport-fixture", QueryTimeoutSeconds: 7, ConnectTimeoutSeconds: 2}}
connection, err := open(context.Background(), proxy, dial, "")
if err != nil {
testContext.Fatal(err)
}
defer connection.Close(context.Background())
select {
case startup := <-seen:
if startup["application_name"] != "serviceport-fixture" || startup["statement_timeout"] != "7000" {
testContext.Fatal("startup options missing", startup)
}
case <-time.After(3 * time.Second):
testContext.Fatal("no startup observed")
}
}
+8 -1
View File
@@ -12,6 +12,7 @@ import (
"serviceport/internal/cluster"
"serviceport/internal/model"
"serviceport/internal/protocol/transport"
"strconv"
"strings"
"time"
)
@@ -45,11 +46,17 @@ func open(ctx context.Context, p model.Proxy, dial model.DialFunc, database stri
return nil, err
}
cfg.Fallbacks = nil
cfg.ConnectTimeout = 15 * time.Second
cfg.ConnectTimeout = p.ConnectionOptions.ConnectTimeout(15 * time.Second)
cfg.DialFunc = pgconn.DialFunc(dial)
// Keep DNS resolution on the configured proxy/jump host.
cfg.LookupFunc = func(_ context.Context, host string) ([]string, error) { return []string{host}, nil }
cfg.RuntimeParams = map[string]string{"application_name": "serviceport", "client_encoding": "UTF8"}
if p.ConnectionOptions.ApplicationName != "" {
cfg.RuntimeParams["application_name"] = p.ConnectionOptions.ApplicationName
}
if p.ConnectionOptions.QueryTimeoutSeconds > 0 {
cfg.RuntimeParams["statement_timeout"] = strconv.Itoa(p.ConnectionOptions.QueryTimeoutSeconds * 1000)
}
return pgconn.ConnectConfig(ctx, cfg)
}
func (h *Handler) Test(ctx context.Context, p model.Proxy, dial model.DialFunc) (model.TestResult, error) {
+56
View File
@@ -0,0 +1,56 @@
package store
import (
"reflect"
"serviceport/internal/model"
"testing"
)
func TestConnectionOptionsBackupAndRestore(testContext *testing.T) {
source := configStore(testContext)
target := configTarget()
target.Color, target.Remark, target.ShowRemark = "#123456", "target remark", true
target.ConnectionOptions = model.ConnectionOptions{ConnectTimeoutSeconds: 9, IdleTimeoutSeconds: 60}
gateway := configGateway()
gateway.Type, gateway.TargetID, gateway.TargetAccountID = target.Type, target.ID, target.Accounts[0].ID
gateway.Color, gateway.Remark, gateway.ShowRemark = "#abcdef", "gateway remark", true
if err := source.RestoreBundle(Bundle{Version: 1, Targets: []model.Target{target}, Gateways: []model.Proxy{gateway}}); err != nil {
testContext.Fatal(err)
}
backup, err := source.Backup("fixture-long-passphrase")
if err != nil {
testContext.Fatal(err)
}
bundle, err := OpenBackup(backup, "fixture-long-passphrase")
if err != nil {
testContext.Fatal(err)
}
destination := configStore(testContext)
if err := destination.RestoreBundle(bundle); err != nil {
testContext.Fatal(err)
}
restored, err := destination.GetTarget(target.ID)
if err != nil || restored.Color != target.Color || restored.Remark != target.Remark || !restored.ShowRemark || !reflect.DeepEqual(restored.ConnectionOptions, target.ConnectionOptions) {
testContext.Fatal("backup lost target metadata or options", err)
}
restoredGateway, err := destination.Get(gateway.ID)
if err != nil || restoredGateway.Color != gateway.Color || restoredGateway.Remark != gateway.Remark || !restoredGateway.ShowRemark || !reflect.DeepEqual(restoredGateway.ConnectionOptions, target.ConnectionOptions) {
testContext.Fatal("restored runtime lost identity or inherited options", err)
}
snapshot, _, err := source.SyncSnapshot()
if err != nil {
testContext.Fatal(err)
}
peer := configStore(testContext)
_, revision, err := peer.SyncSnapshot()
if err != nil {
testContext.Fatal(err)
}
if err := peer.ReplaceSyncBundle(snapshot, revision, SyncHash(snapshot)); err != nil {
testContext.Fatal(err)
}
synced, err := peer.GetTarget(target.ID)
if err != nil || !reflect.DeepEqual(synced.ConnectionOptions, target.ConnectionOptions) || synced.Color != target.Color || synced.Remark != target.Remark || !synced.ShowRemark {
testContext.Fatal("sync lost options or metadata", err)
}
}
+2
View File
@@ -0,0 +1,2 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({testDir:'./tests',testMatch:'ui-domain.spec.ts',workers:1});
+257 -145
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, provide, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { theme, themes } from "./theme";
import AppIcon from './components/AppIcon.vue';
import ThemePicker from './components/ThemePicker.vue';
import { useAudit } from "./composables/useAudit";
import { useApi } from "./composables/useApi";
import { usePersistentFilter } from './composables/usePersistentFilter';
@@ -39,6 +40,21 @@ import ScopeFields from './components/ScopeFields.vue';
import WorkspaceScopeFilter from './components/WorkspaceScopeFilter.vue';
import ScopeCatalogManager from './components/ScopeCatalogManager.vue';
import ConnectionURLImport from './components/ConnectionURLImport.vue';
import ConnectionParameters from './components/ConnectionParameters.vue';
import ConnectionAdvanced from './components/ConnectionAdvanced.vue';
import ConnectionIdentity from './components/ConnectionIdentity.vue';
import ResourceSearch from './components/ResourceSearch.vue';
import ResourceTree from './components/ResourceTree.vue';
import ResourceLabel from './components/ResourceLabel.vue';
import WorkspaceHome from './components/WorkspaceHome.vue';
import WorkspaceTabs from './components/WorkspaceTabs.vue';
import WorkspaceTabSwitcher from './components/WorkspaceTabSwitcher.vue';
import ConfigurableTable from './components/ConfigurableTable';
import ResourceActionMenu from './components/ResourceActionMenu.vue';
import type { ResourceAction } from './domain/resourceActions';
import { resourceItems, useRecentResources } from './composables/useResources';
import { useSidebarResize } from './composables/useSidebarResize';
import { useWorkspaceTabs, type WorkspaceTab } from './composables/useWorkspaceTabs';
import { provideWorkspaceScope, provideResourceScope, matchesScope, scopeLabel } from './composables/useWorkspaceScope';
import { outboundName, outboundDescription } from './domain/outbound';
import { gatewayTargetIds, gatewayTargetSummary } from './domain/gatewayTargets';
@@ -77,20 +93,26 @@ const scopedRows = computed(() => workspaceScope.filter(rows.value));
const scopedTargets = computed(() => workspaceScope.filter(targets.value));
const scopedOutbounds = computed(() => workspaceScope.filter(outbounds.value));
const scopedAssets = computed(() => workspaceScope.filter(assets.value));
const allResources = computed(() => resourceItems({targets:targets.value,proxies:rows.value,outbounds:outbounds.value,assets:assets.value}));
const resources = computed(() => allResources.value.filter(item => matchesScope(item, workspaceScope.query())));
const recentResources = useRecentResources(() => resources.value);
const sidebarSize = useSidebarResize();
const workspaceTabs = useWorkspaceTabs();
const assetManager = ref<InstanceType<typeof AssetsManager>>();
const menuItems = computed(() => [
{id:'outbounds',label:'代理',icon:'⇢',keywords:'SOCKS5 SSH K8s Kubernetes'},
{id:'assets',label:'资产管理',icon:'▣',keywords:'文件 密钥 证书 Kubeconfig'},
{id:'targets',label:'目标服务',icon:'◈',count:scopedTargets.value.length},
{id:'overview',label:'运行概览',icon:'▦'},
{id:'proxies',label:'网关服务',icon:'⇄',count:scopedRows.value.length},
{id:'events',label:'连接与审计',icon:'≡'},
{id:'commands',label:'命令审计',icon:'⌘'},
{id:'connections',label:'拖拽连线',icon:'⇢'},
{id:'operations',label:'监控运维',icon:'⚙',keywords:'健康 告警 Token'},
{id:'configuration',label:'配置管理',icon:'▤',keywords:'备份 导出 导入 模板 历史'},
{id:'sync',label:'多端同步',icon:'⇅',keywords:'S3 MinIO'},
{id:'decrypt',label:'密码解密',icon:'⚿',keywords:'Navicat AES'},
{id:'sqlite',label:'SQLite 文件',icon:'▤',keywords:'数据库 本地 SQL 查询'},
{id:'outbounds',label:'代理',icon:'route',keywords:'SOCKS5 SSH K8s Kubernetes'},
{id:'assets',label:'资产管理',icon:'folder',keywords:'文件 密钥 证书 Kubeconfig'},
{id:'targets',label:'目标服务',icon:'database',count:scopedTargets.value.length},
{id:'overview',label:'运行概览',icon:'layout'},
{id:'proxies',label:'网关服务',icon:'server',count:scopedRows.value.length},
{id:'events',label:'连接与审计',icon:'activity'},
{id:'commands',label:'命令审计',icon:'terminal'},
{id:'connections',label:'拖拽连线',icon:'link'},
{id:'operations',label:'监控运维',icon:'settings',keywords:'健康 告警 Token'},
{id:'configuration',label:'配置管理',icon:'files',keywords:'备份 导出 导入 模板 历史'},
{id:'sync',label:'多端同步',icon:'cloud',keywords:'S3 MinIO'},
{id:'decrypt',label:'密码解密',icon:'key',keywords:'Navicat AES'},
{id:'sqlite',label:'SQLite 文件',icon:'file',keywords:'数据库 本地 SQL 查询'},
]);
provide(assetServiceKey, { assets, api, refresh });
const outboundManager = ref<InstanceType<typeof OutboundManager>>();
@@ -136,7 +158,25 @@ const gatewayAccountEditor = ref<InstanceType<typeof GatewayAccounts>>();
watch(tab, async value => {
if (value === 'accounts' && !editingTarget.value) { await nextTick(); gatewayAccountEditor.value?.resetScroll(); }
});
const formGuard = useUnsavedChanges(() => ({form:form.value, count:batchCount.value}), () => drawer.value, {
const connectionURL = ref<InstanceType<typeof ConnectionURLImport>>();
const connectionParams = ref<InstanceType<typeof ConnectionParameters>>();
const pendingURL = ref(''), pendingParameters = ref('');
let preparingConnection = false;
watch(drawer, () => { pendingURL.value = ''; pendingParameters.value = ''; });
async function prepareConnection() {
if (!editingTarget.value) return true;
if (preparingConnection) return false;
preparingConnection = true;
try {
if (pendingURL.value.trim() && pendingParameters.value) {
tab.value = 'target'; ElMessage.warning('URL 和 URL 参数均有未应用修改,请先应用其中一项并核对配置'); return false;
}
if (connectionURL.value?.prepare() === false || connectionParams.value?.prepare() === false) { tab.value = 'target'; return false; }
await nextTick();
return true;
} finally { preparingConnection = false; }
}
const formGuard = useUnsavedChanges(() => ({form:form.value, count:batchCount.value, pendingURL:pendingURL.value, pendingParameters:pendingParameters.value}), () => drawer.value, {
registry:navigationGuard, label:'服务配置', onDiscard:() => { drawer.value = false; form.value = {}; },
});
async function selectFieldIssue(issue: FieldIssue) {
@@ -146,27 +186,110 @@ async function selectFieldIssue(issue: FieldIssue) {
focusFieldIssue(issue, document.querySelector('.config-form') || document);
}
async function navigatePage(next: string) {
if (next === page.value) return;
if (await navigationGuard.confirmNavigation()) page.value = next;
if (next === page.value && !workspaceTabs.current.value?.resourceId) return;
if (!await navigationGuard.confirmNavigation()) return;
rememberWorkspace(); closeEditors();
workspaceTabs.open(next, menuItems.value.find(item => item.id === next)?.label || next);
page.value = next;
await restoreWorkspace();
}
function rememberWorkspace() {
workspaceTabs.remember({search:search.value,kind:kind.value,targetSearch:targetSearch.value,targetGroup:targetGroup.value,targetProtocol:targetProtocol.value});
if (drawer.value && workspaceTabs.current.value) { workspaceTabs.current.value.editorTab = tab.value; workspaceTabs.current.value.editorScroll = document.querySelector('.connection-editor .el-dialog__body')?.scrollTop || 0; }
}
function closeEditors() { drawer.value = false; outboundManager.value?.close(); assetManager.value?.close(); }
function resourceOpened(kind:ResourceLink['kind'],id:string) { const resource = resources.value.find(item => item.kind === kind && item.id === id); if (resource) { if (workspaceTabs.active.value !== resource.key) rememberWorkspace(); workspaceTabs.open(kind,resource.name,id); recentResources.visit({kind,id}); } }
async function restoreWorkspace() {
const current = workspaceTabs.current.value;
if (current?.filters) {
search.value = current.filters.search; kind.value = current.filters.kind;
targetSearch.value = current.filters.targetSearch; targetGroup.value = current.filters.targetGroup; targetProtocol.value = current.filters.targetProtocol;
}
await nextTick(); workspaceTabs.restoreScroll();
}
async function selectWorkspaceTab(item:WorkspaceTab) {
if (workspaceTabs.active.value === item.key) return;
if (item.resourceId) await navigateResource({kind:item.page as ResourceLink['kind'],id:item.resourceId});
else await navigatePage(item.page);
}
async function closeWorkspaceTab(item:WorkspaceTab) {
if (item.pinned) return;
if (workspaceTabs.active.value === item.key) {
if (!await navigationGuard.confirmNavigation()) return;
closeEditors(); workspaceTabs.remove(item.key);
const next = workspaceTabs.current.value;
if (next) {
workspaceTabs.active.value = '';
await selectWorkspaceTab(next);
} else { workspaceTabs.reset('overview','运行概览'); page.value = 'overview'; }
} else workspaceTabs.remove(item.key);
}
function closeOtherTabs() { for (const item of [...workspaceTabs.tabs.value]) if (!item.pinned && item.key !== workspaceTabs.active.value) workspaceTabs.remove(item.key); }
const contextMenu = ref<InstanceType<typeof ResourceActionMenu>>();
const contextResource = ref<ResourceLink>();
const contextActions = computed(() => contextResource.value ? resourceActions(contextResource.value.kind,contextResource.value.id) : []);
function resourceActions(resourceKind:ResourceLink['kind'],id:string):ResourceAction[] {
const row = (resourceKind === 'targets' ? targets.value : rows.value).find(item => item.config.id === id);
if (!row) return [];
const isTarget = resourceKind === 'targets';
const running = isTarget ? rows.value.some(item => item.runtime.running && gatewayTargetIds(item.config).includes(id)) : row.runtime.running;
const blocked = busy.value;
const invoke = (operation:(current:any)=>unknown) => () => {
const current = (isTarget ? targets.value : rows.value).find(item => item.config.id === id);
if (!current || busy.value) return;
return operation(current);
};
const actions:ResourceAction[] = [
{id:'edit',label:'编辑配置',disabled:blocked || running,run:invoke(current => navigateResource({kind:resourceKind,id:current.config.id}))},
{id:'test',label:'测试连接',disabled:blocked,run:invoke(current => action(current,'test'))},
{id:'audit',label:'查看审计',disabled:blocked,run:invoke(async () => { await navigatePage('commands'); if (page.value !== 'commands') return; commandGateway.value = isTarget ? '' : id; commandFilter.target = isTarget ? id : ''; applyCommandFilters(); })},
{id:'secret',label:'查看密码',disabled:blocked,run:invoke(openSecret)},
];
if (isTarget) actions.push({id:'copy',label:'复制创建',disabled:blocked,run:invoke(cloneTarget)});
if (!isTarget) actions.push(
{id:'toggle',label:row.runtime.running ? '停止网关' : '启动网关',disabled:blocked || (!row.runtime.running && !canStart(row)),run:invoke(current => action(current,current.runtime.running ? 'stop' : 'start'))},
{id:'copy',label:'复制创建',disabled:blocked,run:invoke(cloneGateway)},
{id:'example',label:'连接示例',disabled:blocked,run:invoke(showExample)},
{id:'check',label:'启动检查',disabled:blocked,run:invoke(showStartup)},
);
if (row.config.type === 'ssh') actions.push({id:'fingerprint',label:'确认主机指纹',disabled:blocked || running,run:invoke(probe)});
actions.push({id:'delete',label:isTarget ? '删除目标' : '删除代理',danger:true,disabled:blocked || running || (isTarget && row.gatewayIds?.length > 0),run:invoke(remove)});
return actions;
}
async function openContext(resourceKind:ResourceLink['kind'],id:string,event:MouseEvent) { event.preventDefault(); contextResource.value = {kind:resourceKind,id}; await nextTick(); contextMenu.value?.openContext(event); }
async function quickCreate(kind:'targets'|'proxies') { await navigatePage(kind); if (page.value === kind && !navigationGuard.hasChanges.value) openCreate(); }
async function changeWorkspaceScope(project: string, environment: string) {
if (!await navigationGuard.confirmNavigation()) return;
drawer.value = false;
closeEditors(); contextMenu.value?.close(false);
clearSecret(); commandDialog.value = false; selectedCommand.value = undefined;
selectedGateways.value = []; batchResults.value = [];
workspaceScope.project.value = project; workspaceScope.environment.value = environment;
workspaceTabs.reset(page.value,menuItems.value.find(item => item.id === page.value)?.label || page.value);
if (user.value) await refresh();
}
async function navigateResource(link: ResourceLink) {
if (!await navigationGuard.confirmNavigation()) return;
const resource = allResources.value.find(item => item.kind === link.kind && item.id === link.id);
if (!resource) { ElMessage.warning('资源已删除,请刷新后重试'); return; }
if (!matchesScope(resource,workspaceScope.query())) {
try { await ElMessageBox.confirm('此资源属于其他项目或环境,是否切换范围?','切换资源范围',{confirmButtonText:'切换范围',cancelButtonText:'取消'}); } catch { return; }
workspaceScope.project.value = resource.project || '__unassigned__'; workspaceScope.environment.value = resource.environment || '__unassigned__';
workspaceTabs.reset(link.kind,menuItems.value.find(item => item.id === link.kind)?.label || link.kind);
}
rememberWorkspace(); closeEditors();
workspaceTabs.open(link.kind,resource.name,link.id);
recentResources.visit(link);
startupOpen.value = false;
if (link.kind === 'targets') { targetSearch.value = ''; targetGroup.value = ''; targetProtocol.value = ''; }
if (link.kind === 'proxies') { search.value = ''; kind.value = 'all'; }
page.value = link.kind;
await nextTick();
await restoreWorkspace();
if (link.kind === 'outbounds') {
const row = outbounds.value.find(item => item.config.id === link.id);
if (row) outboundManager.value?.open(row);
} else if (link.kind === 'assets') {
const row = assets.value.find(item => item.config.id === link.id);
if (row) assetManager.value?.open(row);
} else if (link.kind === 'targets' || link.kind === 'proxies') {
const row = (link.kind === 'targets' ? targets.value : rows.value).find(item => item.config.id === link.id);
if (!row) { ElMessage.error('该配置已不存在,请刷新后重试'); return; }
@@ -176,9 +299,15 @@ async function navigateResource(link: ResourceLink) {
}
function cloneGateway(row: View) {
openEdit(row); editID.value = ''; selected.value = undefined;
workspaceTabs.open('proxies','网关服务');
form.value.name += ' 副本'; form.value.listenPort = Math.min(65535, form.value.listenPort + 1);
form.value.gatewayAccounts = form.value.gatewayAccounts.map((a:any) => ({...a,id:crypto.randomUUID(),password:''}));
}
function cloneTarget(row:View) {
openEdit(row); editID.value = ''; selected.value = undefined; workspaceTabs.open('targets','目标服务');
form.value.name += ' 副本';
form.value.accounts = form.value.accounts.map((account:any) => ({...account,password:'',privateKey:'',passphrase:'',certificate:''}));
}
async function batchAction(op: 'start' | 'stop') {
if (busy.value) return;
const chosen = selectedGateways.value.filter(row => scopedRows.value.some(current => current.config.id === row.config.id) && (op === 'start' ? !row.runtime.running && canStart(row) : row.runtime.running));
@@ -193,6 +322,8 @@ async function batchAction(op: 'start' | 'stop') {
}
const { events, eventFilter, eventBefore, eventLoading, eventExporting, loadEvents, applyEventFilters, exportEvents, commands, commandBefore, commandProtocol, commandGateway, commandDialog, selectedCommand, commandFilter, commandLoading, commandExporting, loadCommands, applyCommandFilters, exportCommands, commandRefreshing, openCommand, refreshSelectedCommand } = useAudit(api, report, workspaceScope.query);
const testDialog = ref(false), testDetails = ref<[string, string][]>([]), testSucceeded = ref(false);
const overviewEvents = ref<any[]>([]);
watch(workspaceScope.key, () => { overviewEvents.value = []; });
function showTest(row: View, started: number, result: Record<string, unknown>, success: boolean) {
const p = row.config;
testSucceeded.value = success;
@@ -280,7 +411,11 @@ async function api(path: string, method = "GET", body?: unknown) {
return await request(path, method, body);
} catch (error) {
if ((error as { status?: number }).status === 401 && !path.endsWith("reveal-password")) {
++refreshGeneration; resourceLoading.value = false;
closeEditors(); contextMenu.value?.close(false);
user.value = "";
rows.value = []; targets.value = []; outbounds.value = []; assets.value = []; events.value = []; overviewEvents.value = [];
recentResources.clear(); workspaceTabs.reset('proxies','网关服务'); page.value = 'proxies';
commands.value = []; commandDialog.value = false; selectedCommand.value = undefined;
clearSecret();
drawer.value = false;
@@ -293,25 +428,40 @@ function report(e: unknown) {
ElMessage.error(translateAPIError(e));
}
let refreshGeneration = 0;
async function refresh() {
const resourceLoading = ref(false), resourceFailed = ref(false);
async function refresh(mode?:unknown) {
const generation = ++refreshGeneration;
resourceLoading.value = true;
const scopeKey = workspaceScope.key.value;
try {
const [nextRows, nextTargets, nextOutbounds, nextAssets, nextScopes] = await Promise.all([api('proxies'), api('targets'), api('outbounds'), api('assets'), api('configuration/scopes')]);
if (generation !== refreshGeneration) return false;
resourceFailed.value = false;
rows.value = nextRows; targets.value = nextTargets; outbounds.value = nextOutbounds; assets.value = nextAssets;
workspaceScope.catalog.value = {projects: nextScopes.projects, environments: nextScopes.environments};
if (page.value === 'commands') await loadCommands();
if(page.value === 'events') await loadEvents();
recentResources.prune(allResources.value);
for (const item of [...workspaceTabs.tabs.value]) {
if (!item.resourceId) continue;
const resource = resources.value.find(resource => resource.kind === item.page && resource.id === item.resourceId);
if (resource) item.label = resource.name;
else if (workspaceTabs.active.value !== item.key || !navigationGuard.hasChanges.value) {
const active = workspaceTabs.active.value === item.key;
workspaceTabs.remove(item.key);
if (active) { drawer.value = false; workspaceTabs.open(page.value,menuItems.value.find(menu => menu.id === page.value)?.label || page.value); }
}
}
if (page.value === 'commands' && (mode !== 'navigation' || !commands.value.length)) await loadCommands();
if (page.value === 'events' && (mode !== 'navigation' || !events.value.length)) await loadEvents();
if (page.value === "overview") {
const result = await api('events');
if (scopeKey === workspaceScope.key.value && generation === refreshGeneration) events.value = result;
if (scopeKey === workspaceScope.key.value && generation === refreshGeneration) overviewEvents.value = result;
}
return true;
} catch (e) {
if (generation === refreshGeneration) resourceFailed.value = true;
report(e);
return false;
}
} finally { if (generation === refreshGeneration) resourceLoading.value = false; }
}
async function connectServices(gatewayId:string, targetId:string, accountId?:string, gatewayAccountId?:string) {
const target = targets.value.find(row => row.config.id === targetId);
@@ -375,12 +525,16 @@ async function signIn() {
}
}
async function signOut() {
if (!await navigationGuard.confirmNavigation()) return;
if (!await navigationGuard.confirmNavigation()) return;
try {
await api("logout", "POST");
++refreshGeneration; resourceLoading.value = false; closeEditors(); contextMenu.value?.close(false);
user.value = "";
csrf.value = "";
rows.value = [];
targets.value = []; outbounds.value = []; recentResources.clear(); workspaceTabs.reset('proxies','网关服务');
page.value = 'proxies'; events.value = []; overviewEvents.value = [];
assets.value = [];
commands.value = []; commandDialog.value = false; selectedCommand.value = undefined;
clearSecret();
@@ -393,6 +547,7 @@ async function signOut() {
function defaults(type = "mysql") {
return {
name: "",
color: '', remark: '', showRemark: false, connectionOptions: {},
...workspaceScope.defaults(),
type,
targetId: "",
@@ -433,6 +588,7 @@ function defaults(type = "mysql") {
};
}
function openCreate() {
rememberWorkspace(); page.value = page.value === 'targets' ? 'targets' : 'proxies'; workspaceTabs.open(page.value,page.value === 'targets' ? '目标服务' : '网关服务');
fieldIssues.value = [];
batchCount.value = 1;
editingTarget.value = page.value === "targets";
@@ -449,14 +605,19 @@ function changeType(type: string) {
const group = form.value.group;
const project = form.value.project, environment = form.value.environment;
const outboundId = form.value.outboundId || '';
const identity = {color:form.value.color,remark:form.value.remark,showRemark:form.value.showRemark};
form.value = defaults(type);
form.value.name = name;
form.value.group = group;
form.value.project = project; form.value.environment = environment;
form.value.outboundId = outboundId;
Object.assign(form.value,identity);
}
function openEdit(row: View) {
const resourceKind = page.value === 'targets' ? 'targets' : 'proxies';
if (workspaceTabs.active.value !== `${resourceKind}:${row.config.id}`) rememberWorkspace();
workspaceTabs.open(resourceKind,row.config.name,row.config.id); recentResources.visit({kind:resourceKind,id:row.config.id});
page.value = resourceKind;
fieldIssues.value = [];
batchCount.value = 1;
editingTarget.value = page.value === "targets";
@@ -471,11 +632,13 @@ function openEdit(row: View) {
delete form.value.enabled;
delete form.value.ssh.hostKey;
delete form.value.ssh.targetHostKey;
tab.value = editingTarget.value ? "target" : "gateway";
tab.value = workspaceTabs.current.value?.editorTab || (editingTarget.value ? 'target' : 'gateway');
drawer.value = true;
nextTick(() => { const body = document.querySelector('.connection-editor .el-dialog__body'); if (body) body.scrollTop = workspaceTabs.current.value?.editorScroll || 0; });
}
async function save() {
if (busy.value) return;
if (!await prepareConnection()) return;
const options = { assets:assets.value, secretStatus:selected.value?.secretStatus, editing:!!editID.value,
targets: targets.value, targetAccounts:targets.value.find(item => item.config.id === form.value.targetId)?.config.accounts || [] };
fieldIssues.value = editingTarget.value ? validateTarget(form.value, options) : validateGateway(form.value, options);
@@ -501,8 +664,8 @@ async function save() {
if (p.type === "ssh" && clearSaved.sshPassphrase) p.ssh.passphrase = "";
}
const allowed = editingTarget.value
? ["name", "project", "environment", "group", "type", "targetHost", "targetPort", "targetTLS", "targetCA", "targetCAAssetId", "targetCompression", "authSource", "database", "outboundId", "ssh", "accounts", "cluster"]
: ["name", "project", "environment", "type", "targetId", "targetAccountId", "listenHost", "listenPort", "allowCIDRs", "gatewayAccounts", "accessMode", "maxConnections", "ssh"];
? ["name", "project", "environment", "group", "type", "targetHost", "targetPort", "targetTLS", "targetCA", "targetCAAssetId", "targetCompression", "authSource", "database", "outboundId", "ssh", "accounts", "cluster", "connectionOptions", "color", "remark", "showRemark"]
: ["name", "project", "environment", "type", "targetId", "targetAccountId", "listenHost", "listenPort", "allowCIDRs", "gatewayAccounts", "accessMode", "maxConnections", "ssh", "color", "remark", "showRemark"];
for (const key of Object.keys(p)) if (!allowed.includes(key)) delete p[key];
for (const key of Object.keys(p.ssh)) {
if (editingTarget.value ? key !== "ignoreHostKey" : key !== "authorizedKeys") delete p.ssh[key];
@@ -573,17 +736,20 @@ async function action(row: View, op: string) {
}
}
async function remove(row: View) {
if (busy.value) return;
const resource = page.value === 'targets' ? 'targets' : 'proxies';
busy.value = true;
try {
await ElMessageBox.confirm(
`删除「${row.config.name}」及其保存的凭据?`,
page.value === "targets" ? "删除目标服务" : "删除网关",
resource === "targets" ? "删除目标服务" : "删除网关",
{ type: "warning", confirmButtonText: "删除", cancelButtonText: "取消" },
);
await api(`${page.value === "targets" ? "targets" : "proxies"}/${row.config.id}`, "DELETE");
await api(`${resource}/${row.config.id}`, "DELETE");
await refresh();
} catch (e) {
if (e !== "cancel" && e !== "close") report(e);
}
} finally { busy.value = false; }
}
async function probe(row: View) {
busy.value = true;
@@ -679,10 +845,11 @@ const eventNames: Record<string, string> = {
protocol_rejected: "协议操作被拒绝",
};
watch(page, () => {
contextMenu.value?.close(false);
commandDialog.value = false; selectedCommand.value = undefined;
clearSecret();
secretDialog.value = false;
if (user.value) refresh();
if (user.value) refresh('navigation');
});
watch([() => secret.field, secretDialog], () => {
clearSecret();
@@ -715,15 +882,12 @@ onUnmounted(() => {
</script>
<template>
<div class="theme-picker"><label for="theme-select">主题</label><select id="theme-select" v-model="theme" aria-label="主题"><option v-for="item in themes" :key="item.value" :value="item.value">{{ item.label }}</option></select></div>
<ThemePicker v-if="!ready || !user" class="login-theme-picker" />
<div v-if="!ready" class="loading">serviceport · 正在连接…</div>
<div v-else-if="!user" class="login-shell">
<section class="login-story">
<div class="brand">
<span class="brand-mark">
a
<span>›</span>
</span>
<span class="brand-mark"><AppIcon name="network" :size="20" /></span>
serviceport
</div>
<div>
@@ -784,17 +948,16 @@ onUnmounted(() => {
</div>
</section>
</div>
<div v-else class="app-shell">
<div v-else class="app-shell" :style="{'--workspace-sidebar-width':`${sidebarSize.width.value}px`}">
<aside class="sidebar">
<div class="brand">
<span class="brand-mark">
a
<span>›</span>
</span>
<span class="brand-mark"><AppIcon name="network" :size="18" /></span>
serviceport
</div>
<span class="nav-caption">工作空间</span>
<SidebarNavigation :items="menuItems" :active="page" @navigate="navigatePage" />
<ResourceTree :items="resources" :active="workspaceTabs.active.value" @navigate="navigateResource" />
<div class="sidebar-resizer" role="separator" tabindex="0" aria-label="调整侧栏宽度" aria-orientation="vertical" :aria-valuenow="sidebarSize.width.value" :aria-valuemin="192" :aria-valuemax="420" @pointerdown="sidebarSize.start" @keydown.left.prevent="sidebarSize.set(sidebarSize.width.value-16)" @keydown.right.prevent="sidebarSize.set(sidebarSize.width.value+16)" @keydown.home.prevent="sidebarSize.set(224)"></div>
<div class="sidebar-bottom">
<div class="online">
<b></b>
@@ -806,29 +969,30 @@ onUnmounted(() => {
<strong>{{ user }}</strong>
<small>{{ authEnabled ? '个人账号' : '个人模式' }}</small>
</div>
<button v-if="authEnabled" title="退出登录" aria-label="退出登录" @click="signOut">↪</button>
<button v-if="authEnabled" title="退出登录" aria-label="退出登录" @click="signOut"><AppIcon name="logout" /></button>
</div>
</div>
</aside>
<main class="workspace">
<header class="topbar">
<span>
工作空间
<b>/</b>
{{ title }}
<div class="workspace-location">
<span class="workspace-location__root">工作空间</span>
<b aria-hidden="true">/</b>
<span class="workspace-location__page">{{ title }}</span>
<VersionInfo :api="api" />
</span>
<span class="environment">
<i></i>
{{ authEnabled ? 'LOCAL WORKSPACE' : '个人模式 · 本机免登录' }}
</span>
</div>
<div class="workspace-tools">
<ResourceSearch :items="resources" :enabled="!!user" :loading="resourceLoading" :failed="resourceFailed" @retry="refresh" @navigate="navigateResource" />
<span class="environment"><i></i>{{ authEnabled ? 'LOCAL WORKSPACE' : '个人模式 · 本机免登录' }}</span>
<ThemePicker />
</div>
</header>
<WorkspaceTabs :tabs="workspaceTabs.tabs.value" :active="workspaceTabs.active.value" :dirty="navigationGuard.hasChanges.value" @select="selectWorkspaceTab" @close="closeWorkspaceTab" @pin="$event.pinned = !$event.pinned" @others="closeOtherTabs" />
<div class="content">
<WorkspaceScopeFilter :project="workspaceScope.project.value" :environment="workspaceScope.environment.value" :projects="workspaceScope.projects.value" :environments="workspaceScope.environments.value" :disabled="busy" @change="changeWorkspaceScope" @manage="scopeCatalogManager?.open()" />
<ScopeCatalogManager ref="scopeCatalogManager" :api="api" @changed="workspaceScope.catalog.value = $event" />
<div class="page-heading">
<div>
<span class="eyebrow">CONNECTION WORKSPACE</span>
<h1>{{ title }}</h1>
<p>
{{
@@ -858,8 +1022,9 @@ onUnmounted(() => {
<OpsPanel v-if="page === 'operations'" :key="workspaceScope.key.value" :api="api" :targets="scopedTargets" :gateways="scopedRows" />
<ConfigurationManager v-if="page === 'configuration'" :key="workspaceScope.key.value" :api="api" :targets="scopedTargets" :gateways="scopedRows" :outbounds="scopedOutbounds" :assets="scopedAssets" @changed="refresh" />
<S3SyncManager v-if="page === 'sync'" :api="api" @changed="refresh" />
<OutboundManager ref="outboundManager" v-if="page === 'outbounds'" :key="workspaceScope.key.value" :profiles="scopedOutbounds" :targets="targets" :gateways="rows" :assets="assets" :api="api" @changed="refresh" />
<AssetsManager v-if="page === 'assets'" :key="workspaceScope.key.value" :assets="scopedAssets" :outbounds="outbounds" :targets="targets" :gateways="rows" :api="api" @changed="refresh" @navigate="navigateResource" />
<OutboundManager ref="outboundManager" v-if="page === 'outbounds'" :key="workspaceScope.key.value" :profiles="scopedOutbounds" :targets="targets" :gateways="rows" :assets="assets" :api="api" @opened="resourceOpened('outbounds',$event)" @changed="refresh" ><template #workspace><WorkspaceTabSwitcher :tabs="workspaceTabs.tabs.value" :active="workspaceTabs.active.value" @select="selectWorkspaceTab" @close="closeWorkspaceTab" /></template></OutboundManager>
<AssetsManager ref="assetManager" v-if="page === 'assets'" :key="workspaceScope.key.value" :assets="scopedAssets" :outbounds="outbounds" :targets="targets" :gateways="rows" :api="api" @opened="resourceOpened('assets',$event)" @changed="refresh" @navigate="navigateResource" ><template #workspace><WorkspaceTabSwitcher :tabs="workspaceTabs.tabs.value" :active="workspaceTabs.active.value" @select="selectWorkspaceTab" @close="closeWorkspaceTab" /></template></AssetsManager>
<WorkspaceHome v-if="page === 'overview'" :recent="recentResources.recent.value" :failures="resources.filter(item => item.kind === 'proxies' && item.status === '异常')" @navigate="navigateResource" @create="quickCreate" @page="navigatePage" @clear="recentResources.clear" />
<section v-if="page === 'targets'" class="target-grid">
<div class="target-filter" style="display: flex; flex-wrap: wrap; gap: 12px">
<el-input v-model="targetSearch" clearable aria-label="搜索目标服务" placeholder="搜索名称、主机或账号" style="width:260px" />
@@ -867,8 +1032,8 @@ onUnmounted(() => {
<el-select v-model="targetProtocol" aria-label="目标协议" placeholder="全部协议" clearable style="width: 200px; max-width: 100%"><el-option v-for="(name, protocol) in protocolNames" :key="protocol" :label="name" :value="protocol" /></el-select>
<el-select v-model="targetGroup" aria-label="目标分组" placeholder="全部分组" clearable style="width: 200px; max-width: 100%"><el-option label="未分组" value="__ungrouped__" /><el-option v-for="group in targetGroups" :key="group" :label="group" :value="group" /></el-select>
</div>
<article v-for="target in visibleTargets" :key="target.config.id" class="target-card">
<div class="section-heading"><h3>{{ target.config.name }}</h3><span>{{ target.config.group || '未分组' }} · {{ target.config.type }}</span></div>
<article v-for="target in visibleTargets" :key="target.config.id" class="target-card" @contextmenu="openContext('targets',target.config.id,$event)">
<div class="section-heading"><h3><ResourceLabel :name="target.config.name" :color="target.config.color" :remark="target.config.remark" :show-remark="target.config.showRemark" /></h3><span>{{ target.config.group || '未分组' }} · {{ target.config.type }}</span></div>
<p>{{ scopeLabel(target.config) }}</p>
<p><code>{{ target.config.targetHost }}:{{ target.config.targetPort }}</code></p>
<p v-if="target.config.cluster?.mode && target.config.cluster.mode !== 'single'">{{clusterLabels[target.config.cluster.mode]}} · {{1 + (target.config.cluster.nodes?.length || 0)}} 个种子节点</p>
@@ -880,11 +1045,12 @@ onUnmounted(() => {
<p v-if="target.config.type !== 'ssh'">传输压缩:{{ target.config.targetCompression === 'zlib' ? 'zlib(按目标支持情况协商)' : '关闭' }}</p>
<p v-if="['ssh','sftp'].includes(target.config.type)">主机指纹:{{ target.config.ssh?.ignoreHostKey ? '已忽略校验' : target.config.ssh?.targetHostKey ? '已确认' : '待确认' }}</p>
<div class="target-actions">
<el-button @click="openEdit(target)">配置目标</el-button>
<ResourceActionMenu :items="resourceActions('targets',target.config.id)" />
<el-button :disabled="resourceActions('targets',target.config.id).find(item => item.id === 'edit')?.disabled" @click="openEdit(target)">配置目标</el-button>
<el-button @click="action(target, 'test')" :disabled="busy">测试连接</el-button>
<el-button v-if="target.config.type === 'ssh'" @click="probe(target)" :disabled="busy">确认指纹</el-button>
<el-button @click="openSecret(target)">查看密码</el-button>
<el-button type="danger" plain :disabled="target.gatewayIds.length > 0" @click="remove(target)">删除目标</el-button>
<el-button v-if="target.config.type === 'ssh'" @click="probe(target)" :disabled="resourceActions('targets',target.config.id).find(item => item.id === 'fingerprint')?.disabled">确认指纹</el-button>
<el-button :disabled="busy" @click="openSecret(target)">查看密码</el-button>
<el-button type="danger" plain :disabled="busy || target.gatewayIds.length > 0" @click="remove(target)">删除目标</el-button>
</div>
</article>
<div v-if="!visibleTargets.length" class="empty-guide"><h3>{{ targets.length ? '没有匹配的目标服务' : '先添加一个目标服务' }}</h3><el-button @click="targets.length ? (targetSearch = '', targetGroup = '', targetProtocol = '') : openCreate()">{{ targets.length ? '清除筛选' : '新建目标' }}</el-button></div>
@@ -965,14 +1131,15 @@ onUnmounted(() => {
aria-label="搜索代理"
/>
</div>
<el-table
<ConfigurableTable table-id="gateways" :columns="[{id:'name',label:'代理名称',width:210,required:true},{id:'target',label:'目标服务',width:190},{id:'status',label:'状态',width:115},{id:'mode',label:'访问 / 出口',width:145},{id:'actions',label:'操作',width:250,required:true,fixed:true}]"
:data="filtered"
@row-contextmenu="(row:any, _column:any, event:MouseEvent) => openContext('proxies',row.config.id,event)"
@selection-change="selectedGateways = $event"
empty-text="暂无匹配的代理连接"
class="proxy-table"
>
<el-table-column type="selection" width="42" />
<el-table-column label="代理名称" min-width="210">
<el-table-column column-key="name" label="代理名称" min-width="210">
<template #default="{ row }">
<div class="proxy-name">
<span class="type-icon" :class="row.config.type">
@@ -985,7 +1152,7 @@ onUnmounted(() => {
}}
</span>
<div>
<strong>{{ row.config.name }}</strong>
<strong><ResourceLabel :name="row.config.name" :color="row.config.color" :remark="row.config.remark" :show-remark="row.config.showRemark" /></strong>
<small>{{ scopeLabel(row.config) }}</small>
<small>
{{
@@ -997,7 +1164,7 @@ onUnmounted(() => {
</div>
</template>
</el-table-column>
<el-table-column label="目标服务" min-width="190">
<el-table-column column-key="target" label="目标服务" min-width="190">
<template #default="{ row }">
<code>
{{ gatewayTargetSummary(row.config, targets) }}
@@ -1007,7 +1174,7 @@ onUnmounted(() => {
</small>
</template>
</el-table-column>
<el-table-column label="状态" width="115">
<el-table-column column-key="status" label="状态" width="115">
<template #default="{ row }">
<span
class="status"
@@ -1027,7 +1194,7 @@ onUnmounted(() => {
</span>
</template>
</el-table-column>
<el-table-column label="访问 / 出口" width="145">
<el-table-column column-key="mode" label="访问 / 出口" width="145">
<template #default="{ row }">
<span class="mode-tag">
{{
@@ -1051,7 +1218,7 @@ onUnmounted(() => {
</small>
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<el-table-column column-key="actions" label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button
link
@@ -1063,53 +1230,23 @@ onUnmounted(() => {
</el-button>
<el-button
link
:disabled="row.runtime.running"
:disabled="busy || row.runtime.running"
@click="openEdit(row)"
>
配置
</el-button>
<el-button v-for="id in gatewayTargetIds(row.config)" :key="id" link type="primary" :disabled="busy" @click="navigateResource({kind:'targets',id})">维护目标{{ gatewayTargetIds(row.config).length > 1 ? ' · ' + (targets.find(t => t.config.id === id)?.config.name || id) : '' }}</el-button>
<el-button link type="primary" :disabled="busy" @click="gatewayTargetSwitch?.open(row.config.id)">切换目标</el-button>
<el-dropdown trigger="click">
<el-button link aria-label="更多操作">•••</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="action(row, 'test')">
测试连接
</el-dropdown-item>
<el-dropdown-item @click="showStartup(row)">启动检查</el-dropdown-item>
<el-dropdown-item @click="showExample(row)">
连接示例
</el-dropdown-item>
<el-dropdown-item @click="cloneGateway(row)">复制创建</el-dropdown-item>
<el-dropdown-item @click="openSecret(row)">
查看密码
</el-dropdown-item>
<el-dropdown-item
v-if="row.config.type === 'ssh'"
:disabled="row.runtime.running"
@click="probe(row)"
>
探测 / 确认主机指纹
</el-dropdown-item>
<el-dropdown-item
divided
:disabled="row.runtime.running"
@click="remove(row)"
>
删除代理
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<ResourceActionMenu :items="resourceActions('proxies',row.config.id)" />
</template>
</el-table-column>
</el-table>
</ConfigurableTable>
<div class="mobile-proxy-list">
<article
v-for="row in filtered"
:key="row.config.id"
class="mobile-proxy-card"
@contextmenu="openContext('proxies',row.config.id,$event)"
>
<el-checkbox :aria-label="`选择 ${row.config.name}`" :model-value="selectedGateways.some(s=>s.config.id===row.config.id)" @update:model-value="selectedGateways = $event ? [...selectedGateways,row] : selectedGateways.filter(s=>s.config.id!==row.config.id)">选择此网关</el-checkbox>
<div class="mobile-card-heading">
@@ -1118,7 +1255,7 @@ onUnmounted(() => {
{{ row.config.type === "ssh" ? ">_" : "M" }}
</span>
<div>
<strong>{{ row.config.name }}</strong>
<strong><ResourceLabel :name="row.config.name" :color="row.config.color" :remark="row.config.remark" :show-remark="row.config.showRemark" /></strong>
<small>{{ scopeLabel(row.config) }}</small>
<small>
{{ row.config.type }} · {{ gatewayNames(row) }}
@@ -1174,44 +1311,14 @@ onUnmounted(() => {
{{ row.runtime.running ? "停止" : "启动" }}
</el-button>
<el-button
:disabled="row.runtime.running"
:disabled="busy || row.runtime.running"
@click="openEdit(row)"
>
配置
</el-button>
<el-button v-for="id in gatewayTargetIds(row.config)" :key="id" :disabled="busy" @click="navigateResource({kind:'targets',id})">维护目标{{ gatewayTargetIds(row.config).length > 1 ? ' · ' + (targets.find(t => t.config.id === id)?.config.name || id) : '' }}</el-button>
<el-button :disabled="busy" @click="gatewayTargetSwitch?.open(row.config.id)">切换目标</el-button>
<el-dropdown trigger="click">
<el-button aria-label="更多操作">•••</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="action(row, 'test')">
测试连接
</el-dropdown-item>
<el-dropdown-item @click="showStartup(row)">启动检查</el-dropdown-item>
<el-dropdown-item @click="showExample(row)">
连接示例
</el-dropdown-item>
<el-dropdown-item @click="openSecret(row)">
查看密码
</el-dropdown-item>
<el-dropdown-item
v-if="row.config.type === 'ssh'"
:disabled="row.runtime.running"
@click="probe(row)"
>
探测 / 确认主机指纹
</el-dropdown-item>
<el-dropdown-item
divided
:disabled="row.runtime.running"
@click="remove(row)"
>
删除代理
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<ResourceActionMenu :items="resourceActions('proxies',row.config.id)" />
</div>
</article>
<p v-if="rows.length && !filtered.length" class="form-help">
@@ -1249,21 +1356,21 @@ onUnmounted(() => {
<h3>最近活动</h3>
<span>按天分库 · 保留 7 天 · 每页 300 条</span>
</div>
<el-table
:data="page === 'overview' ? events.slice(0, 6) : events"
<ConfigurableTable table-id="events" :columns="[{id:'time',label:'时间',width:200,required:true},{id:'category',label:'事件',width:170},{id:'gateway',label:'代理',width:160},{id:'source',label:'来源 IP',width:150},{id:'detail',label:'结果 / 规则',width:200}]"
:data="page === 'overview' ? overviewEvents.slice(0, 6) : events"
empty-text="暂无活动记录"
>
<el-table-column label="时间" width="200">
<el-table-column column-key="time" label="时间" width="200">
<template #default="{ row }">
{{ new Date(row.at).toLocaleString("zh-CN") }}
</template>
</el-table-column>
<el-table-column label="事件" min-width="170">
<el-table-column column-key="category" label="事件" min-width="170">
<template #default="{ row }">
{{ eventNames[row.category] || row.category }}
</template>
</el-table-column>
<el-table-column label="代理" min-width="160">
<el-table-column column-key="gateway" label="代理" min-width="160">
<template #default="{ row }">
{{
rows.find((p) => p.config.id === row.proxyId)?.config.name || targets.find(t=>t.config.id===row.proxyId)?.config.name ||
@@ -1271,13 +1378,13 @@ onUnmounted(() => {
}}
</template>
</el-table-column>
<el-table-column prop="source" label="来源 IP" width="150" />
<el-table-column column-key="source" prop="source" label="来源 IP" width="150" />
<el-table-column
prop="detail"
column-key="detail" prop="detail"
label="结果 / 规则"
min-width="200"
/>
</el-table>
</ConfigurableTable>
<div v-if="page==='events'" class="table-footer"><span>已加载 {{events.length}} 条;导出当前已应用条件的全部记录</span><el-button v-if="eventBefore" :loading="eventLoading" @click="loadEvents(true)">加载更早记录</el-button></div>
</section>
<footer class="workspace-footer">
@@ -1292,6 +1399,7 @@ onUnmounted(() => {
<el-dialog v-model="exampleOpen" title="客户端连接示例" width="min(720px, 94vw)" destroy-on-close>
<ConnectionExamples v-if="exampleRow" :row="exampleRow" />
</el-dialog>
<ResourceActionMenu ref="contextMenu" :items="contextActions" context-only />
<GatewayTargetSwitch ref="gatewayTargetSwitch" :key="workspaceScope.key.value" :gateways="scopedRows" :targets="scopedTargets" :api="api" :editable="true" @changed="refresh" />
<el-dialog v-model="startupOpen" title="启动前检查" width="min(640px, 94vw)" destroy-on-close>
<StartupChecklist v-if="startupCurrent" :name="startupCurrent.config.name" :issues="startupProblems" :host-identity="startupHostIdentity" :busy="busy" :running="startupCurrent.runtime.running" @navigate="navigateResource" @start="action(startupCurrent, 'start')" />
@@ -1303,10 +1411,12 @@ onUnmounted(() => {
:before-close="formGuard.requestClose"
:title="`${editID ? '编辑' : '新建'}${editingTarget ? '目标服务' : '网关服务'}`"
>
<template #workspace><WorkspaceTabSwitcher :tabs="workspaceTabs.tabs.value" :active="workspaceTabs.active.value" @select="selectWorkspaceTab" @close="closeWorkspaceTab" /></template>
<el-form v-if="drawer" label-position="top" class="config-form">
<ValidationErrors :issues="fieldIssues" @select="selectFieldIssue" />
<div v-show="tab === (editingTarget ? 'target' : 'gateway')">
<ConnectionURLImport v-if="editingTarget" v-model="form" />
<ConnectionURLImport v-if="editingTarget" ref="connectionURL" v-model="form" @pending="pendingURL = $event" />
<ConnectionParameters v-if="editingTarget" ref="connectionParams" v-model="form" @pending="pendingParameters = $event" />
<ScopeFields v-else :value="form" :issues="fieldIssues" />
<el-form-item data-field="name" :label="editingTarget ? '目标名称' : '网关名称'">
<el-input
@@ -1327,6 +1437,7 @@ onUnmounted(() => {
<el-radio-button value="ssh">SSH</el-radio-button><el-radio-button value="sftp">SFTP</el-radio-button><el-radio-button value="http">HTTP</el-radio-button><el-radio-button value="https">HTTPS</el-radio-button><el-radio-button value="sqlserver">SQL Server</el-radio-button><el-radio-button value="redis">Redis</el-radio-button><el-radio-button value="rabbitmq">RabbitMQ</el-radio-button><el-radio-button value="pgsql">PostgreSQL</el-radio-button>
</el-radio-group>
</el-form-item>
<ConnectionIdentity v-model="form" />
<el-form-item v-if="!editingTarget" label="关联目标服务">
<el-select v-model="form.targetId" clearable @change="form.targetAccountId = targets.find(t => t.config.id === form.targetId)?.config.accounts?.length === 1 ? targets.find(t => t.config.id === form.targetId).config.accounts[0].id : ''; form.gatewayAccounts.forEach((a:any) => { if (!a.targetId) a.targetAccountId = '' })" placeholder="可在账号页分别选择目标" filterable aria-label="关联目标服务">
<el-option value="" label="暂不关联" />
@@ -1390,6 +1501,7 @@ onUnmounted(() => {
<p class="form-help">选择已有 SSH 隧道、SOCKS5 或 Kubernetes 代理;使用“直接连接”时直接访问目标。代理凭据与主机身份在代理管理中维护。</p>
</el-tab-pane>
<el-tab-pane v-if="editingTarget" label="高级" name="advanced">
<ConnectionAdvanced v-model="form" :outbounds="outbounds" />
<ScopeFields :value="form" :issues="fieldIssues" />
<el-form-item label="目标分组(可选)">
<el-select v-model="form.group" aria-label="目标所属分组" filterable allow-create default-first-option clearable placeholder="选择或输入分组" style="width: 100%"><el-option v-for="group in targetGroups" :key="group" :label="group" :value="group" /></el-select>
@@ -1472,7 +1584,7 @@ onUnmounted(() => {
</el-tabs>
</el-form>
<template #footer>
<TargetDraftTest v-if="editingTarget && drawer" :draft="form" :target-id="editID" :initial-account-id="targetAccountSelection[editID]" :busy="busy" :api="api" :assets="assets" :outbounds="outbounds" :secret-status="selected?.secretStatus" @testing="busy = $event" @invalid="targetTestIssues" @tested="showTest" />
<TargetDraftTest v-if="editingTarget && drawer" :draft="form" :prepare="prepareConnection" :pending="pendingURL + pendingParameters" :target-id="editID" :initial-account-id="targetAccountSelection[editID]" :busy="busy" :api="api" :assets="assets" :outbounds="outbounds" :secret-status="selected?.secretStatus" @testing="busy = $event" @invalid="targetTestIssues" @tested="showTest" />
<el-button @click="formGuard.requestClose(() => drawer = false)">取消</el-button>
<el-button type="primary" :loading="busy" @click="save">
保存配置
+4 -4
View File
@@ -77,10 +77,10 @@ const edges=computed(()=>props.gateways.flatMap((g,i)=>{
<style scoped>
.connection-selection{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;min-height:32px;color:var(--el-text-color-secondary);font-size:13px}.connection-node small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.target-account{position:absolute;right:0;width:34%;height:32px;text-align:left;padding:4px 12px;border:1px solid var(--el-border-color);border-radius:6px;background:var(--el-bg-color);color:var(--el-text-color-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}.target-account.compatible{border-color:var(--el-color-primary)}.target-account:focus-visible{outline:2px solid var(--el-color-primary);outline-offset:2px}
.target-account{position:absolute;right:0;width:34%;height:32px;text-align:left;padding:4px 12px;border:1px solid var(--el-border-color);border-radius: var(--app-radius-panel);background:var(--el-bg-color);color:var(--el-text-color-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}.target-account.compatible{border-color:var(--el-color-primary)}.target-account:focus-visible{outline:2px solid var(--el-color-primary);outline-offset:2px}
.target-link-action{position:absolute;right:0;width:34%}
.gateway-account{position:absolute;left:0;width:34%;height:32px;text-align:left;padding:4px 12px;border:1px solid var(--el-border-color);border-radius:6px;background:var(--el-bg-color);color:var(--el-text-color-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:grab}.gateway-account.selected{border:2px solid var(--el-color-primary)}.gateway-account:disabled{opacity:.6;cursor:not-allowed}.gateway-account:focus-visible{outline:2px solid var(--el-color-primary);outline-offset:2px}.connection-lines path.selected{stroke-width:3;opacity:1}
.gateway-account{position:absolute;left:0;width:34%;height:32px;text-align:left;padding:4px 12px;border:1px solid var(--el-border-color);border-radius: var(--app-radius-panel);background:var(--el-bg-color);color:var(--el-text-color-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:grab}.gateway-account.selected{border:2px solid var(--el-color-primary)}.gateway-account:disabled{opacity:.6;cursor:not-allowed}.gateway-account:focus-visible{outline:2px solid var(--el-color-primary);outline-offset:2px}.connection-lines path.selected{stroke-width:3;opacity:1}
.gateway-link-action{flex-wrap:wrap}
.gateway-link-action{position:absolute;left:0;width:34%;display:flex;align-items:center;gap:8px;font-size:12px;color:var(--el-text-color-secondary)}.disconnect-button{padding:3px 8px;border:1px solid var(--el-color-danger);border-radius:4px;background:var(--el-bg-color);color:var(--el-color-danger);cursor:pointer}.disconnect-button:disabled{opacity:.5;cursor:not-allowed}.disconnect-button:focus-visible{outline:2px solid var(--el-color-danger);outline-offset:2px}
.connection-board{padding:20px}.connection-labels{display:flex;justify-content:space-between;margin:18px 0}.connection-scroll{overflow:auto}.connection-canvas{position:relative;min-width:600px}.connection-lines{position:absolute;width:100%;height:100%;pointer-events:none}.connection-lines path{fill:none;stroke:var(--el-color-primary);stroke-width:2;opacity:.55}.connection-node{position:absolute;width:34%;height:90px;padding:12px;text-align:left;border:1px solid var(--el-border-color);border-radius:10px;background:var(--el-bg-color);color:var(--el-text-color-primary);display:flex;flex-direction:column;gap:6px;cursor:pointer}.connection-node strong,.connection-node span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.connection-node span,.connection-node small{font-size:12px;color:var(--el-text-color-secondary)}.gateway-node{left:0}.target-node{right:0}.connection-node.selected,.connection-node.compatible{border:2px solid var(--el-color-primary)}.connection-node:disabled{opacity:.6;cursor:not-allowed}.connection-node:focus-visible{outline:2px solid var(--el-color-primary);outline-offset:2px}
.gateway-link-action{position:absolute;left:0;width:34%;display:flex;align-items:center;gap:8px;font-size:12px;color:var(--el-text-color-secondary)}.disconnect-button{padding:3px 8px;border:1px solid var(--el-color-danger);border-radius: var(--app-radius);background:var(--el-bg-color);color:var(--el-color-danger);cursor:pointer}.disconnect-button:disabled{opacity:.5;cursor:not-allowed}.disconnect-button:focus-visible{outline:2px solid var(--el-color-danger);outline-offset:2px}
.connection-board{padding:20px}.connection-labels{display:flex;justify-content:space-between;margin:18px 0}.connection-scroll{overflow:auto}.connection-canvas{position:relative;min-width:600px}.connection-lines{position:absolute;width:100%;height:100%;pointer-events:none}.connection-lines path{fill:none;stroke:var(--el-color-primary);stroke-width:2;opacity:.55}.connection-node{position:absolute;width:34%;height:90px;padding:12px;text-align:left;border:1px solid var(--el-border-color);border-radius: var(--app-radius-panel);background:var(--el-bg-color);color:var(--el-text-color-primary);display:flex;flex-direction:column;gap:6px;cursor:pointer}.connection-node strong,.connection-node span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.connection-node span,.connection-node small{font-size:12px;color:var(--el-text-color-secondary)}.gateway-node{left:0}.target-node{right:0}.connection-node.selected,.connection-node.compatible{border:2px solid var(--el-color-primary)}.connection-node:disabled{opacity:.6;cursor:not-allowed}.connection-node:focus-visible{outline:2px solid var(--el-color-primary);outline-offset:2px}
</style>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
withDefaults(defineProps<{ name: string; size?: number }>(), { size: 16 });
const paths: Record<string, string[]> = {
network: ['M4 4h6v6H4zM14 14h6v6h-6zM14 4h6v6h-6zM4 14h6v6H4zM10 7h4M7 10v4M17 10v4M10 17h4'],
route: ['M4 7h12M12 3l4 4-4 4M20 17H8M12 13l-4 4 4 4'],
folder: ['M3 7V5h6l2 2h10v13H3z'],
database: ['M20 6c0 2-3.6 3-8 3S4 8 4 6s3.6-3 8-3 8 1 8 3zM4 6v12c0 2 3.6 3 8 3s8-1 8-3V6M4 12c0 2 3.6 3 8 3s8-1 8-3'],
layout: ['M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z'],
server: ['M3 3h18v7H3zM3 14h18v7H3zM7 6.5h.01M7 17.5h.01M11 6.5h6M11 17.5h6'],
activity: ['M3 12h4l3-8 4 16 3-8h4'],
terminal: ['M3 4h18v16H3zM7 9l3 3-3 3M13 15h4'],
link: ['M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2'],
settings: ['M4 7h16M4 17h16M8 4v6M16 14v6'],
files: ['M7 3h10l4 4v14H7zM17 3v5h4M3 7v14M11 12h6M11 16h6'],
cloud: ['M7 18H5a4 4 0 0 1-.4-8A7 7 0 0 1 18 8a5 5 0 0 1 1 10h-2M9 15l3-3 3 3M12 12v9'],
key: ['M14 3a7 7 0 0 0-6 10L3 18v3h4v-3h3l2-2a7 7 0 1 0 2-13zM16 7h.01'],
file: ['M5 3h10l4 4v14H5zM15 3v5h4M9 12h6M9 16h6'],
search: ['M10.5 3a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15M16 16l5 5'],
logout: ['M9 4H4v16h5M9 12h12M17 8l4 4-4 4'],
palette: ['M12 3a9 9 0 1 0 0 18h1a2 2 0 0 0 2-2c0-1-1-2-1-3s1-2 2-2h2a3 3 0 0 0 3-3 9 9 0 0 0-9-8zM7 10h.01M10 6h.01M15 6h.01M18 10h.01'],
};
</script>
<template>
<svg class="app-icon" :width="size" :height="size" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path v-for="(path, index) in paths[name] || paths.server" :key="index" :d="path" />
</svg>
</template>
<style scoped>
.app-icon { display: inline-block; flex-shrink: 0; vertical-align: middle; }
</style>
+31 -12
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, nextTick, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import AssetUpload from './AssetUpload.vue';
import ConfigurableTable from './ConfigurableTable';
import ResourceActionMenu from './ResourceActionMenu.vue';
import type { ResourceAction } from '../domain/resourceActions';
import { assetTypes, assetTypeName } from '../domain/assets';
import { useListFilters } from '../composables/useListFilters';
import { useUnsavedChanges } from '../composables/useUnsavedChanges';
@@ -15,7 +18,7 @@ const props = defineProps<{
api: (path: string, method?: string, body?: unknown) => Promise<any>;
}>();
type AssetReference = { kind: 'targets' | 'proxies' | 'outbounds'; id: string; name: string };
const emit = defineEmits<{ changed: []; navigate: [reference: AssetReference] }>();
const emit = defineEmits<{ changed: []; navigate: [reference: AssetReference]; opened:[id:string] }>();
const drawer = ref(false), busy = ref(false), reading = ref(false), editId = ref('');
const form = ref<any>({}), saved = ref(false);
const issues = ref<FieldIssue[]>([]), formElement = ref<any>();
@@ -58,7 +61,20 @@ function open(asset?: any) {
reading.value = false;
issues.value = [];
drawer.value = true;
if (asset) emit('opened',asset.config.id);
}
function close() { drawer.value = false; form.value = {}; }
defineExpose({open,close});
const contextMenu = ref<InstanceType<typeof ResourceActionMenu>>(), contextId = ref('');
function actions(id:string):ResourceAction[] {
const row = props.assets.find(item => item.config.id === id);
if (!row) return [];
return [
{id:'edit',label:'编辑资产',disabled:busy.value || running(row),run:() => { const current=props.assets.find(item => item.config.id === id); if (current && !busy.value && !running(current)) open(current); }},
{id:'delete',label:'删除资产',danger:true,disabled:busy.value || referenced(row),run:() => { const current=props.assets.find(item => item.config.id === id); if (current && !busy.value && !referenced(current)) return remove(current); }},
];
}
async function context(row:any,_column:any,event:MouseEvent) { event.preventDefault(); contextId.value = row.config.id; await nextTick(); contextMenu.value?.openContext(event); }
async function save() {
if (busy.value || reading.value) return;
issues.value = validateAsset(form.value, { editing: !!editId.value, secretStatus: { content: saved.value } });
@@ -97,26 +113,29 @@ async function remove(asset: any) {
<el-select v-model="filters.reference" clearable aria-label="筛选资产引用" placeholder="全部引用状态"><el-option value="referenced" label="已引用" /><el-option value="unreferenced" label="未引用" /></el-select>
<el-button @click="Object.assign(filters, { name: '', type: '', reference: '' })">重置筛选</el-button>
</div>
<el-table :data="filteredAssets" row-key="config.id" empty-text="暂无匹配资产">
<el-table-column label="资产名称" prop="config.name" min-width="180" />
<el-table-column label="项目 / 环境" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ scopeLabel(row.config) }}</template></el-table-column>
<el-table-column label="备注" min-width="200" show-overflow-tooltip><template #default="{ row }">{{ row.config.remark || '—' }}</template></el-table-column>
<el-table-column label="类型" width="170"><template #default="{ row }">{{ assetTypeName(row.config.type) }}</template></el-table-column>
<el-table-column label="文件与更新时间" min-width="200"><template #default="{ row }"><div>{{ row.config.filename || '未记录文件名' }}</div><small>{{ time(row.config.updatedAt) }}</small></template></el-table-column>
<el-table-column label="证书与指纹" min-width="260"><template #default="{ row }">
<ConfigurableTable table-id="assets" :columns="[{id:'name',label:'资产名称',width:180,required:true},{id:'scope',label:'项目 / 环境',width:180},{id:'remark',label:'备注',width:200},{id:'type',label:'类型',width:170},{id:'file',label:'文件与更新时间',width:200},{id:'certificate',label:'证书与指纹',width:260},{id:'references',label:'引用配置',width:200},{id:'actions',label:'操作',width:220,required:true,fixed:true}]" @row-contextmenu="context" :data="filteredAssets" row-key="config.id" empty-text="暂无匹配资产">
<el-table-column column-key="name" label="资产名称" prop="config.name" min-width="180" />
<el-table-column column-key="scope" label="项目 / 环境" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ scopeLabel(row.config) }}</template></el-table-column>
<el-table-column column-key="remark" label="备注" min-width="200" show-overflow-tooltip><template #default="{ row }">{{ row.config.remark || '—' }}</template></el-table-column>
<el-table-column column-key="type" label="类型" width="170"><template #default="{ row }">{{ assetTypeName(row.config.type) }}</template></el-table-column>
<el-table-column column-key="file" label="文件与更新时间" min-width="200"><template #default="{ row }"><div>{{ row.config.filename || '未记录文件名' }}</div><small>{{ time(row.config.updatedAt) }}</small></template></el-table-column>
<el-table-column column-key="certificate" label="证书与指纹" min-width="260"><template #default="{ row }">
<el-tag :type="['已过期', '尚未生效'].includes(validity(row)) ? 'danger' : validity(row) === '30 天内到期' ? 'warning' : 'info'">{{ validity(row) }}</el-tag>
<div v-if="row.summary?.subject" class="asset-detail">{{ row.summary.subject }}</div>
<div v-if="row.summary?.fingerprint" class="asset-detail">{{ row.summary.fingerprint }}</div>
<small v-if="row.summary?.validAfter">生效:{{ time(row.summary.validAfter) }}</small>
<small v-if="row.summary?.expiresAt">到期:{{ time(row.summary.expiresAt) }}</small>
</template></el-table-column>
<el-table-column label="引用配置" min-width="200"><template #default="{ row }"><el-button v-for="reference in references(row)" :key="`${reference.kind}:${reference.id}`" link type="primary" class="asset-reference" @click="emit('navigate', reference)">{{ reference.name }}</el-button><span v-if="!referenced(row)">未引用</span></template></el-table-column>
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }">
<el-table-column column-key="references" label="引用配置" min-width="200"><template #default="{ row }"><el-button v-for="reference in references(row)" :key="`${reference.kind}:${reference.id}`" link type="primary" class="asset-reference" @click="emit('navigate', reference)">{{ reference.name }}</el-button><span v-if="!referenced(row)">未引用</span></template></el-table-column>
<el-table-column column-key="actions" label="操作" width="220" fixed="right"><template #default="{ row }">
<ResourceActionMenu :items="actions(row.config.id)" />
<el-tooltip :content="running(row) ? '请先停止关联网关' : '修改名称、备注或重新上传文件'"><span><el-button :disabled="busy || running(row)" @click="open(row)">编辑资产</el-button></span></el-tooltip>
<el-tooltip :content="referenced(row) ? '请先解除所有配置引用' : '删除资产'"><span><el-button type="danger" plain :disabled="busy || referenced(row)" @click="remove(row)">删除资产</el-button></span></el-tooltip>
</template></el-table-column>
</el-table>
</ConfigurableTable>
<ResourceActionMenu ref="contextMenu" :items="actions(contextId)" context-only />
<el-drawer v-model="drawer" :title="editId ? '编辑资产' : '新建资产'" size="min(680px, 100vw)" destroy-on-close :before-close="guard.requestClose" @closed="form = {}">
<template #header="{titleId,titleClass}"><div><h2 :id="titleId" :class="titleClass">{{ editId ? '编辑资产' : '新建资产' }}</h2><slot name="workspace" /></div></template>
<el-form v-if="drawer" ref="formElement" label-position="top" @submit.prevent="save">
<ValidationErrors :issues="issues" @select="focusIssue" />
<ScopeFields :value="form" :issues="issues" />
+1 -1
View File
@@ -73,5 +73,5 @@ async function detect() {
</section>
</template>
<style scoped>
.cluster-fields{margin:16px 0;padding:16px;border:1px solid var(--el-border-color);border-radius:8px}.cluster-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.cluster-node-row{display:flex;gap:8px;margin-bottom:10px}.cluster-node-row .el-input-number{flex:0 0 130px}.cluster-result{white-space:pre-line;overflow-wrap:anywhere}@media(max-width:600px){.cluster-node-row{flex-wrap:wrap}}
.cluster-fields{margin:16px 0;padding:16px;border:1px solid var(--el-border-color);border-radius: var(--app-radius-panel)}.cluster-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.cluster-node-row{display:flex;gap:8px;margin-bottom:10px}.cluster-node-row .el-input-number{flex:0 0 130px}.cluster-result{white-space:pre-line;overflow-wrap:anywhere}@media(max-width:600px){.cluster-node-row{flex-wrap:wrap}}
</style>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { ref } from 'vue';
import type { ColumnDefinition, useTableLayout } from '../composables/useTableLayout';
const props = defineProps<{definitions:ColumnDefinition[];layout:ReturnType<typeof useTableLayout>}>();
const dragged = ref('');
function drop(id:string) {
const columns = props.layout.columns.value;
const offset = columns.findIndex(column => column.id === id) - columns.findIndex(column => column.id === dragged.value);
if (dragged.value) props.layout.move(dragged.value,offset);
dragged.value = '';
}
</script>
<template>
<div class="column-layout-toolbar"><el-popover placement="bottom-end" :width="350" trigger="click"><template #reference><el-button aria-label="表格列设置">列设置</el-button></template>
<div class="column-layout" aria-label="表格列设置面板">
<p class="form-help">拖动排序或使用上下移按钮;宽度限制 80–800px。标识和操作列不可隐藏。</p>
<div v-for="(column,index) in layout.columns.value" :key="column.id" class="column-setting" :draggable="!definitions.find(item => item.id === column.id)?.fixed" @dragstart="dragged = column.id" @dragover.prevent @drop.prevent="drop(column.id)">
<el-checkbox v-model="column.visible" :disabled="definitions.find(item => item.id === column.id)?.required">{{ definitions.find(item => item.id === column.id)?.label }}</el-checkbox>
<el-input-number :model-value="column.width" @update:model-value="column.width = Math.max(80,Math.min(800,Number($event) || definitions.find(item => item.id === column.id)!.width))" :min="80" :max="800" :precision="0" :aria-label="`${definitions.find(item => item.id === column.id)?.label}列宽`" controls-position="right" size="small" />
<el-button text :disabled="index === 0 || definitions.find(item => item.id === column.id)?.fixed" :aria-label="`上移${definitions.find(item => item.id === column.id)?.label}列`" @click="layout.move(column.id,-1)">↑</el-button>
<el-button text :disabled="index === layout.columns.value.length-1 || definitions.find(item => item.id === column.id)?.fixed" :aria-label="`下移${definitions.find(item => item.id === column.id)?.label}列`" @click="layout.move(column.id,1)">↓</el-button>
</div>
<el-button @click="layout.reset">恢复默认列</el-button>
</div>
</el-popover></div>
</template>
<style scoped>
.column-layout-toolbar { display:flex; justify-content:flex-end; padding:8px; }
.column-layout { max-height:65dvh; overflow:auto; }.column-setting { display:flex; align-items:center; gap:4px; margin-bottom:6px; }.column-setting .el-checkbox { flex:1; margin:0; overflow:hidden; }.column-setting .el-input-number { width:92px; flex:none; }.column-setting .el-button { margin:0; padding:3px; }
</style>
+1 -1
View File
@@ -39,7 +39,7 @@ async function copy() {
.command-detail-tools { margin-top: 14px; }
.command-detail-tools .el-button + .el-button { margin-left: 0; }
.command-detail-tools > span { color: var(--el-text-color-secondary); font-size: 12px; }
.command-full-text { width: 100%; min-width: 0; max-height: 52dvh; margin: 0 0 16px; padding: clamp(10px, 2vw, 18px); border-radius: 8px; background: var(--app-subtle); color: var(--app-text); font: 13px/1.7 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; overflow: auto; tab-size: 4; }
.command-full-text { width: 100%; min-width: 0; max-height: 52dvh; margin: 0 0 16px; padding: clamp(10px, 2vw, 18px); border-radius: var(--app-radius-panel); background: var(--app-subtle); color: var(--app-text); font: 13px/1.7 var(--app-font-mono); white-space: pre-wrap; overflow-wrap: anywhere; overflow: auto; tab-size: 4; }
.command-full-text.nowrap { white-space: pre; overflow-wrap: normal; }
.command-keys { max-height: 25dvh; }
.capture-note { font-size: 12px; line-height: 1.7; color: var(--el-text-color-secondary); margin: 12px 0; }
+10 -9
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ConfigurableTable from './ConfigurableTable';
defineProps<{ records: any[]; gateways: any[]; targets: any[] }>();
defineEmits<{ open: [record: any] }>();
function name(items: any[], id: string) { return items.find(item => item.config.id === id)?.config.name || id || '—'; }
@@ -11,19 +12,19 @@ function preview(record: any) {
<template>
<div class="command-records">
<el-table class="command-desktop" :data="records" empty-text="暂无命令记录;通过网关执行命令后将自动记录。">
<el-table-column label="时间" width="180"><template #default="{ row }">{{ new Date(row.at).toLocaleString('zh-CN') }}</template></el-table-column>
<el-table-column label="网关 / 协议" min-width="150"><template #default="{ row }">{{ name(gateways, row.proxyId) }}<small class="cell-sub">{{ row.protocol }} · {{ row.kind }}</small></template></el-table-column>
<el-table-column label="来源" prop="source" width="135" />
<el-table-column label="目标服务" min-width="130"><template #default="{ row }">{{ name(targets, row.targetId) }}</template></el-table-column>
<el-table-column label="处理" width="100"><template #default="{ row }">{{ decision(row) }}</template></el-table-column>
<el-table-column label="命令 / 请求" min-width="230"><template #default="{ row }">
<ConfigurableTable table-id="commands" :columns="[{id:'time',label:'时间',width:180,required:true},{id:'gateway',label:'网关 / 协议',width:150},{id:'source',label:'来源',width:135},{id:'target',label:'目标服务',width:130},{id:'decision',label:'处理',width:100},{id:'command',label:'命令 / 请求',width:230,required:true}]" class="command-desktop" :data="records" empty-text="暂无命令记录;通过网关执行命令后将自动记录。">
<el-table-column column-key="time" label="时间" width="180"><template #default="{ row }">{{ new Date(row.at).toLocaleString('zh-CN') }}</template></el-table-column>
<el-table-column column-key="gateway" label="网关 / 协议" min-width="150"><template #default="{ row }">{{ name(gateways, row.proxyId) }}<small class="cell-sub">{{ row.protocol }} · {{ row.kind }}</small></template></el-table-column>
<el-table-column column-key="source" label="来源" prop="source" width="135" />
<el-table-column column-key="target" label="目标服务" min-width="130"><template #default="{ row }">{{ name(targets, row.targetId) }}</template></el-table-column>
<el-table-column column-key="decision" label="处理" width="100"><template #default="{ row }">{{ decision(row) }}</template></el-table-column>
<el-table-column column-key="command" label="命令 / 请求" min-width="230"><template #default="{ row }">
<code class="command-preview">{{ preview(row) }}</code>
<el-tag v-if="row.incomplete" size="small" type="warning">待回显确认</el-tag>
<el-tag v-else-if="row.capture === 'terminal_echo'" size="small" type="success">回显已补全</el-tag>
<el-button link type="primary" @click="$emit('open', row)">查看{{ row.truncated ? '(已截断)' : '' }}</el-button>
</template></el-table-column>
</el-table>
</ConfigurableTable>
<div class="command-mobile">
<el-empty v-if="!records.length" description="暂无命令记录" />
<article v-for="record in records" :key="record.id" class="command-item">
@@ -50,7 +51,7 @@ function preview(record: any) {
.command-item dl { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 6px 10px; font-size: 12px; }
.command-item dt { color: var(--el-text-color-secondary); }
.command-item dd { margin: 0; }
.command-item .command-preview { padding: 10px; background: var(--app-subtle); border-radius: 6px; max-height: 100px; }
.command-item .command-preview { padding: 10px; background: var(--app-subtle); border-radius: var(--app-radius-panel); max-height: 100px; }
.command-item-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; }
@container (max-width: 900px) { .command-desktop { display: none; } .command-mobile { display: block; } }
@media (max-width: 720px) { .command-desktop { display: none; } .command-mobile { display: block; } }
+1 -1
View File
@@ -49,7 +49,7 @@ const size = (value: number) => value < 1024 ? `${value} B` : value < 1024 ** 2
.output-tools a { color: var(--el-color-primary); font-size: 13px; overflow-wrap: anywhere; }
.output-note, .output-status > span, .output-channel > span { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; }
.output-channel { min-width: 0; }
pre { width: 100%; min-width: 0; max-height: 40dvh; padding: clamp(10px, 2vw, 18px); border-radius: 8px; background: var(--app-subtle); color: var(--app-text); font: 13px/1.7 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; overflow: auto; tab-size: 4; }
pre { width: 100%; min-width: 0; max-height: 40dvh; padding: clamp(10px, 2vw, 18px); border-radius: var(--app-radius-panel); background: var(--app-subtle); color: var(--app-text); font: 13px/1.7 var(--app-font-mono); white-space: pre-wrap; overflow-wrap: anywhere; overflow: auto; tab-size: 4; }
pre.nowrap { white-space: pre; overflow-wrap: normal; }
@media (max-width: 480px) { .output-tools { gap: 8px; } pre { font-size: 12px; } }
</style>
+27
View File
@@ -0,0 +1,27 @@
import { cloneVNode, defineComponent, Fragment, h as renderNode, type PropType, type VNode } from 'vue';
import { ElTable } from 'element-plus';
import ColumnLayoutPicker from './ColumnLayoutPicker.vue';
import { useTableLayout, type ColumnDefinition } from '../composables/useTableLayout';
function flatten(nodes:VNode[]):VNode[] { return nodes.flatMap(node => node.type === Fragment && Array.isArray(node.children) ? flatten(node.children as VNode[]) : [node]); }
export default defineComponent({
name:'ConfigurableTable', inheritAttrs:false,
props:{tableId:{type:String,required:true},columns:{type:Array as PropType<ColumnDefinition[]>,required:true}},
setup(props,{attrs,slots}) {
const layout = useTableLayout(props.tableId,props.columns);
return () => renderNode('div',{'data-table-id':props.tableId,class:['configurable-table',attrs.class],style:attrs.style},[
renderNode(ColumnLayoutPicker,{definitions:props.columns,layout}),
renderNode(ElTable,{...attrs,class:undefined,style:undefined,onHeaderDragend:layout.resize}, {
...slots,
default:() => {
const nodes = flatten(slots.default?.() || []);
const fixed = nodes.filter(node => node.props?.type === 'selection');
const columns = layout.visible.value.flatMap(column => {
const original = nodes.find(node => (node.props?.['column-key'] || node.props?.columnKey) === column.id);
return original ? [cloneVNode(original,{key:column.id,width:column.width,minWidth:80})] : [];
});
return [...fixed,...columns];
},
}),
]);
},
});
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue';
const form = defineModel<any>({required:true});
const props = defineProps<{outbounds:any[]}>();
const options = computed(() => form.value.connectionOptions || (form.value.connectionOptions = {}));
const kubernetes = computed(() => props.outbounds.find(row => row.config.id === form.value.outboundId)?.config.outbound.mode === 'k8s');
</script>
<template>
<div class="form-grid">
<el-form-item label="网络连接超时(秒,0 使用默认)" data-field="connectionOptions.connectTimeoutSeconds">
<el-input-number v-model="options.connectTimeoutSeconds" :min="0" :max="120" :precision="0" aria-label="网络连接超时" />
</el-form-item>
<el-form-item label="查询超时(秒)" data-field="connectionOptions.queryTimeoutSeconds">
<el-input-number v-model="options.queryTimeoutSeconds" :disabled="form.type !== 'pgsql'" :min="0" :max="86400" :precision="0" aria-label="查询超时" />
<p class="form-help">仅 PostgreSQL:新会话的 statement_timeout;0 不覆盖服务器设置。客户端仍可调整,不是安全策略。</p>
</el-form-item>
<el-form-item label="空闲会话超时(秒)" data-field="connectionOptions.idleTimeoutSeconds">
<el-input-number v-model="options.idleTimeoutSeconds" :disabled="form.type !== 'mongodb'" :min="0" :max="86400" :precision="0" aria-label="空闲会话超时" />
<p class="form-help">仅 MongoDB:认证后等待下一条请求的时限;0 关闭,不中断执行中的查询。</p>
</el-form-item>
<el-form-item label="TCP 保活间隔(秒)" data-field="connectionOptions.keepAliveSeconds">
<el-input-number v-model="options.keepAliveSeconds" :disabled="kubernetes" :min="0" :max="3600" :precision="0" aria-label="TCP 保活间隔" />
<el-button text @click="delete options.keepAliveSeconds">使用默认</el-button>
<p class="form-help">留空使用系统默认,0 禁用。对直连、SOCKS5 / SSH 底层 TCP 生效;Kubernetes 不支持,请恢复默认。</p>
</el-form-item>
</div>
</template>
+12 -11
View File
@@ -22,6 +22,7 @@ function moveTab(event: KeyboardEvent, index: number) {
<el-dialog v-model="open" :title="title" :before-close="beforeClose" class="connection-editor" width="min(920px, calc(100vw - 32px))" align-center destroy-on-close>
<template #header="{ titleId, titleClass }">
<h2 :id="titleId" :class="titleClass">{{ title }}</h2>
<slot name="workspace" />
<div class="connection-editor__navigation" role="tablist" aria-label="连接配置分组">
<button v-for="(item, index) in tabs" :key="item.name" type="button" role="tab" :aria-selected="activeTab === item.name" :aria-controls="`pane-${item.name}`" :tabindex="activeTab === item.name ? 0 : -1" @click="activeTab = item.name" @keydown="moveTab($event, index)">{{ item.label }}</button>
</div>
@@ -39,19 +40,19 @@ function moveTab(event: KeyboardEvent, index: number) {
padding: 0;
overflow: hidden;
border: 1px solid var(--el-border-color);
border-radius: 18px;
border-radius: var(--app-radius-panel);
}
.connection-editor > .el-dialog__header { padding: 22px 28px 0; margin: 0; border-bottom: 1px solid var(--el-border-color); }
.connection-editor .el-dialog__header h2 { margin: 0 36px 18px 0; font-size: 22px; line-height: 30px; font-weight: 600; }
.connection-editor > .el-dialog__header .el-dialog__headerbtn { top: 12px; right: 12px; }
.connection-editor__navigation { display: inline-flex; flex-wrap: wrap; gap: 4px; max-width: 100%; margin-bottom: 16px; padding: 4px; background: var(--el-fill-color-light); border-radius: 12px; }
.connection-editor__navigation [role="tab"] { padding: 0 18px; min-height: 36px; border: 0; border-radius: 9px; font: inherit; font-size: 15px; color: var(--el-text-color-regular); background: transparent; cursor: pointer; }
.connection-editor__navigation [aria-selected="true"] { color: var(--el-text-color-primary); background: var(--el-bg-color); box-shadow: 0 1px 5px #00000014; }
.connection-editor > .el-dialog__header { padding: 16px 20px 0; margin: 0; border-bottom: 1px solid var(--el-border-color); }
.connection-editor .el-dialog__header h2 { margin: 0 32px 14px 0; font-size: 16px; line-height: 24px; font-weight: 600; }
.connection-editor > .el-dialog__header .el-dialog__headerbtn { top: 10px; right: 10px; }
.connection-editor__navigation { display: inline-flex; flex-wrap: wrap; gap: 2px; max-width: 100%; margin-bottom: 12px; padding: 3px; background: var(--el-fill-color-light); border-radius: var(--app-radius); }
.connection-editor__navigation [role="tab"] { padding: 0 12px; min-height: 30px; border: 1px solid transparent; border-radius: var(--app-radius); font: inherit; font-size: 12px; color: var(--el-text-color-regular); background: transparent; cursor: pointer; }
.connection-editor__navigation [aria-selected="true"] { color: var(--el-text-color-primary); background: var(--el-bg-color); border-color: var(--app-border); }
.connection-editor__navigation [role="tab"]:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
.connection-editor > .el-dialog__body { flex: 1; min-height: 0; padding: 24px 28px; overflow: auto; overscroll-behavior: contain; }
.connection-editor > .el-dialog__body { flex: 1; min-height: 0; padding: 18px 20px; overflow: auto; overscroll-behavior: contain; }
.connection-editor .config-form > .el-tabs > .el-tabs__header { display: none; }
.connection-editor .config-form > .el-tabs > .el-tabs__content { padding-top: 0; }
.connection-editor > .el-dialog__footer { padding: 16px 28px; border-top: 1px solid var(--el-border-color); background: var(--el-fill-color-extra-light); }
.connection-editor > .el-dialog__footer { padding: 12px 20px; border-top: 1px solid var(--el-border-color); background: var(--el-fill-color-extra-light); }
.connection-editor__actions { width: 100%; display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.connection-editor__actions > .target-test-tools { flex: 1 1 360px; margin: 0; text-align: left; }
.connection-editor__actions > .el-button:first-of-type { margin-left: auto; }
@@ -59,9 +60,9 @@ function moveTab(event: KeyboardEvent, index: number) {
.connection-editor__actions .target-test-tools .el-select { flex: 1 1 150px; width: auto !important; max-width: 260px; }
.connection-editor__actions .target-test-tools .form-help { font-size: 12px; }
@media (max-width: 600px) {
.connection-editor.el-dialog { width: calc(100vw - 12px); max-width: calc(100vw - 12px); height: calc(100dvh - 12px); max-height: calc(100dvh - 12px); border-radius: 12px; }
.connection-editor.el-dialog { width: calc(100vw - 12px); max-width: calc(100vw - 12px); height: calc(100dvh - 12px); max-height: calc(100dvh - 12px); }
.connection-editor > .el-dialog__header { padding: 12px 14px 0; }
.connection-editor .el-dialog__header h2 { font-size: 18px; line-height: 24px; margin-bottom: 8px; }
.connection-editor .el-dialog__header h2 { font-size: 15px; line-height: 24px; margin-bottom: 8px; }
.connection-editor > .el-dialog__header .el-dialog__headerbtn { top: 0; right: 0; }
.connection-editor__navigation { margin-bottom: 10px; }
.connection-editor__navigation [role="tab"] { padding: 0 12px; min-height: 32px; }
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
const form = defineModel<any>({required:true});
</script>
<template>
<div class="connection-identity">
<el-form-item label="连接颜色" data-field="color"><el-color-picker :model-value="form.color" @update:model-value="form.color = $event || ''" :predefine="['#3b82f6','#22c55e','#f59e0b','#ef4444','#8b5cf6']" aria-label="连接颜色" /><el-button text @click="form.color = ''">清除颜色</el-button></el-form-item>
<el-form-item label="连接备注" data-field="remark"><el-input v-model="form.remark" type="textarea" maxlength="600" show-word-limit aria-label="连接备注" placeholder="用途说明,不要填写密码、令牌或私钥" /></el-form-item>
<el-checkbox v-model="form.showRemark">在资源列表和侧栏显示备注</el-checkbox>
</div>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { connectionParameters, parseConnectionParameters } from '../domain/connectionURL';
const form = defineModel<any>({required:true});
const emit = defineEmits<{pending:[value:string]}>();
const input = ref(''), error = ref(''), edited = ref(false);
const canonical = computed(() => connectionParameters(form.value));
const example = computed(() => ({mysql:'charset=utf8mb4&tls=true',pgsql:'application_name=serviceport&sslmode=verify-full',mongodb:'authSource=admin&replicaSet=rs0&tls=true',redis:'db=0&tls=false',sqlserver:'database=master&encrypt=true',rabbitmq:'vhost=%2F&tls=true'} as Record<string,string>)[form.value.type] || '');
watch(canonical, value => { if (!edited.value) input.value = value; }, {immediate:true});
watch(() => form.value.type, () => { edited.value = false; input.value = canonical.value; error.value = ''; emit('pending',''); });
function edit() { edited.value = true; error.value = ''; emit('pending', `edited:${input.value}`); }
function prepare() {
if (!edited.value) return true;
try {
const patch = parseConnectionParameters(input.value, form.value);
Object.assign(form.value, patch);
edited.value = false; input.value = canonical.value; error.value = ''; emit('pending','');
return true;
} catch (failure) { error.value = failure instanceof Error ? failure.message : '参数解析失败'; return false; }
}
defineExpose({prepare});
</script>
<template>
<section v-if="!['ssh','sftp','http','https'].includes(form.type)" class="connection-parameters" aria-label="独立 URL 参数">
<el-form-item label="URL 参数" :error="error">
<el-input v-model="input" type="textarea" :rows="2" aria-label="URL 参数" :placeholder="example" @input="edit" />
</el-form-item>
<el-button :disabled="!edited" @click="prepare">应用参数</el-button>
<p class="form-help">测试和保存会自动应用。参数覆盖对应表单;未填写的数据库、TLS 和集群项保持不变。删除 charset / application_name 恢复默认。未知、重复和冲突参数会阻止提交;凭据请在账号页维护。</p>
</section>
</template>
+13 -5
View File
@@ -3,9 +3,12 @@ import { computed, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { connectionURLExamples, parseConnectionURL } from '../domain/connectionURL';
const form = defineModel<any>({ required:true });
const emit = defineEmits<{pending:[value:string]}>();
const uri = ref(''), error = ref(''), accountId = ref('');
const applied = ref(false);
const label = computed(() => form.value.type === 'mongodb' ? 'MongoDB 连接串' : '连接 URL');
watch(() => form.value.type, () => { uri.value = ''; error.value = ''; });
watch(() => form.value.type, () => { uri.value = ''; error.value = ''; applied.value = false; });
watch(uri, value => emit('pending', value), {flush:'sync'});
watch(() => form.value.accounts?.map((a:any) => a.id).join(','), () => {
if (!form.value.accounts?.some((a:any) => a.id === accountId.value)) accountId.value = form.value.accounts?.[0]?.id || '';
}, {immediate:true});
@@ -24,26 +27,31 @@ function apply() {
Object.assign(account, credential, {name:account.name || credential.username});
if (['ssh','sftp'].includes(form.value.type)) Object.assign(account, {authMethod:'password',privateKey:'',privateKeyAssetId:'',certificateAssetId:'',certificate:'',passphrase:''});
}
form.value = {...form.value,...connection,accounts};
Object.assign(form.value, {...connection,connectionOptions:{...form.value.connectionOptions,...connection.connectionOptions},accounts});
uri.value = '';
applied.value = true;
ElMessage.success('URL 已解析并填入配置,请检查后保存');
} catch (e) { error.value = e instanceof Error ? e.message : '连接 URL 解析失败'; }
return true;
} catch (e) { error.value = e instanceof Error ? e.message : '连接 URL 解析失败'; return false; }
}
function prepare() { return !uri.value.trim() || apply(); }
defineExpose({prepare});
</script>
<template>
<section class="connection-url" aria-label="连接 URL 导入">
<el-form-item label="URL(可选)" :error="error">
<el-input v-model="uri" type="password" show-password autocomplete="new-password" :aria-label="label" :placeholder="connectionURLExamples[form.type]" @input="error = ''" @keydown.enter.prevent="apply" />
<el-input v-model="uri" type="password" show-password autocomplete="new-password" :aria-label="label" :placeholder="connectionURLExamples[form.type]" @input="error = ''; applied = false" @keydown.enter.prevent="apply" />
</el-form-item>
<el-form-item v-if="form.accounts?.length > 1" label="凭据填入账号">
<el-select v-model="accountId" aria-label="凭据填入账号"><el-option v-for="account in form.accounts" :key="account.id" :value="account.id" :label="account.name || account.username || '未命名账号'" /></el-select>
</el-form-item>
<el-button :disabled="!uri.trim()" @click="apply">解析并填入配置</el-button>
<el-tag v-if="uri.trim() || applied || error" :type="error ? 'danger' : applied ? 'success' : 'warning'" role="status">{{ error ? 'URL 解析错误' : applied ? 'URL 已填入配置,可继续修改表单' : 'URL 待解析,测试 / 保存时自动应用' }}</el-tag>
<p class="form-help">URL 参数会填入下方表单和其他页签。包含账号时更新所选账号,不包含账号时保留现有凭据;解析后仍需保存。</p>
</section>
</template>
<style scoped>
.connection-url { padding: 16px; margin-bottom: 20px; border: 1px solid var(--el-border-color); border-radius: 10px; min-width: 0; }
.connection-url { padding: 16px; margin-bottom: 20px; border: 1px solid var(--el-border-color); border-radius: var(--app-radius-panel); min-width: 0; }
</style>
+1 -1
View File
@@ -40,5 +40,5 @@ function apply() {
</section>
</template>
<style scoped>
.mongo-import { padding: 16px; margin-bottom: 20px; border: 1px solid var(--el-border-color); border-radius: 10px; min-width: 0; }
.mongo-import { padding: 16px; margin-bottom: 20px; border: 1px solid var(--el-border-color); border-radius: var(--app-radius-panel); min-width: 0; }
</style>
+2 -2
View File
@@ -53,10 +53,10 @@ const size = (value: number) => value < 1024 ? `${value} B` : value < 1024 ** 2
.result-tools a { color: var(--el-color-primary); font-size: 13px; }
.result-selector { width: min(100%, 300px); }
.result-note, .result-tools > span, .result-paging > span { font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.7; overflow-wrap: anywhere; }
.result-notice { padding: 12px; margin: 10px 0; background: var(--app-subtle); border-radius: 8px; overflow-wrap: anywhere; white-space: pre-wrap; font-size: 13px; }
.result-notice { padding: 12px; margin: 10px 0; background: var(--app-subtle); border-radius: var(--app-radius-panel); overflow-wrap: anywhere; white-space: pre-wrap; font-size: 13px; }
.result-notice.failed { color: var(--el-color-danger); }
.result-table-scroll { width: 100%; overflow: auto; max-height: 45dvh; }
table { width: 100%; border-collapse: collapse; font: 12px/1.6 ui-monospace, Consolas, monospace; }
table { width: 100%; border-collapse: collapse; font: 12px/1.6 var(--app-font-mono); }
th, td { border: 1px solid var(--el-border-color); padding: 8px 12px; min-width: 100px; max-width: 400px; text-align: left; vertical-align: top; }
th { position: sticky; top: 0; background: var(--app-subtle); overflow-wrap: anywhere; }
.cell-value { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; }
+1 -1
View File
@@ -105,7 +105,7 @@ async function copy() {
.decrypt-material { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 0 16px; }
.navicat-decrypt :deep(.el-form-item__content) { min-width: 0; }
.navicat-decrypt :deep(.el-radio-group) { display: flex; flex-wrap: wrap; gap: 8px; max-width: 100%; }
.navicat-decrypt :deep(.el-radio-button__inner) { border: 1px solid var(--el-border-color); border-radius: 6px; box-shadow: none; }
.navicat-decrypt :deep(.el-radio-button__inner) { border: 1px solid var(--el-border-color); border-radius: var(--app-radius-panel); box-shadow: none; }
.navicat-decrypt :deep(.el-textarea__inner) { resize: vertical; overflow-wrap: anywhere; }
@media (max-width: 600px) {
.decrypt-parameters, .decrypt-material { grid-template-columns: minmax(0, 1fr); }
+29 -9
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue';
import { computed, nextTick, onUnmounted, ref } from 'vue';
import ResourceActionMenu from './ResourceActionMenu.vue';
import type { ResourceAction } from '../domain/resourceActions';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Edit, Plus, View, Delete as Trash2 } from '@element-plus/icons-vue';
import OutboundFields from './OutboundFields.vue';
@@ -12,7 +14,7 @@ import ScopeFields from './ScopeFields.vue';
import { useWorkspaceScope, provideResourceScope, scopeLabel } from '../composables/useWorkspaceScope';
const workspaceScope = useWorkspaceScope();
const props = defineProps<{ profiles: any[]; targets: any[]; gateways: any[]; assets: any[]; api: (path: string, method?: string, body?: unknown) => Promise<any> }>();
const emit = defineEmits<{ changed: [] }>();
const emit = defineEmits<{ changed: []; opened:[id:string] }>();
const drawer = ref(false), busy = ref(false), editId = ref('');
const form = ref<any>({});
provideResourceScope(() => form.value);
@@ -58,12 +60,13 @@ async function reveal(profile: any) {
} catch (e) { if (e !== 'cancel' && e !== 'close') ElMessage.error(e instanceof Error ? e.message : '查看失败'); }
}
function open(profile?: any) {
if (busy.value || (profile && running(profile))) return;
editId.value = profile?.config.id || '';
form.value = profile ? JSON.parse(JSON.stringify(profile.config)) : { name: '', outbound: { mode: 'socks5', host: '', port: 1080, auth: 'none', username: '', password: '' } };
Object.assign(form.value, profile ? { project: profile.config.project || '', environment: profile.config.environment || '' } : workspaceScope?.defaults());
issues.value = [];
drawer.value = true;
if (profile) emit('opened',profile.config.id);
}
function running(profile: any) {
return props.gateways.some(g => g.runtime.running && (profile.targetIds.includes(g.config.targetId) || g.config.gatewayAccounts?.some((a:any) => profile.targetIds.includes(a.targetId))));
@@ -97,7 +100,21 @@ async function remove(profile: any) {
emit('changed');
} catch (e) { if (e !== 'cancel' && e !== 'close') ElMessage.error(e instanceof Error ? e.message : '删除失败'); }
}
defineExpose({ open });
function close() { drawer.value = false; form.value = {}; clearSecret(); }
defineExpose({ open, close });
const contextMenu = ref<InstanceType<typeof ResourceActionMenu>>(), contextId = ref('');
function actions(id:string):ResourceAction[] {
const row = props.profiles.find(item => item.config.id === id);
if (!row) return [];
const result:ResourceAction[] = [
{id:'edit',label:'编辑代理',disabled:busy.value || running(row),run:() => { const current=props.profiles.find(item => item.config.id === id); if (current) open(current); }},
{id:'secret',label:'查看代理密码',disabled:busy.value || !row.secretStatus.password,run:() => reveal(row)},
{id:'delete',label:'删除代理',danger:true,disabled:busy.value || row.targetIds.length > 0,run:() => { const current=props.profiles.find(item => item.config.id === id); if (current && !busy.value && !current.targetIds.length) return remove(current); }},
];
if (row.config.outbound.mode === 'ssh') result.unshift({id:'test',label:'测试连接',disabled:busy.value || !!testing.value,run:() => testConnection(row)});
return result;
}
async function context(row:any,_column:any,event:MouseEvent) { event.preventDefault(); contextId.value = row.config.id; await nextTick(); contextMenu.value?.openContext(event); }
</script>
<template>
@@ -108,20 +125,23 @@ defineExpose({ open });
<el-button @click="Object.assign(filters, { name: '', type: '' })">重置筛选</el-button>
<el-button type="primary" @click="open()"><Plus :size="16" />新建代理</el-button>
</div>
<el-table :data="filteredProfiles" empty-text="暂无匹配代理" row-key="config.id">
<el-table :data="filteredProfiles" empty-text="暂无匹配代理" row-key="config.id" @row-contextmenu="context">
<el-table-column label="名称" min-width="160"><template #default="{ row }">{{ row.config.name }}</template></el-table-column>
<el-table-column label="项目 / 环境" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ scopeLabel(row.config) }}</template></el-table-column>
<el-table-column label="类型" width="160"><template #default="{ row }">{{ outboundName(row.config.outbound.mode) }}</template></el-table-column>
<el-table-column label="连接配置" min-width="260"><template #default="{ row }"><span class="outbound-detail">{{ outboundDescription(row.config.outbound) }}</span></template></el-table-column>
<el-table-column label="引用目标" min-width="200"><template #default="{ row }">{{ references(row) || '未引用' }}</template></el-table-column>
<el-table-column label="操作" width="250" fixed="right"><template #default="{ row }">
<el-button v-if="row.config.outbound.mode === 'ssh'" :loading="testing === row.config.id" :disabled="!!testing" @click="testConnection(row)">测试连接</el-button>
<el-tooltip content="查看代理密码"><span><el-button aria-label="查看代理密码" :disabled="!row.secretStatus.password" @click="reveal(row)"><View :size="16" /></el-button></span></el-tooltip>
<el-tooltip :content="running(row) ? '请先停止关联网关' : '编辑代理'"><span><el-button aria-label="编辑代理" :disabled="running(row)" @click="open(row)"><Edit :size="16" /></el-button></span></el-tooltip>
<el-tooltip :content="row.targetIds.length ? '代理已被目标引用' : '删除代理'"><span><el-button aria-label="删除代理" :disabled="row.targetIds.length > 0" @click="remove(row)"><Trash2 :size="16" /></el-button></span></el-tooltip>
<ResourceActionMenu :items="actions(row.config.id)" />
<el-button v-if="row.config.outbound.mode === 'ssh'" :loading="testing === row.config.id" :disabled="busy || !!testing" @click="testConnection(row)">测试连接</el-button>
<el-tooltip content="查看代理密码"><span><el-button aria-label="查看代理密码" :disabled="busy || !row.secretStatus.password" @click="reveal(row)"><View :size="16" /></el-button></span></el-tooltip>
<el-tooltip :content="running(row) ? '请先停止关联网关' : '编辑代理'"><span><el-button aria-label="编辑代理" :disabled="busy || running(row)" @click="open(row)"><Edit :size="16" /></el-button></span></el-tooltip>
<el-tooltip :content="row.targetIds.length ? '代理已被目标引用' : '删除代理'"><span><el-button aria-label="删除代理" :disabled="busy || row.targetIds.length > 0" @click="remove(row)"><Trash2 :size="16" /></el-button></span></el-tooltip>
</template></el-table-column>
</el-table>
<ResourceActionMenu ref="contextMenu" :items="actions(contextId)" context-only />
<el-drawer v-model="drawer" :title="editId ? '编辑代理' : '新建代理'" size="min(680px, 100vw)" destroy-on-close :before-close="guard.requestClose" @closed="form = {}">
<template #header="{titleId,titleClass}"><div><h2 :id="titleId" :class="titleClass">{{ editId ? '编辑代理' : '新建代理' }}</h2><slot name="workspace" /></div></template>
<el-form v-if="drawer" ref="formElement" label-position="top" @submit.prevent="save">
<ValidationErrors :issues="issues" @select="focusIssue" />
<ScopeFields :value="form" :issues="issues" />
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { nextTick, onScopeDispose, ref } from 'vue';
import { ElMessage } from 'element-plus';
import type { ResourceAction } from '../domain/resourceActions';
const props = defineProps<{items:ResourceAction[];contextOnly?:boolean}>();
const open = ref(false), left = ref(0), top = ref(0), menu = ref<HTMLElement>();
let previous:HTMLElement | null = null;
function close(restore = true) { open.value = false; if (restore) previous?.focus(); }
async function run(id:string) {
const action = props.items.find(item => item.id === id);
if (!action || action.disabled) return;
close();
try { await action.run(); } catch { ElMessage.error('操作未完成,请刷新后重试'); }
}
async function openContext(event:MouseEvent) {
event.preventDefault(); previous = document.activeElement as HTMLElement;
left.value = Math.max(8,Math.min(event.clientX,window.innerWidth-228));
top.value = Math.max(8,Math.min(event.clientY,window.innerHeight-360)); open.value = true;
await nextTick(); menu.value?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
}
function keyboard(event:KeyboardEvent) {
if (event.key === 'Escape' || event.key === 'Tab') { close(); return; }
if (!['ArrowUp','ArrowDown','Home','End'].includes(event.key)) return;
event.preventDefault();
const items = Array.from(menu.value?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') || []);
const current = items.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length-1 : (current+(event.key === 'ArrowDown' ? 1 : -1)+items.length) % items.length;
items[next]?.focus();
}
function outside(event:Event) { if (open.value && !menu.value?.contains(event.target as Node)) close(false); }
function resized() { close(false); }
if (props.contextOnly) { window.addEventListener('pointerdown',outside); window.addEventListener('resize',resized); }
onScopeDispose(() => { window.removeEventListener('pointerdown',outside); window.removeEventListener('resize',resized); });
defineExpose({openContext,close});
</script>
<template>
<el-dropdown v-if="!contextOnly" trigger="click" @command="run"><el-button aria-label="更多操作">•••</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-for="item in items" :key="item.id" :command="item.id" :disabled="item.disabled" :class="{'danger-action':item.danger}">{{ item.label }}</el-dropdown-item></el-dropdown-menu></template></el-dropdown>
<Teleport to="body"><div v-if="open" ref="menu" class="resource-context-menu" role="menu" aria-label="资源操作" :style="{left:`${left}px`,top:`${top}px`}" @keydown="keyboard"><button v-for="item in items" :key="item.id" role="menuitem" :disabled="item.disabled" :class="{'danger-action':item.danger}" @click="run(item.id)">{{ item.label }}</button></div></Teleport>
</template>
<style scoped>
.resource-context-menu { position:fixed; z-index:4000; width:220px; max-height:min(350px,85dvh); overflow:auto; padding:5px; border:1px solid var(--app-border); border-radius:var(--app-radius-panel); box-shadow:var(--el-box-shadow-light); background:var(--app-bg); }
.resource-context-menu button { display:block; width:100%; text-align:left; padding:8px 10px; border:0; border-radius:var(--app-radius); background:transparent; color:var(--app-text); font-size:12px; }.resource-context-menu button:hover,.resource-context-menu button:focus-visible { background:var(--app-subtle); }.resource-context-menu button:disabled { opacity:.45; cursor:not-allowed; }.danger-action,.resource-context-menu .danger-action { color:var(--el-color-danger); }
</style>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
defineProps<{name:string;color?:string;remark?:string;showRemark?:boolean}>();
</script>
<template><span class="resource-label"><i v-if="/^#[0-9a-f]{6}$/i.test(color || '')" :style="{backgroundColor:color}" aria-hidden="true"></i><span>{{ name }}<small v-if="showRemark && remark">{{ remark }}</small></span></span></template>
<style scoped>
.resource-label { display:inline-flex; align-items:center; gap:7px; min-width:0; overflow-wrap:anywhere; }
.resource-label i { width:9px; height:9px; border-radius:50%; flex-shrink:0; border:1px solid var(--app-border); }
.resource-label small { display:block; color:var(--app-text-muted); font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; max-height:3em; overflow:hidden; }
</style>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onScopeDispose, ref, watch } from 'vue';
import AppIcon from './AppIcon.vue';
import ResourceLabel from './ResourceLabel.vue';
import { resourceKinds, type ResourceItem } from '../composables/useResources';
import { scopeLabel } from '../composables/useWorkspaceScope';
const props = defineProps<{items:ResourceItem[]; enabled:boolean; loading?:boolean; failed?:boolean}>();
const emit = defineEmits<{navigate:[item:ResourceItem];retry:[]}>();
const open = ref(false), query = ref(''), index = ref(0), input = ref<any>();
const results = computed(() => {
const terms = query.value.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
return props.items.filter(item => terms.every(term => `${item.name} ${item.address} ${item.project} ${item.environment} ${item.protocol} ${resourceKinds[item.kind]}`.toLocaleLowerCase().includes(term))).slice(0,100);
});
watch([query, () => props.items], () => index.value = 0);
watch(() => props.enabled, enabled => { if (!enabled) open.value = false; });
function show() { if (props.enabled) open.value = true; }
function choose(item?:ResourceItem) { if (item) { emit('navigate',item); open.value = false; } }
function move(offset:number) { index.value = (index.value + offset + results.value.length) % (results.value.length || 1); nextTick(() => document.getElementById(`resource-result-${index.value}`)?.scrollIntoView({block:'nearest'})); }
function shortcut(event:KeyboardEvent) { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k' && props.enabled && !event.altKey) { event.preventDefault(); show(); } }
onMounted(() => window.addEventListener('keydown',shortcut));
onScopeDispose(() => window.removeEventListener('keydown',shortcut));
</script>
<template>
<el-button class="resource-search-trigger" aria-label="搜索全部资源" title="搜索资源(Ctrl / ⌘ K)" @click="show"><AppIcon name="search" /><span>搜索资源</span></el-button>
<el-dialog v-model="open" title="搜索资源 · 当前项目与环境" width="min(680px, 96vw)" append-to-body @opened="input?.focus()" @closed="query = ''">
<p v-if="loading" role="status">正在刷新资源摘要…</p>
<el-alert v-if="failed" title="资源刷新失败,结果可能不是最新数据" type="warning" :closable="false"><el-button :disabled="loading" @click="$emit('retry')">重试刷新</el-button></el-alert>
<el-input ref="input" v-model="query" clearable placeholder="名称、协议、地址、项目或环境" aria-label="搜索资源关键词" role="combobox" aria-controls="resource-results" :aria-expanded="open" :aria-activedescendant="results.length ? `resource-result-${index}` : undefined" @keydown.down.prevent="move(1)" @keydown.up.prevent="move(-1)" @keydown.enter.prevent="choose(results[index])" />
<div id="resource-results" class="resource-results" role="listbox" aria-label="资源搜索结果">
<button v-for="(item, position) in results" :id="`resource-result-${position}`" :key="item.key" role="option" :aria-selected="index === position" :class="{selected:index === position}" @click="choose(item)" @pointerenter="index = position">
<ResourceLabel :name="item.name" :color="item.color" />
<small>{{ resourceKinds[item.kind] }} · {{ item.protocol }} · {{ scopeLabel(item) }} · {{ item.address }} {{ item.status }}</small>
</button>
<p v-if="!results.length" role="status">当前范围没有匹配资源。可调整关键词或项目/环境筛选。</p>
</div>
<p class="form-help">↑ ↓ 选择,Enter 打开,Esc 关闭。仅搜索配置摘要,不搜索凭据和资产内容;最多显示 100 条。</p>
</el-dialog>
</template>
<style scoped>
.resource-search-trigger { gap:6px; }
.resource-results { max-height:55dvh; overflow:auto; display:grid; gap:4px; margin:12px 0; }
.resource-results button { display:grid; gap:4px; text-align:left; border:1px solid transparent; border-radius:var(--app-radius); padding:10px; color:var(--app-text); background:transparent; overflow-wrap:anywhere; }
.resource-results button.selected { background:var(--app-subtle); border-color:var(--el-color-primary); }
.resource-results small { color:var(--app-text-muted); }
@media(max-width:720px) { .resource-search-trigger span { display:none; } }
</style>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { resourceKinds, type ResourceItem } from '../composables/useResources';
import ResourceLabel from './ResourceLabel.vue';
import { environmentLabel } from '../composables/useWorkspaceScope';
const props = defineProps<{items:ResourceItem[];active:string}>();
const emit = defineEmits<{navigate:[item:ResourceItem]}>();
type Branch = {key:string;label:string;children?:Branch[];resource?:ResourceItem};
const opened = ref(false), expanded = ref<string[]>([]), tree = ref<any>();
try { const saved = JSON.parse(localStorage.getItem('serviceport.tree.v1') || '[]'); if (Array.isArray(saved)) expanded.value = saved.filter(item => typeof item === 'string').slice(0,500); } catch {}
const branches = computed(() => {
const result:Branch[] = [];
for (const item of props.items) {
const projectKey = JSON.stringify([item.project]);
let project = result.find(branch => branch.key === projectKey);
if (!project) { project = {key:projectKey,label:item.project || '未分配项目',children:[]}; result.push(project); }
const environmentKey = JSON.stringify([item.project,item.environment]);
let environment = project.children!.find(branch => branch.key === environmentKey);
if (!environment) { environment = {key:environmentKey,label:environmentLabel(item.environment),children:[]}; project.children!.push(environment); }
const kindKey = JSON.stringify([item.project,item.environment,item.kind]);
let kind = environment.children!.find(branch => branch.key === kindKey);
if (!kind) { kind = {key:kindKey,label:resourceKinds[item.kind],children:[]}; environment.children!.push(kind); }
kind.children!.push({key:item.key,label:item.name,resource:item});
}
return result;
});
function saveExpanded() { try { localStorage.setItem('serviceport.tree.v1',JSON.stringify(expanded.value.slice(-500))); } catch {} }
function expand(branch:Branch) { if (!expanded.value.includes(branch.key)) expanded.value.push(branch.key); saveExpanded(); }
function collapse(branch:Branch) { expanded.value = expanded.value.filter(key => key !== branch.key); saveExpanded(); }
function choose(branch:Branch) { if (branch.resource) { emit('navigate',branch.resource); opened.value = false; } }
watch(() => { const item = props.items.find(item => item.key === props.active); return JSON.stringify([props.active,item?.project,item?.environment,item?.kind]); }, async () => {
const item = props.items.find(item => item.key === props.active);
if (!item) return;
for (const values of [[item.project],[item.project,item.environment],[item.project,item.environment,item.kind]]) {
const key = JSON.stringify(values); if (!expanded.value.includes(key)) expanded.value.push(key);
}
await nextTick(); tree.value?.setCurrentKey(item.key);
}, {immediate:true});
</script>
<template>
<section class="resource-tree" aria-label="资源树">
<div class="tree-heading"><strong>资源</strong><el-button text @click="opened = true" aria-label="展开资源树">展开</el-button></div>
<el-tree ref="tree" class="desktop-tree" :data="branches" node-key="key" :current-node-key="active" :default-expanded-keys="expanded" :expand-on-click-node="true" highlight-current empty-text="当前范围没有资源" @node-click="choose" @node-expand="expand" @node-collapse="collapse">
<template #default="{data}"><span class="tree-node" :title="data.resource ? `${data.label} · ${data.resource.status}` : data.label"><ResourceLabel :name="data.label" :color="data.resource?.color" :remark="data.resource?.remark" :show-remark="true" /><small>{{ data.resource?.status || (data.children?.length ?? '') }}</small></span></template>
</el-tree>
<el-dialog v-model="opened" title="资源树" width="min(600px, 96vw)" append-to-body>
<el-tree :data="branches" node-key="key" :current-node-key="active" :default-expanded-keys="expanded" highlight-current empty-text="当前范围没有资源" @node-click="choose" @node-expand="expand" @node-collapse="collapse"><template #default="{data}"><span class="tree-node"><ResourceLabel :name="data.label" :color="data.resource?.color" :remark="data.resource?.remark" :show-remark="true" /><small>{{ data.resource?.status }}</small></span></template></el-tree>
</el-dialog>
</section>
</template>
<style scoped>
.resource-tree { min-height:80px; flex:1 1 35%; overflow:auto; border-top:1px solid var(--app-border); padding:6px; }
.tree-heading,.tree-node { display:flex; gap:6px; align-items:center; justify-content:space-between; min-width:0; }
.tree-heading { padding:0 6px; font-size:11px; color:var(--app-text-muted); }
.tree-node { font-size:12px; flex:1; overflow:hidden; padding-right:8px; }
.tree-node small { color:var(--app-text-muted); white-space:nowrap; font-size:10px; }
.tree-node :deep(.resource-label) { overflow:hidden; }
.resource-tree :deep(.el-tree) { background:transparent; }
.resource-tree :deep(.el-tree-node__content) { min-height:30px; height:auto; }
@media(max-width:720px) { .desktop-tree,.tree-heading strong { display:none; } .resource-tree { flex:0 0 auto; min-height:40px; padding:0; } .tree-heading { padding:0; justify-content:center; } }
</style>
+2 -2
View File
@@ -68,13 +68,13 @@ const actionLabels: Record<string, string> = { pushed: '已推送本机配置',
</template>
<style scoped>
.s3-sync { margin: 0; padding: 20px; border: 1px solid var(--el-border-color); border-radius: 8px; }
.s3-sync { margin: 0; padding: 20px; border: 1px solid var(--el-border-color); border-radius: var(--app-radius-panel); }
.s3-sync h3 { margin-top: 0; }
.s3-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
.s3-note { color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.7; }
.s3-actions { margin-top: 20px; flex-wrap: wrap; }
.s3-actions > .el-input { width: 330px; max-width: 100%; }
.s3-conflict { margin: 16px 0; padding: 16px; border: 1px solid var(--el-color-warning); border-radius: 8px; }
.s3-conflict { margin: 16px 0; padding: 16px; border: 1px solid var(--el-color-warning); border-radius: var(--app-radius-panel); }
.s3-conflict .el-button { margin: 4px 8px 4px 0; }
.s3-status { margin: 16px 0; display: grid; gap: 10px; font-size: 13px; }
.s3-status > div { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: baseline; }
+12 -11
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import AppIcon from './AppIcon.vue';
type MenuItem = { id: string; label: string; icon: string; count?: number; keywords?: string };
const props = defineProps<{ items: MenuItem[]; active: string }>();
const emit = defineEmits<{ navigate: [id: string] }>();
@@ -14,31 +15,31 @@ function firstMatch() { if (matches.value[0]) choose(matches.value[0].id); }
<template>
<div class="menu-search">
<el-input v-model="query" class="menu-search-input" aria-label="搜索菜单" placeholder="搜索菜单" clearable @keydown.enter.prevent="firstMatch" @keydown.esc="query=''" />
<button class="menu-search-trigger" aria-label="搜索菜单" title="搜索菜单" @click="searchOpen=true"><span aria-hidden="true">⌕</span></button>
<el-input v-model="query" class="menu-search-input" aria-label="搜索菜单" placeholder="搜索菜单" clearable @keydown.enter.prevent="firstMatch" @keydown.esc="query=''"><template #prefix><AppIcon name="search" :size="14" /></template></el-input>
<button class="menu-search-trigger" aria-label="搜索菜单" title="搜索菜单" @click="searchOpen=true"><AppIcon name="search" /></button>
</div>
<nav aria-label="工作空间菜单">
<button v-for="item in matches" :key="item.id" :class="{active:active===item.id}" :title="item.label" :aria-current="active===item.id ? 'page' : undefined" @click="choose(item.id)">
<span aria-hidden="true">{{ item.icon }}</span>{{ item.label }}<i v-if="item.count !== undefined">{{ item.count }}</i>
<button v-for="item in matches" :key="item.id" :class="{active:active===item.id}" :title="item.label" :aria-label="item.label" :aria-current="active===item.id ? 'page' : undefined" @click="choose(item.id)">
<AppIcon :name="item.icon" /><span class="nav-label">{{ item.label }}</span><i v-if="item.count !== undefined">{{ item.count }}</i>
</button>
<p v-if="!matches.length" class="menu-no-results" role="status">没有匹配的菜单</p>
</nav>
<el-dialog v-model="searchOpen" title="搜索菜单" width="min(480px, 95vw)" append-to-body @closed="query=''">
<el-input v-model="query" aria-label="搜索菜单" placeholder="输入菜单名称,如资产、同步" clearable @keydown.enter.prevent="firstMatch" />
<div class="menu-search-results">
<button v-for="item in matches" :key="item.id" :class="{active:active===item.id}" @click="choose(item.id)"><span aria-hidden="true">{{ item.icon }}</span>{{ item.label }}</button>
<button v-for="item in matches" :key="item.id" :class="{active:active===item.id}" @click="choose(item.id)"><AppIcon :name="item.icon" />{{ item.label }}</button>
<p v-if="!matches.length" role="status">没有匹配的菜单</p>
</div>
</el-dialog>
</template>
<style scoped>
.menu-search { flex-shrink: 0; min-width: 0; margin-bottom: 14px; }
.menu-search-input { --el-input-bg-color: rgba(255,255,255,.08); --el-input-text-color: #ecf6f0; --el-input-placeholder-color: #aebfb9; --el-input-border-color: #52716d; }
.menu-search-trigger { display: none; width: 100%; min-height: 44px; border: 1px solid #52716d; border-radius: 6px; color: #ecf6f0; background: transparent; cursor: pointer; font-size: 25px; }
.menu-no-results { color: #aebfb9; font-size: 13px; text-align: center; overflow-wrap: anywhere; }
.menu-search { flex-shrink: 0; min-width: 0; margin: 0 10px 10px; }
.menu-search-input { --el-input-bg-color: var(--app-bg); --el-input-text-color: var(--app-text); --el-input-placeholder-color: var(--app-text-muted); --el-input-border-color: var(--app-border); }
.menu-search-trigger { display: none; align-items: center; justify-content: center; width: 100%; min-height: 44px; border: 1px solid var(--app-border); border-radius: var(--app-radius); color: var(--app-text-muted); background: transparent; cursor: pointer; }
.menu-no-results { color: var(--app-text-muted); font-size: 12px; text-align: center; overflow-wrap: anywhere; }
.menu-search-results { display: grid; gap: 6px; margin-top: 14px; }
.menu-search-results button { display: flex; gap: 12px; align-items: center; padding: 12px; border: 1px solid var(--el-border-color); border-radius: 6px; color: var(--el-text-color-primary); background: var(--el-bg-color); text-align: left; cursor: pointer; }
.menu-search-results button { display: flex; gap: 12px; align-items: center; padding: 12px; border: 1px solid var(--el-border-color); border-radius: var(--app-radius-panel); color: var(--el-text-color-primary); background: var(--el-bg-color); text-align: left; cursor: pointer; }
.menu-search-results button:hover, .menu-search-results button.active { background: var(--el-color-primary-light-9); border-color: var(--el-color-primary); }
@media (max-width: 720px) { .menu-search-input { display: none; } .menu-search-trigger { display: block; } }
@media (max-width: 720px) { .menu-search { margin: 0 6px 8px; } .menu-search-input { display: none; } .menu-search-trigger { display: flex; } }
</style>
+31 -5
View File
@@ -1,19 +1,39 @@
<script setup lang="ts">
import { computed, onScopeDispose, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import { targetDraftPayload } from '../domain/targetDraft';
import { validateTarget, type FieldIssue } from '../domain/formValidation';
const props = defineProps<{ draft:any; targetId:string; initialAccountId?:string; busy:boolean; assets:any[]; outbounds:any[]; secretStatus?:Record<string,boolean>; api:(path:string, method?:string, body?:unknown)=>Promise<any> }>();
const props = defineProps<{ draft:any; targetId:string; initialAccountId?:string; busy:boolean; assets:any[]; outbounds:any[]; pending?:string; prepare?:()=>Promise<boolean>; secretStatus?:Record<string,boolean>; api:(path:string, method?:string, body?:unknown)=>Promise<any> }>();
const emit = defineEmits<{ testing:[boolean]; invalid:[FieldIssue[]]; tested:[row:any, started:number, result:Record<string,unknown>, success:boolean] }>();
const accountId = ref(''), loading = ref(false);
let alive = true;
watch(() => props.draft, () => { accountId.value = props.initialAccountId || ''; }, {immediate:true});
const selected = computed(() => (props.draft.accounts || []).find((a:any) => a.id === accountId.value) || (props.draft.accounts?.length === 1 ? props.draft.accounts[0] : undefined));
const signature = computed(() => JSON.stringify([props.draft, selected.value?.id, props.pending]));
const summary = ref<{success:boolean; elapsed:number; signature:string; protocol:string; tls:boolean}>();
const stale = computed(() => !!summary.value && summary.value.signature !== signature.value);
let details: {view:any; started:number; result:Record<string,unknown>; success:boolean} | undefined;
function finish(view:any, started:number, result:Record<string,unknown>, success:boolean, snapshot:string) {
summary.value = {success,elapsed:Math.round(performance.now()-started),signature:snapshot,protocol:view.config.type,tls:result.tls === true};
details = {view,started,result,success};
emit('tested',view,started,result,success);
}
function showDetails() { if (details) emit('tested',details.view,performance.now()-(summary.value?.elapsed || 0),details.result,details.success); }
async function copyDiagnostic() {
if (!summary.value) return;
try {
await navigator.clipboard.writeText(JSON.stringify({protocol:summary.value.protocol,success:summary.value.success,elapsedMs:summary.value.elapsed,tls:summary.value.tls,stale:stale.value,scope:'unsaved-draft'},null,2));
ElMessage.success('已复制脱敏诊断(不含地址、账号、凭据及原始错误)');
} catch { ElMessage.error('无法访问剪贴板'); }
}
onScopeDispose(() => { alive = false; if (loading.value) emit('testing', false); });
async function test() {
if (props.busy || loading.value || !selected.value) return;
if (props.busy || loading.value) return;
if (props.prepare && !await props.prepare()) return;
if (!selected.value) return;
const snapshot = signature.value;
const config = targetDraftPayload(props.draft);
config.accounts = config.accounts.filter((a:any) => a.id === selected.value.id || a.id === config.cluster?.sentinelAccountId);
const issues = validateTarget({...config, name:config.name?.trim() || '连接测试', accounts:config.accounts.map((a:any)=>({...a,name:a.name?.trim() || a.username}))}, {assets:props.assets,secretStatus:props.secretStatus,editing:!!props.targetId});
@@ -35,11 +55,11 @@ async function test() {
if (!alive) return;
result = await props.api('targets/test','POST',{...body,hostFingerprint:failure.fingerprint});
}
if (alive) emit('tested',view,started,{...result,测试范围:'当前草稿,仅测试连接,未保存配置'},true);
if (alive) finish(view,started,{...result,测试范围:'当前草稿,仅测试连接,未保存配置'},true,snapshot);
} catch (error) {
if (alive && error !== 'cancel' && error !== 'close') {
const failure = error as Error & {stage?:string};
emit('tested',view,started,{error:failure.message || '请求失败',...(failure.stage?{stage:failure.stage}:{}),测试范围:'当前草稿,未保存配置'},false);
finish(view,started,{error:failure.message || '请求失败',...(failure.stage?{stage:failure.stage}:{}),测试范围:'当前草稿,未保存配置'},false,snapshot);
}
} finally { loading.value = false; if (alive) emit('testing', false); }
}
@@ -52,10 +72,16 @@ async function test() {
</el-select>
<el-button :loading="loading" :disabled="busy || !selected" @click="test">测试连接</el-button>
<p class="form-help">直接测试当前填写的配置,无需保存。已有账号密码留空时使用已保存凭据。</p>
<div v-if="summary" class="test-summary" role="status" aria-live="polite">
<el-tag :type="stale ? 'warning' : summary.success ? 'success' : 'danger'">{{ stale ? '配置已变化,需重新测试' : summary.success ? '连接成功' : '连接失败' }}</el-tag>
<span>{{ summary.protocol }} · {{ summary.elapsed }} ms · {{ summary.tls ? 'TLS' : '未协商 TLS' }}</span>
<el-button text @click="showDetails">完整结果</el-button><el-button text @click="copyDiagnostic">复制脱敏诊断</el-button>
</div>
</div>
</template>
<style scoped>
.target-test-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 12px; }
.target-test-tools .form-help { flex-basis: 100%; margin: 0; text-align: left; }
.test-summary { display:flex; flex-wrap:wrap; gap:8px; align-items:center; text-align:left; }
</style>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { theme, themes } from '../theme';
import AppIcon from './AppIcon.vue';
</script>
<template>
<div class="theme-picker">
<AppIcon name="palette" />
<label for="theme-select">主题</label>
<select id="theme-select" v-model="theme" aria-label="主题">
<option v-for="item in themes" :key="item.value" :value="item.value">{{ item.label }}</option>
</select>
</div>
</template>
<style scoped>
.theme-picker { display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--app-text-muted); font-size: 12px; }
select { min-width: 0; max-width: 130px; min-height: 28px; padding: 2px 4px; border: 1px solid transparent; border-radius: var(--app-radius); color: var(--app-text-regular); background: var(--app-chrome); font: inherit; cursor: pointer; }
select:hover { border-color: var(--app-border); }
select:focus-visible { outline: 2px solid var(--app-ring); outline-offset: 1px; }
@media (max-width: 720px) { label, .app-icon { display: none; } select { max-width: 108px; min-height: 36px; } }
</style>
+1 -1
View File
@@ -9,4 +9,4 @@ defineEmits<{ select: [issue: FieldIssue] }>();
<ul><li v-for="(issue,index) in issues" :key="index"><el-button link type="danger" @click="$emit('select',issue)">{{ issue.label }}:{{ issue.message }}</el-button></li></ul>
</div>
</template>
<style scoped>.validation-errors{padding:12px;margin-bottom:16px;background:var(--el-color-danger-light-9);border-radius:6px}.validation-errors ul{padding-left:18px;margin:8px 0}.validation-errors .el-button{white-space:normal;text-align:left;height:auto}</style>
<style scoped>.validation-errors{padding:12px;margin-bottom:16px;background:var(--el-color-danger-light-9);border-radius: var(--app-radius-panel)}.validation-errors ul{padding-left:18px;margin:8px 0}.validation-errors .el-button{white-space:normal;text-align:left;height:auto}</style>
+2
View File
@@ -20,6 +20,8 @@ onMounted(async () => { try { info.value = await props.api('version'); } catch {
<style scoped>
.version-info { border: 0; background: transparent; color: inherit; font-size: 12px; cursor: pointer; padding: 4px 8px; max-width: 180px; overflow-wrap: anywhere; }
.version-info:hover { text-decoration: underline; }
.version-info:focus-visible { outline: 2px solid var(--app-ring); outline-offset: 1px; border-radius: var(--app-radius); }
@media (max-width: 720px) { .version-info { max-width: 58px; padding: 4px; font-size: 10px; } }
.version-details { display: grid; grid-template-columns: 80px minmax(0, 1fr); gap: 12px; }
.version-details dd { margin: 0; overflow-wrap: anywhere; }
.version-details dt { color: var(--el-text-color-secondary); }
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { resourceKinds, type ResourceItem } from '../composables/useResources';
import ResourceLabel from './ResourceLabel.vue';
defineProps<{recent:(ResourceItem & {at:number})[];failures:ResourceItem[]}>();
defineEmits<{navigate:[item:ResourceItem];create:[kind:'targets'|'proxies'];page:[id:string];clear:[]}>();
</script>
<template>
<section class="workspace-home">
<div class="home-actions"><el-button type="primary" @click="$emit('create','targets')">新建目标</el-button><el-button @click="$emit('create','proxies')">创建网关</el-button><el-button @click="$emit('page','configuration')">导入配置</el-button><el-button @click="$emit('page','operations')">监控运维</el-button></div>
<div class="home-panels">
<section class="table-card"><header><h3>最近访问</h3><el-button text @click="$emit('clear')">清空历史</el-button></header><p v-if="!recent.length" class="form-help">尚无访问记录。打开资源或使用搜索开始管理连接。</p><button v-for="item in recent" :key="item.key" class="home-resource" @click="$emit('navigate',item)"><ResourceLabel :name="item.name" :color="item.color" :remark="item.remark" :show-remark="true" /><small>{{ resourceKinds[item.kind] }} · {{ new Date(item.at).toLocaleString() }}</small></button></section>
<section class="table-card"><header><h3>异常网关</h3><el-tag :type="failures.length ? 'danger' : 'success'">{{ failures.length }}</el-tag></header><p v-if="!failures.length" class="form-help">当前范围没有报告异常的网关。</p><button v-for="item in failures" :key="item.key" class="home-resource" @click="$emit('navigate',item)"><ResourceLabel :name="item.name" :color="item.color" /><small>{{ item.protocol }} · {{ item.status }}</small></button></section>
</div>
</section>
</template>
<style scoped>
.workspace-home { margin-bottom:20px; }
.home-actions { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px; }.home-actions .el-button { margin:0; }
.home-panels { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }.home-panels .table-card { padding:16px; }
.home-panels header { display:flex; align-items:center; justify-content:space-between; gap:10px; }.home-panels h3 { margin:0; font-size:14px; }
.home-resource { width:100%; display:flex; flex-wrap:wrap; justify-content:space-between; gap:6px; padding:12px 0; border:0; border-bottom:1px solid var(--app-border); background:transparent; color:var(--app-text); text-align:left; }.home-resource:hover { color:var(--el-color-primary); }.home-resource small { color:var(--app-text-muted); }
@media(max-width:900px) { .home-panels { grid-template-columns:1fr; } }
</style>
+1 -1
View File
@@ -23,7 +23,7 @@ const environmentOptions = computed(() => [...new Set([...props.environments, ..
</section>
</template>
<style scoped>
.workspace-scope { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 14px 16px; margin-bottom: 20px; background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); border-radius: 12px; }
.workspace-scope { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; margin-bottom: 16px; background: var(--app-sidebar); border: 1px solid var(--app-border); border-radius: var(--app-radius-panel); color: var(--app-text-muted); font-size: 12px; }
.workspace-scope > .el-select { width: min(240px, 100%); }
@media (max-width: 600px) { .workspace-scope > .el-select { flex: 1 1 calc(100% - 56px); min-width: 0; } }
</style>
@@ -0,0 +1,8 @@
<script setup lang="ts">
import type { WorkspaceTab } from '../composables/useWorkspaceTabs';
const props = defineProps<{tabs:WorkspaceTab[];active:string}>();
const emit = defineEmits<{select:[tab:WorkspaceTab];close:[tab:WorkspaceTab]}>();
function select(key:string) { const tab = props.tabs.find(item => item.key === key); if (tab) emit('select',tab); }
</script>
<template><div class="workspace-tab-switcher"><el-select :model-value="active" aria-label="切换工作区标签" @update:model-value="select"><el-option v-for="item in tabs" :key="item.key" :value="item.key" :label="item.label" /></el-select><el-button :disabled="!tabs.find(item => item.key === active) || tabs.find(item => item.key === active)?.pinned" @click="$emit('close',tabs.find(item => item.key === active)!)">关闭当前标签</el-button></div></template>
<style scoped>.workspace-tab-switcher { display:flex; align-items:center; gap:8px; margin:0 32px 10px 0; }.workspace-tab-switcher .el-select { width:220px; max-width:100%; min-width:0; }.workspace-tab-switcher .el-button { margin:0; } @media(max-width:420px) { .workspace-tab-switcher { flex-wrap:wrap; margin-right:20px; } }</style>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { WorkspaceTab } from '../composables/useWorkspaceTabs';
defineProps<{tabs:WorkspaceTab[];active:string;dirty:boolean}>();
defineEmits<{select:[tab:WorkspaceTab];close:[tab:WorkspaceTab];pin:[tab:WorkspaceTab];others:[]}>();
function arrow(event:KeyboardEvent) {
const buttons = Array.from((event.currentTarget as HTMLElement).querySelectorAll<HTMLButtonElement>('[role=tab]'));
const current = buttons.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length-1 : (current + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length;
buttons[next]?.focus();
}
</script>
<template>
<div class="workspace-tabs" role="tablist" aria-label="工作区标签" @keydown.right.prevent="arrow" @keydown.left.prevent="arrow" @keydown.home.prevent="arrow" @keydown.end.prevent="arrow">
<div v-for="item in tabs" :key="item.key" class="workspace-tab" :class="{active:active===item.key}">
<button role="tab" :aria-selected="active===item.key" :tabindex="active===item.key ? 0 : -1" :title="item.label" @click="$emit('select',item)">{{ item.pinned ? '◆ ' : '' }}{{ item.label }}{{ dirty && active===item.key ? ' •' : '' }}</button>
<el-dropdown trigger="click"><button :aria-label="`${item.label}标签操作`">⋯</button><template #dropdown><el-dropdown-menu><el-dropdown-item @click="$emit('pin',item)">{{ item.pinned ? '取消固定' : '固定标签' }}</el-dropdown-item><el-dropdown-item :disabled="item.pinned" @click="$emit('close',item)">关闭标签</el-dropdown-item><el-dropdown-item v-if="active===item.key" @click="$emit('others')">关闭其他未固定标签</el-dropdown-item></el-dropdown-menu></template></el-dropdown>
<button v-if="!item.pinned" :aria-label="`关闭${item.label}标签`" @click="$emit('close',item)">×</button>
</div>
</div>
</template>
<style scoped>
.workspace-tabs { display:flex; gap:2px; overflow:auto; max-width:100%; padding:5px 12px 0; background:var(--app-subtle); border-bottom:1px solid var(--app-border); }
.workspace-tab { display:flex; align-items:center; flex:0 0 auto; max-width:280px; border:1px solid transparent; border-bottom:0; border-radius:var(--app-radius) var(--app-radius) 0 0; }
.workspace-tab.active { background:var(--app-bg); border-color:var(--app-border); }
.workspace-tab button { padding:8px 7px; color:var(--app-text-muted); background:transparent; border:0; font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.workspace-tab.active button { color:var(--app-text); }
.workspace-tab button:focus-visible { outline:2px solid var(--el-color-primary); outline-offset:-2px; }
</style>
+27
View File
@@ -0,0 +1,27 @@
import { computed, ref, watch } from 'vue';
import type { ResourceLink } from '../domain/readiness';
export type ResourceItem = ResourceLink & {key:string; name:string; project:string; environment:string; protocol:string; address:string; color:string; remark:string; status:string};
export const resourceKinds = {targets:'目标服务',proxies:'网关服务',outbounds:'代理',assets:'资产'};
export function resourceItems(collections:Record<ResourceLink['kind'],any[]>): ResourceItem[] {
return Object.entries(collections).flatMap(([kind, rows]) => rows.map(row => {
const config = row.config;
return {kind:kind as ResourceLink['kind'],id:config.id,key:`${kind}:${config.id}`,name:config.name || config.id,project:config.project || '',environment:config.environment || '',protocol:config.type || config.outbound?.mode || '',address:kind === 'targets' ? `${config.targetHost}:${config.targetPort}` : kind === 'proxies' ? `${config.listenHost}:${config.listenPort}` : kind === 'outbounds' && config.outbound?.host ? `${config.outbound.host}:${config.outbound.port}` : '',color:/^#[0-9a-f]{6}$/i.test(config.color || '') ? config.color : '',remark:config.showRemark ? config.remark || '' : '',status:kind === 'proxies' ? row.runtime?.error ? '异常' : row.runtime?.running ? '运行中' : '已停止' : kind === 'assets' ? row.summary?.status === 'expired' ? '已过期' : '' : ''};
}));
}
export function useRecentResources(resources:()=>ResourceItem[]) {
const key = 'serviceport.recent.v1';
const history = ref<{kind:ResourceLink['kind'];id:string;at:number}[]>([]);
try {
const stored = JSON.parse(localStorage.getItem(key) || '[]');
if (Array.isArray(stored)) history.value = stored.filter(item => item && Object.prototype.hasOwnProperty.call(resourceKinds,item.kind) && typeof item.id === 'string' && item.id.length <= 128 && Number.isFinite(item.at)).slice(0,30).map(({kind,id,at}) => ({kind,id,at}));
} catch {}
watch(history, value => { try { localStorage.setItem(key,JSON.stringify(value)); } catch {} }, {deep:true});
const recent = computed(() => history.value.flatMap(entry => {
const item = resources().find(item => item.id === entry.id && item.kind === entry.kind);
return item ? [{...item,at:entry.at}] : [];
}).slice(0,8));
function visit(link:ResourceLink) { history.value = [{kind:link.kind,id:link.id,at:Date.now()}, ...history.value.filter(entry => entry.kind !== link.kind || entry.id !== link.id)].slice(0,30); }
function prune(all:ResourceItem[]) { history.value = history.value.filter(entry => all.some(item => item.kind === entry.kind && item.id === entry.id)); }
function clear() { history.value = []; }
return {recent,visit,prune,clear};
}
+11
View File
@@ -0,0 +1,11 @@
import { onScopeDispose, ref } from 'vue';
export function useSidebarResize() {
const width = ref(224);
try { const stored = Number(localStorage.getItem('serviceport.sidebar-width')); if (stored >= 192 && stored <= 420) width.value = stored; } catch {}
function set(value:number) { width.value = Math.min(420,Math.max(192,Math.round(value))); try { localStorage.setItem('serviceport.sidebar-width',String(width.value)); } catch {} }
function move(event:PointerEvent) { set(event.clientX); }
function stop() { window.removeEventListener('pointermove',move); window.removeEventListener('pointerup',stop); window.removeEventListener('pointercancel',stop); window.removeEventListener('blur',stop); }
function start(event:PointerEvent) { if (event.button !== 0) return; event.preventDefault(); (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); window.addEventListener('pointermove',move); window.addEventListener('pointerup',stop); window.addEventListener('pointercancel',stop); window.addEventListener('blur',stop); }
onScopeDispose(stop);
return {width,set,start};
}
+31
View File
@@ -0,0 +1,31 @@
import { computed, ref, watch } from 'vue';
export type ColumnDefinition = {id:string;label:string;width:number;required?:boolean;fixed?:boolean};
export type ColumnPreference = {id:string;visible:boolean;width:number};
export function mergeColumns(definitions:ColumnDefinition[], stored:unknown): ColumnPreference[] {
const preferences = Array.isArray(stored) ? stored : [];
const ordered = [...preferences.flatMap(item => definitions.find(column => column.id === item?.id) || []), ...definitions];
const seen = new Set<string>();
return ordered.filter(column => !seen.has(column.id) && !!seen.add(column.id)).map(column => {
const saved = preferences.find(item => item?.id === column.id);
return {id:column.id,visible:!!column.required || saved?.visible !== false,width:Number.isFinite(saved?.width) ? Math.max(80,Math.min(800,Math.round(saved.width))) : column.width};
}).sort((left,right) => Number(!!definitions.find(column => column.id === left.id)?.fixed) - Number(!!definitions.find(column => column.id === right.id)?.fixed));
}
export function useTableLayout(key:string, definitions:ColumnDefinition[]) {
const storageKey = `serviceport.columns.v1.${key}`;
let stored:unknown;
try { stored = JSON.parse(localStorage.getItem(storageKey) || 'null'); } catch {}
const columns = ref(mergeColumns(definitions,stored));
const visible = computed(() => columns.value.filter(column => column.visible));
watch(columns, value => { try { localStorage.setItem(storageKey,JSON.stringify(value.map(({id,visible,width}) => ({id,visible,width})))); } catch {} }, {deep:true});
function move(id:string, offset:number) {
const index = columns.value.findIndex(column => column.id === id), next = index+offset;
if (index < 0 || next < 0 || next >= columns.value.length || [id,columns.value[next].id].some(key => definitions.find(column => column.id === key)?.fixed)) return;
const [column] = columns.value.splice(index,1); columns.value.splice(next,0,column);
}
function resize(width:number, _oldWidth:number, column:{columnKey?:string}) {
const preference = columns.value.find(item => item.id === column.columnKey);
if (preference) preference.width = Math.max(80,Math.min(800,Math.round(width)));
}
function reset() { columns.value = mergeColumns(definitions,null); }
return {columns,visible,move,resize,reset};
}
+38
View File
@@ -0,0 +1,38 @@
import { computed, ref } from 'vue';
export type WorkspaceTab = {key:string;page:string;resourceId?:string;label:string;pinned:boolean;scroll:number;filters?:Record<string,string>;editorTab?:string;editorScroll?:number;tableScroll?:Record<string,{left:number;top:number}>};
export function useWorkspaceTabs() {
const tabs = ref<WorkspaceTab[]>([{key:'proxies',page:'proxies',label:'网关服务',pinned:false,scroll:0}]);
const active = ref('proxies');
const current = computed(() => tabs.value.find(tab => tab.key === active.value));
function open(page:string,label:string,resourceId?:string) {
const key = resourceId ? `${page}:${resourceId}` : page;
let tab = tabs.value.find(tab => tab.key === key);
if (!tab) { tab = {key,page,resourceId,label,pinned:false,scroll:0}; tabs.value.push(tab); }
tab.label = label; active.value = key; return tab;
}
function remember(filters:Record<string,string>) {
if (!current.value) return;
current.value.scroll = window.scrollY; current.value.filters = {...filters};
current.value.tableScroll = {};
document.querySelectorAll<HTMLElement>('[data-table-id] .el-table__body-wrapper .el-scrollbar__wrap').forEach(element => {
const id = element.closest<HTMLElement>('[data-table-id]')?.dataset.tableId;
if (id) current.value!.tableScroll![id] = {left:element.scrollLeft,top:element.scrollTop};
});
}
function restoreScroll() {
window.scrollTo({top:current.value?.scroll || 0});
document.querySelectorAll<HTMLElement>('[data-table-id] .el-table__body-wrapper .el-scrollbar__wrap').forEach(element => {
const id = element.closest<HTMLElement>('[data-table-id]')?.dataset.tableId;
const scroll = id ? current.value?.tableScroll?.[id] : undefined;
if (scroll) { element.scrollLeft = scroll.left; element.scrollTop = scroll.top; }
});
}
function remove(key:string) {
const index = tabs.value.findIndex(tab => tab.key === key);
if (index < 0) return;
tabs.value.splice(index,1);
if (active.value === key) active.value = tabs.value[Math.min(index,tabs.value.length-1)]?.key || '';
}
function reset(page:string,label:string) { tabs.value = []; open(page,label); }
return {tabs,active,current,open,remember,restoreScroll,remove,reset};
}
+98
View File
@@ -0,0 +1,98 @@
.el-button {
font-weight: 500;
border-radius: var(--app-radius);
}
.el-button:not(.el-button--primary):not(.el-button--success):not(.el-button--warning):not(.el-button--danger):not(.el-button--info) {
--el-button-hover-text-color: var(--app-text);
--el-button-hover-bg-color: var(--app-subtle);
--el-button-hover-border-color: var(--app-border);
--el-button-active-text-color: var(--app-text);
--el-button-active-bg-color: var(--app-hover);
--el-button-active-border-color: var(--app-ring);
}
.el-button--primary:not(.is-plain):not(.is-text):not(.is-link) {
--el-button-text-color: var(--app-primary-foreground);
--el-button-hover-text-color: var(--app-primary-foreground);
--el-button-active-text-color: var(--app-primary-foreground);
--el-button-disabled-text-color: var(--app-primary-foreground);
--el-button-hover-bg-color: var(--el-color-primary-light-3);
--el-button-hover-border-color: var(--el-color-primary-light-3);
--el-button-active-bg-color: var(--el-color-primary-dark-2);
--el-button-active-border-color: var(--el-color-primary-dark-2);
}
.el-button--danger.is-plain {
--el-button-bg-color: var(--el-color-danger-light-9);
--el-button-border-color: transparent;
--el-button-hover-text-color: var(--app-danger);
--el-button-hover-bg-color: var(--el-color-danger-light-8);
--el-button-hover-border-color: var(--el-color-danger-light-8);
}
.el-button:focus-visible,
.el-checkbox__original:focus-visible + .el-checkbox__inner,
.el-switch:focus-visible .el-switch__core,
.tabs button:focus-visible,
.profile button:focus-visible {
outline: 2px solid var(--app-ring);
outline-offset: 2px;
}
.el-input__wrapper, .el-select__wrapper, .el-textarea__inner {
border-radius: var(--app-radius);
}
.el-input__wrapper.is-focus,
.el-select__wrapper.is-focused,
.el-textarea__inner:focus {
box-shadow: 0 0 0 1px var(--app-ring) inset, 0 0 0 2px color-mix(in srgb, var(--app-ring) 20%, transparent);
}
.el-form-item__label { font-size: 12px; font-weight: 500; color: var(--app-text-regular); }
.el-checkbox__inner { border-radius: 3px; }
.el-checkbox__input.is-checked .el-checkbox__inner::after { border-color: var(--app-primary-foreground); }
.el-checkbox__input.is-indeterminate .el-checkbox__inner::before { background-color: var(--app-primary-foreground); }
.el-radio__input.is-checked .el-radio__inner::after { background-color: var(--app-primary-foreground); }
.el-radio-button.is-active .el-radio-button__inner { color: var(--app-primary-foreground); }
.el-switch.is-checked .el-switch__action { background-color: var(--app-primary-foreground); }
.el-tabs__item { height: 36px; font-size: 12px; font-weight: 500; }
.el-tabs__nav-wrap::after { height: 1px; }
.el-tabs__active-bar { height: 2px; }
.el-tabs--border-card { border-radius: var(--app-radius-panel); box-shadow: none; }
.el-card { border-radius: var(--app-radius-panel); }
.el-card.is-always-shadow, .el-card.is-hover-shadow:hover { box-shadow: none; }
.el-tag { border-radius: var(--app-radius); font-weight: 400; }
.el-empty { --el-empty-fill-color-0: var(--app-surface); }
.el-empty__description p { font-size: 12px; }
.el-alert { border-radius: var(--app-radius); }
.el-dialog {
--el-dialog-padding-primary: 0px;
border: 1px solid var(--app-border);
border-radius: var(--app-radius-panel);
background: var(--app-overlay);
box-shadow: var(--app-shadow);
}
.el-dialog__header {
margin: 0;
padding: 14px 44px 14px 18px;
border-bottom: 1px solid var(--app-border);
}
.el-dialog__title, .el-message-box__title { font-size: 15px; font-weight: 600; }
.el-dialog__headerbtn { top: 6px; right: 6px; width: 32px; height: 32px; }
.el-dialog__body { padding: 18px; color: var(--app-text-regular); }
.el-dialog__footer { padding: 12px 18px; border-top: 1px solid var(--app-border); background: var(--app-sidebar); }
.el-drawer { --el-drawer-padding-primary: 18px; background: var(--app-overlay); }
.el-drawer__header { margin-bottom: 0; padding-bottom: 14px; border-bottom: 1px solid var(--app-border); font-size: 15px; font-weight: 600; }
.el-drawer__footer { padding: 12px 18px; background: var(--app-sidebar); }
.el-message-box, .el-message, .el-notification, .el-popover.el-popper { border-radius: var(--app-radius-panel); }
.el-message-box { padding: 16px; }
.el-message-box__header { padding-bottom: 12px; }
.el-select__popper.el-popper, .el-dropdown__popper.el-popper { border-radius: var(--app-radius-panel); }
.el-select-dropdown__item, .el-dropdown-menu__item { font-size: 12px; }
.el-select-dropdown__item.is-selected { background: var(--app-subtle); color: var(--app-text); font-weight: 500; }
.el-pagination { --el-pagination-button-bg-color: var(--app-surface); --el-pagination-hover-color: var(--app-text); }
.el-pagination.is-background .el-pager li.is-active { color: var(--app-primary-foreground); }
.el-descriptions__label.el-descriptions__cell.is-bordered-label { background: var(--app-sidebar); font-weight: 500; }
.el-table__fixed-right-patch { background: var(--app-sidebar); }
.el-table td.el-table-fixed-column--right, .el-table td.el-table-fixed-column--left { background: var(--app-surface); }
.el-table th.el-table-fixed-column--right, .el-table th.el-table-fixed-column--left { background: var(--app-sidebar); }
pre, code, .el-textarea.monospace textarea { font-family: var(--app-font-mono); }
::selection { background: var(--el-color-primary-light-7); color: var(--app-text); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
+58
View File
@@ -44,6 +44,8 @@ export function parseConnectionURL(input: string, protocol: string): any {
if (['mysql','pgsql','sqlserver','redis'].includes(protocol)) allowed.push('database');
if (protocol === 'redis') allowed.push('db');
if (protocol === 'pgsql') allowed.push('sslmode');
if (protocol === 'mysql') allowed.push('charset');
if (protocol === 'pgsql') allowed.push('application_name');
if (protocol === 'sqlserver') allowed.push('encrypt');
if (protocol === 'rabbitmq') allowed.push('vhost');
decode(url.search);
@@ -81,5 +83,61 @@ export function parseConnectionURL(input: string, protocol: string): any {
result.database = String(Number(database || 0));
} else if (protocol === 'mysql') result.database = database;
else if (['pgsql','sqlserver','rabbitmq'].includes(protocol)) result.authSource = database || ({pgsql:'postgres',sqlserver:'master',rabbitmq:'/'} as Record<string,string>)[protocol];
if (params.has('charset')) {
const charset = params.get('charset')!;
if (!['utf8mb4','utf8','latin1','ascii'].includes(charset)) throw new Error('charset 支持 utf8mb4、utf8、latin1 或 ascii');
result.connectionOptions = {charset};
}
if (params.has('application_name')) {
const applicationName = params.get('application_name')!;
if (!applicationName || /[\r\n\x00]/.test(applicationName) || new TextEncoder().encode(applicationName).length > 63) throw new Error('application_name 需要包含 1–63 个 UTF-8 字节且不能包含换行或空字符');
result.connectionOptions = {applicationName};
}
return result;
}
export function connectionParameters(form:any): string {
const params = new URLSearchParams();
const protocol = form.type;
if (!['ssh','sftp','http','https'].includes(protocol)) params.set('tls', String(!!form.targetTLS));
if (['mysql','redis'].includes(protocol) && form.database) params.set('database', form.database);
if (['pgsql','sqlserver'].includes(protocol) && form.authSource) params.set('database', form.authSource);
if (protocol === 'rabbitmq' && form.authSource) params.set('vhost', form.authSource);
if (protocol === 'mysql' && form.connectionOptions?.charset) params.set('charset', form.connectionOptions.charset);
if (protocol === 'pgsql' && form.connectionOptions?.applicationName) params.set('application_name', form.connectionOptions.applicationName);
if (protocol === 'mongodb') {
if (form.authSource) params.set('authSource', form.authSource);
if (form.cluster?.mode === 'mongodb-replica-set' && form.cluster.name) params.set('replicaSet', form.cluster.name);
if (form.cluster?.mode === 'single') params.set('directConnection', 'true');
params.set('compressors', form.targetCompression === 'zlib' ? 'zlib' : '');
}
return params.toString();
}
export function parseConnectionParameters(input:string, form:any): any {
const raw = input.trim().replace(/^\?/, '');
if (/[#\s\x00-\x1f\x7f]/.test(raw) || /%(?![0-9a-f]{2})/i.test(raw)) throw new Error('参数中的特殊字符需要百分号编码');
if (raw.split('&').filter(Boolean).some(part => !/^[^=]+=.*/.test(part))) throw new Error('参数需要使用名称=值格式');
const supplied = new URLSearchParams(raw);
const keys:string[] = []; supplied.forEach((_value,key) => keys.push(key.toLowerCase()));
if (keys.some(key => ['user','username','password'].includes(key))) throw new Error('请在账号页维护凭据,URL 参数区不接受用户名或密码');
const scheme = form.type === 'mongodb' ? 'mongodb' : schemes[form.type]?.[0];
if (!scheme) throw new Error('此协议不支持 URL 参数');
const host = (value:string) => value.includes(':') ? `[${value}]` : value;
const nodes = form.type === 'mongodb' ? [{host:form.targetHost || 'localhost',port:form.targetPort || 27017}, ...(form.cluster?.nodes || [])].map(node => `${host(node.host)}:${node.port}`).join(',') : 'localhost';
const parsed = parseConnectionURL(`${scheme}://${nodes}/?${raw}`, form.type);
const result:any = {connectionOptions:{...form.connectionOptions}};
if (form.type === 'mysql') result.connectionOptions.charset = parsed.connectionOptions?.charset || '';
if (form.type === 'pgsql') result.connectionOptions.applicationName = parsed.connectionOptions?.applicationName || '';
if (keys.some(key => ['tls','ssl','sslmode','encrypt'].includes(key))) result.targetTLS = parsed.targetTLS;
if (keys.some(key => ['database','db','vhost'].includes(key))) {
if (['mysql','redis'].includes(form.type)) result.database = parsed.database;
else result.authSource = parsed.authSource;
}
if (form.type === 'mongodb') {
if (keys.includes('authsource')) result.authSource = parsed.authSource;
if (keys.includes('compressors')) result.targetCompression = parsed.targetCompression;
if (keys.some(key => ['replicaset','directconnection'].includes(key))) result.cluster = {...form.cluster, ...parsed.cluster};
}
return result;
}
+17
View File
@@ -26,6 +26,16 @@ function validCIDR(value: string): boolean {
}
export function validateTarget(draft: any, options: Options = {}): FieldIssue[] {
const {issues,add} = collector();
issues.push(...identityIssues(draft, 'target'));
const connection = draft.connectionOptions || {};
for (const [key, maximum] of Object.entries({connectTimeoutSeconds:120,queryTimeoutSeconds:86400,idleTimeoutSeconds:86400,keepAliveSeconds:3600})) {
const value = connection[key];
if (value != null && (!Number.isInteger(value) || value < 0 || value > maximum)) add(`connectionOptions.${key}`,'连接参数',`请输入 0–${maximum} 的整数`, 'advanced');
}
if (connection.queryTimeoutSeconds && draft.type !== 'pgsql') add('connectionOptions.queryTimeoutSeconds','查询超时','仅 PostgreSQL 支持查询超时','advanced');
if (connection.idleTimeoutSeconds && draft.type !== 'mongodb') add('connectionOptions.idleTimeoutSeconds','空闲超时','仅 MongoDB 支持空闲超时','advanced');
if (connection.charset && (draft.type !== 'mysql' || !['utf8mb4','utf8','latin1','ascii'].includes(connection.charset))) add('connectionOptions.charset','字符集','MySQL 支持 utf8mb4、utf8、latin1 或 ascii');
if (connection.applicationName && (draft.type !== 'pgsql' || bytes(connection.applicationName) > 63 || /[\r\n\x00]/.test(connection.applicationName))) add('connectionOptions.applicationName','应用名称','仅 PostgreSQL 支持,最多 63 个 UTF-8 字节且不包含换行或空字符');
if (['auto','manual'].includes(draft.cluster?.mode)) add('cluster','部署模式','请手动选择部署模式');
if (draft.database && (bytes(draft.database) > 128 || /[\r\n\x00]/.test(draft.database))) add('database','默认数据库','数据库名称无效');
if (draft.type === 'redis' && draft.database && (!/^(0|[1-9]\d*)$/.test(draft.database) || Number(draft.database) > 2147483647)) add('database','数据库编号','数据库编号必须为 0–2147483647 的整数');
@@ -55,8 +65,15 @@ export function validateTarget(draft: any, options: Options = {}): FieldIssue[]
}
return issues;
}
function identityIssues(draft:any, tab:string): FieldIssue[] {
const issues:FieldIssue[] = [];
if (draft.color && !/^#[0-9a-f]{6}$/i.test(draft.color)) issues.push({path:'color',label:'连接颜色',message:'请选择六位十六进制颜色',tab});
if (bytes(draft.remark) > 2000 || /\x00/.test(draft.remark || '')) issues.push({path:'remark',label:'连接备注',message:'备注最多 2000 个 UTF-8 字节,不可包含空字符',tab});
return issues;
}
export function validateGateway(draft: any, options: Options = {}): FieldIssue[] {
const {issues,add} = collector();
issues.push(...identityIssues(draft,'gateway'));
issues.push(...scopeIssues(draft, 'gateway'));
if (!draft.name?.trim() || bytes(draft.name) > 100) add('name','网关名称','名称需要包含 1–100 个 UTF-8 字节','gateway');
if (!validIP(draft.listenHost || '')) add('listenHost','监听 IP','请输入有效的 IPv4 或 IPv6 地址','gateway');
+1
View File
@@ -0,0 +1 @@
export type ResourceAction = {id:string;label:string;disabled?:boolean;danger?:boolean;run:()=>unknown};
+2 -1
View File
@@ -1,8 +1,9 @@
// Saving and testing use the same secret omission and asset-selection rules.
export function targetDraftPayload(draft:any) {
const source = JSON.parse(JSON.stringify(draft));
const fields = ['name','project','environment','group','type','targetHost','targetPort','targetTLS','targetCA','targetCAAssetId','targetCompression','authSource','database','outboundId','cluster'];
const fields = ['name','project','environment','group','type','targetHost','targetPort','targetTLS','targetCA','targetCAAssetId','targetCompression','authSource','database','outboundId','cluster','connectionOptions','color','remark','showRemark'];
const result:any = Object.fromEntries(fields.filter(key => key in source).map(key => [key, source[key]]));
result.connectionOptions = {charset:source.connectionOptions?.charset || '',applicationName:source.connectionOptions?.applicationName || '',connectTimeoutSeconds:source.connectionOptions?.connectTimeoutSeconds || 0,queryTimeoutSeconds:source.connectionOptions?.queryTimeoutSeconds || 0,idleTimeoutSeconds:source.connectionOptions?.idleTimeoutSeconds || 0,keepAliveSeconds:source.connectionOptions?.keepAliveSeconds ?? null};
result.ssh = source.ssh?.ignoreHostKey === undefined ? {} : {ignoreHostKey:source.ssh.ignoreHostKey};
result.accounts = (source.accounts || []).map((account:any) => {
if (['ssh','sftp'].includes(source.type) && account.authMethod !== 'privatekey') {
+2
View File
@@ -2,7 +2,9 @@ import { createApp } from "vue";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import "element-plus/theme-chalk/dark/css-vars.css";
import "./theme.css";
import "./style.css";
import "./controls.css";
import "./responsive.css";
import App from "./App.vue";
createApp(App).use(ElementPlus).mount("#app");
+12 -13
View File
@@ -33,10 +33,10 @@
.el-checkbox__label { white-space: normal; overflow-wrap: anywhere; }
.el-checkbox__input, .setting-row > .el-switch { flex-shrink: 0; }
.el-radio-group { flex-wrap: wrap; gap: 6px; max-width: 100%; }
.el-radio-button__inner { border: 1px solid var(--el-border-color); border-radius: 5px; }
.el-radio-button__inner { border: 1px solid var(--el-border-color); border-radius: var(--app-radius); }
.el-radio-button.is-active .el-radio-button__inner { box-shadow: none; }
.el-radio-button:first-child .el-radio-button__inner,
.el-radio-button:last-child .el-radio-button__inner { border-radius: 5px; }
.el-radio-button:last-child .el-radio-button__inner { border-radius: var(--app-radius); }
.el-table { max-width: 100%; }
.el-table .cell { overflow-wrap: anywhere; }
.el-table__body-wrapper .el-scrollbar__bar { opacity: 1; }
@@ -82,17 +82,16 @@
.target-filter { justify-content: flex-start; }
}
@media (max-width: 720px) {
.sidebar { padding-top: 16px; padding-bottom: 16px; }
.sidebar .brand { margin-bottom: 20px; }
.sidebar { padding: 0; }
.sidebar .brand { margin-bottom: 8px; }
.sidebar nav button { min-height: 44px; justify-content: center; padding: 10px; }
.sidebar nav button span { flex-shrink: 0; }
.sidebar-bottom { display: block; margin-top: 12px; }
.sidebar-bottom { display: block; margin-top: 8px; padding: 6px 4px; }
.sidebar-bottom .online, .sidebar-bottom .profile > div,
.sidebar-bottom .profile > span { display: none; }
.sidebar-bottom .profile { justify-content: center; padding: 0; }
.sidebar-bottom .profile button { min-width: 40px; min-height: 40px; }
.content { padding: 20px 12px; }
.page-heading { gap: 12px; margin-bottom: 20px; }
.content { padding: 16px 12px; }
.page-heading { gap: 12px; margin-bottom: 16px; }
.page-heading > div { flex: 1 1 100%; }
.page-heading > .el-button { margin-left: 0; }
.form-grid { grid-template-columns: minmax(0, 1fr); }
@@ -114,7 +113,9 @@
.asset-toolbar > p { margin-top: 0; }
.asset-toolbar > .el-button { margin-left: 0; }
.setting-row { padding: 12px; }
.el-dialog { --el-dialog-padding-primary: 14px; }
.el-dialog__body { padding: 14px; }
.el-dialog__header { padding: 12px 40px 12px 14px; }
.el-dialog__footer { padding: 10px 14px; }
.el-drawer { --el-drawer-padding-primary: 16px; }
.el-dialog__footer > .el-button, .el-drawer__footer > .el-button { flex: 1 1 auto; }
.el-radio-button__inner { white-space: normal; line-height: 1.5; }
@@ -133,8 +134,6 @@
.el-dialog__footer, .el-drawer__footer { justify-content: stretch; }
}
@media (max-height: 700px) and (min-width: 721px) {
.sidebar { padding-top: 16px; padding-bottom: 16px; }
.sidebar .brand { margin-bottom: 20px; }
.sidebar .nav-caption { margin-bottom: 8px; }
.sidebar .online { margin-top: 12px; margin-bottom: 12px; }
.sidebar .nav-caption { padding-top: 8px; }
.sidebar .online { margin-bottom: 6px; }
}
+214 -294
View File
@@ -1,25 +1,9 @@
:root {
font-family: Inter, "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--app-text);
background: var(--app-bg);
font-synthesis: none;
--el-color-primary: #187766;
--el-color-primary-light-3: #519b8c;
--el-color-primary-light-5: #8bbab0;
--el-color-primary-light-7: #b9d7d0;
--el-color-primary-light-8: #d0e5df;
--el-color-primary-light-9: #eaf4f0;
--el-color-primary-dark-2: #105b4e;
--el-border-radius-base: 7px;
--el-font-size-base: 13px;
--el-text-color-primary: var(--app-text);
--el-border-color: var(--app-border);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
line-height: 1.5;
}
button,
input {
@@ -37,109 +21,136 @@ p {
.brand {
display: flex;
align-items: center;
gap: 11px;
font-size: 23px;
gap: 9px;
font-size: 16px;
font-weight: 650;
letter-spacing: -0.8px;
letter-spacing: -0.4px;
}
.brand-mark {
width: 35px;
height: 35px;
background: #b9edc8;
border-radius: 10px;
color: #143d32;
width: 28px;
height: 28px;
background: var(--app-primary);
border-radius: var(--app-radius);
color: var(--app-primary-foreground);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 31px;
line-height: 1;
}
.brand-mark span {
font-size: 22px;
margin-left: 1px;
flex-shrink: 0;
}
.eyebrow {
font-size: 10px;
font-weight: 700;
letter-spacing: 2px;
color: #6a9387;
color: var(--app-text-muted);
}
.app-shell {
--app-sidebar-width: var(--workspace-sidebar-width, 224px);
display: flex;
min-height: 100vh;
}
.sidebar {
position: fixed;
width: 226px;
width: var(--app-sidebar-width);
height: 100vh;
background: var(--app-sidebar);
color: #ecf6f0;
padding: 33px 22px;
color: var(--app-text);
border-right: 1px solid var(--app-border);
padding: 0;
display: flex;
flex-direction: column;
}
.sidebar .brand {
margin: 0 5px 53px;
min-height: 44px;
padding: 0 12px;
margin: 0;
border-bottom: 1px solid var(--app-border);
}
.nav-caption {
color: #76928a;
color: var(--app-text-muted);
font-size: 11px;
letter-spacing: 1px;
padding: 0 14px;
margin-bottom: 16px;
font-weight: 500;
padding: 14px 12px 8px;
}
nav {
.sidebar nav {
display: grid;
gap: 9px;
gap: 2px;
padding: 0 6px 8px;
}
.sidebar nav {
min-height: 0;
max-height: 48vh;
overflow-y: auto;
flex: 1;
align-content: start;
}
.sidebar-resizer { position:absolute; top:0; right:-3px; width:6px; height:100%; cursor:col-resize; z-index:5; touch-action:none; }
.sidebar-resizer:hover,.sidebar-resizer:focus-visible { background:var(--app-primary); outline:0; }
.configurable-table { min-width:0; max-width:100%; }
@media(max-width:1050px) { .sidebar-resizer { display:none; } }
.sidebar .brand,
.sidebar .nav-caption,
.sidebar .sidebar-bottom {
flex-shrink: 0;
}
nav button {
.sidebar nav button {
border: 0;
background: transparent;
color: #aebfb9;
color: var(--app-text-regular);
display: flex;
align-items: center;
text-align: left;
gap: 13px;
padding: 13px 14px;
border-radius: 7px;
gap: 9px;
min-height: 34px;
padding: 7px 10px;
border-radius: var(--app-radius);
font-size: 12px;
position: relative;
}
nav button span {
font-size: 20px;
width: 20px;
.sidebar .nav-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
nav button.active {
background: #25483f;
color: #d3f2de;
.sidebar nav button.active {
background: var(--app-subtle);
color: var(--app-text);
font-weight: 600;
}
nav button:hover {
background: #21413a;
.sidebar nav button.active::before {
content: "";
position: absolute;
left: 0;
top: 9px;
bottom: 9px;
width: 2px;
background: var(--app-primary);
border-radius: 2px;
}
nav i {
.sidebar nav button:hover {
background: var(--app-hover);
}
.sidebar nav button:focus-visible {
outline: 2px solid var(--app-ring);
outline-offset: -2px;
}
.sidebar nav i {
font-style: normal;
margin-left: auto;
background: #3d5a4e;
padding: 2px 7px;
border-radius: 5px;
color: var(--app-text-muted);
padding: 0 4px;
font-variant-numeric: tabular-nums;
font-size: 11px;
}
.sidebar-bottom {
margin-top: auto;
padding: 10px 12px;
border-top: 1px solid var(--app-border);
}
.online {
font-size: 11px;
color: #a9c0b5;
margin: 25px 9px;
color: var(--app-text-muted);
margin: 0 0 10px;
}
.online b,
.environment i {
@@ -147,112 +158,125 @@ nav i {
width: 6px;
height: 6px;
border-radius: 50%;
background: #8ad8a9;
background: var(--app-success);
margin-right: 8px;
}
.profile {
border-top: 1px solid #31483f;
padding-top: 22px;
display: flex;
align-items: center;
gap: 10px;
}
.profile > span {
background: #355548;
border-radius: 9px;
padding: 10px 14px;
color: #c6e3cf;
background: var(--app-subtle);
border: 1px solid var(--app-border);
border-radius: var(--app-radius);
padding: 4px 9px;
color: var(--app-text-regular);
}
.profile div {
display: grid;
gap: 4px;
gap: 1px;
}
.profile strong {
font-size: 12px;
}
.profile small {
font-size: 10px;
color: #9ab5a7;
color: var(--app-text-muted);
}
.profile button {
margin-left: auto;
color: #a4bbae;
color: var(--app-text-muted);
background: none;
border: 0;
font-size: 22px;
display: inline-flex;
padding: 6px;
border-radius: var(--app-radius);
}
.workspace {
margin-left: 226px;
width: calc(100% - 226px);
margin-left: var(--app-sidebar-width);
width: calc(100% - var(--app-sidebar-width));
}
.topbar {
height: 72px;
min-height: 44px;
border-bottom: 1px solid var(--app-border);
background: var(--app-surface);
background: var(--app-chrome);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 38px;
color: #7b8781;
padding: 4px 16px;
gap: 12px;
color: var(--app-text-muted);
font-size: 12px;
}
.topbar b {
margin: 0 15px;
color: #bcc5bf;
color: var(--app-text-muted);
font-weight: 400;
}
.workspace-location, .workspace-tools {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.workspace-location__page {
color: var(--app-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-tools { flex-shrink: 0; }
.environment {
font-size: 9px;
letter-spacing: 1.5px;
color: #748b7e;
font-size: 11px;
color: var(--app-text-muted);
}
.content {
padding: 38px;
max-width: 1680px;
padding: 18px 20px;
margin: auto;
}
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin: 5px 0 29px;
margin: 0 0 18px;
gap: 18px;
}
.page-heading h1 {
font-size: 30px;
letter-spacing: -1px;
margin: 10px 0 9px;
font-weight: 650;
font-size: 20px;
letter-spacing: -0.4px;
margin: 0 0 6px;
font-weight: 600;
}
.page-heading p {
font-size: 12px;
color: #839087;
color: var(--app-text-muted);
margin-bottom: 0;
}
.page-heading .el-button {
padding: 22px 23px;
padding: 0 12px;
height: 32px;
}
.stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
margin-bottom: 28px;
gap: 12px;
margin-bottom: 16px;
}
.stats article {
border: 1px solid var(--app-border);
background: var(--app-surface);
padding: 22px 23px;
border-radius: 10px;
padding: 16px;
border-radius: var(--app-radius-panel);
display: flex;
flex-direction: column;
gap: 15px;
gap: 10px;
}
.stats article > span {
font-size: 12px;
color: #75867a;
color: var(--app-text-muted);
}
.stats strong {
font-size: 34px;
font-size: 28px;
font-weight: 550;
letter-spacing: -1px;
font-variant-numeric: tabular-nums;
@@ -260,7 +284,7 @@ nav i {
}
.stats small {
font-size: 10px;
color: #9aa59e;
color: var(--app-text-muted);
}
.dot {
display: inline-block;
@@ -271,19 +295,19 @@ nav i {
vertical-align: middle;
}
.green {
background: #3e9b73;
background: var(--app-success);
}
.table-card {
background: var(--app-surface);
border: 1px solid var(--app-border);
border-radius: 10px;
border-radius: var(--app-radius-panel);
overflow: hidden;
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 22px;
padding: 12px 14px;
border-bottom: 1px solid var(--app-border);
gap: 15px;
}
@@ -294,22 +318,22 @@ nav i {
.tabs button {
background: none;
border: 0;
color: #87948a;
border-radius: 6px;
padding: 8px 13px;
color: var(--app-text-muted);
border-radius: var(--app-radius);
padding: 6px 10px;
font-size: 12px;
}
.tabs button.active {
background: #edf4ef;
color: #277758;
background: var(--app-subtle);
color: var(--app-text);
font-weight: 600;
}
.search {
max-width: 230px;
}
.el-table {
--el-table-header-bg-color: var(--app-subtle);
--el-table-header-text-color: #849086;
--el-table-header-bg-color: var(--app-sidebar);
--el-table-header-text-color: var(--app-text-muted);
--el-table-row-hover-bg-color: var(--app-subtle);
--el-table-border-color: var(--app-border);
--el-table-text-color: var(--app-text);
@@ -317,14 +341,14 @@ nav i {
}
.el-table th.el-table__cell {
font-weight: 500;
font-size: 11px;
height: 44px;
font-size: 12px;
height: 36px;
}
.el-table td.el-table__cell {
height: 78px;
height: 52px;
}
.el-table .cell {
padding: 0 22px;
padding: 0 14px;
}
.el-table .el-button {
font-size: 12px;
@@ -332,7 +356,7 @@ nav i {
.proxy-name {
display: flex;
align-items: center;
gap: 12px;
gap: 9px;
}
.proxy-name > div {
display: grid;
@@ -344,13 +368,15 @@ nav i {
}
.proxy-name small {
font-size: 10px;
color: #99a39b;
color: var(--app-text-muted);
}
.type-icon {
width: 37px;
height: 39px;
border: 1px solid #e0ebe8;
border-radius: 8px;
width: 30px;
height: 30px;
border: 1px solid var(--app-border);
border-radius: var(--app-radius);
background: var(--app-subtle);
color: var(--app-text-regular);
display: flex;
align-items: center;
justify-content: center;
@@ -358,35 +384,20 @@ nav i {
font-size: 16px;
font-weight: 650;
}
.type-icon.mysql {
background: #eef5f8;
color: #477c8a;
border-color: #dce9ee;
}
.type-icon.mongodb {
background: #f0f7e9;
color: #6b9246;
border-color: #e0ead3;
}
.type-icon.ssh {
background: #f4f1f9;
color: #8c78a2;
border-color: #e6e0ed;
}
code {
font-family: "Cascadia Code", Consolas, monospace;
font-family: var(--app-font-mono);
font-size: 11px;
}
.cell-sub {
display: block;
font-size: 10px;
color: #9aa69b;
color: var(--app-text-muted);
line-height: 20px;
}
.status {
font-size: 11px;
white-space: nowrap;
color: #909c91;
color: var(--app-text-muted);
}
.status i {
display: inline-block;
@@ -397,10 +408,10 @@ code {
margin-right: 6px;
}
.status.running {
color: #318459;
color: var(--app-success);
}
.status.failed {
color: #b86240;
color: var(--app-danger);
}
.mode-tag {
font-size: 10px;
@@ -408,14 +419,16 @@ code {
border: 1px solid var(--app-border);
border-radius: 4px;
padding: 2px 7px;
color: #6d7d6b;
color: var(--app-text-regular);
}
.table-footer {
display: flex;
justify-content: space-between;
padding: 18px 24px;
font-size: 10px;
color: #9aa698;
padding: 10px 14px;
font-size: 11px;
color: var(--app-text-muted);
border-top: 1px solid var(--app-border);
background: var(--app-sidebar);
}
.empty-guide {
text-align: center;
@@ -423,7 +436,7 @@ code {
}
.empty-guide > span {
font-size: 36px;
color: #76a58a;
color: var(--app-text-muted);
}
.empty-guide h3 {
font-size: 15px;
@@ -431,7 +444,7 @@ code {
}
.empty-guide p {
font-size: 12px;
color: #899788;
color: var(--app-text-muted);
}
.empty-guide .el-button {
margin-top: 8px;
@@ -439,18 +452,18 @@ code {
.workspace-footer {
display: flex;
justify-content: space-between;
font-size: 10px;
color: #a0aaa0;
margin-top: 25px;
font-size: 11px;
color: var(--app-text-muted);
margin-top: 16px;
}
.event-card {
margin-top: 26px;
margin-top: 16px;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 23px;
padding: 12px 14px;
}
.section-heading h3 {
font-size: 14px;
@@ -458,7 +471,7 @@ code {
}
.section-heading span {
font-size: 10px;
color: #8e9c8d;
color: var(--app-text-muted);
}
.form-grid {
display: grid;
@@ -466,7 +479,7 @@ code {
gap: 0 18px;
}
.config-form .el-form-item {
margin-bottom: 22px;
margin-bottom: 16px;
}
.config-form .el-input-number {
width: 100%;
@@ -476,12 +489,12 @@ code {
}
.setting-row {
border: 1px solid var(--app-border);
border-radius: 8px;
border-radius: var(--app-radius-panel);
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px;
margin-bottom: 20px;
padding: 12px;
margin-bottom: 16px;
gap: 12px;
}
.setting-row strong {
@@ -491,12 +504,12 @@ code {
.form-help {
font-size: 12px;
line-height: 1.7;
color: #8a978b;
color: var(--app-text-muted);
margin: 6px 0 0;
}
.optional {
background: var(--app-subtle);
color: #6d916e;
color: var(--app-text-muted);
font-weight: 400;
font-size: 10px;
padding: 2px 6px;
@@ -505,14 +518,14 @@ code {
}
.fingerprint {
overflow-wrap: anywhere;
font: 11px monospace;
color: #7b907b;
font: 11px var(--app-font-mono);
color: var(--app-text-muted);
}
.secret-result {
padding: 18px;
margin-top: 20px;
background: var(--app-bg);
border-radius: 8px;
border-radius: var(--app-radius-panel);
display: flex;
gap: 10px;
align-items: center;
@@ -529,16 +542,17 @@ code {
grid-template-columns: 1fr 1fr;
}
.login-story {
padding: 50px 65px;
padding: 40px 48px;
background: var(--app-sidebar);
color: white;
color: var(--app-text);
border-right: 1px solid var(--app-border);
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 100vh;
}
.login-story h1 {
font-size: 48px;
font-size: 36px;
line-height: 1.5;
letter-spacing: -1.5px;
margin: 22px 0;
@@ -546,10 +560,10 @@ code {
.login-story p {
font-size: 14px;
line-height: 2;
color: #a0b8ad;
color: var(--app-text-muted);
}
.login-story small {
color: #59796c;
color: var(--app-text-muted);
font-size: 9px;
letter-spacing: 2px;
}
@@ -559,29 +573,29 @@ code {
margin-top: 35px;
}
.protocol-pills span {
border: 1px solid #3e5a4d;
border-radius: 30px;
padding: 8px 18px;
color: #b5ccbd;
border: 1px solid var(--app-border);
border-radius: var(--app-radius);
padding: 5px 12px;
color: var(--app-text-regular);
font-size: 12px;
}
.login-panel {
display: flex;
align-items: center;
justify-content: center;
background: var(--app-subtle);
background: var(--app-bg);
padding: 35px;
}
.login-card {
width: 355px;
}
.login-card h2 {
font-size: 26px;
font-size: 22px;
margin: 14px 0 10px;
}
.login-card > p {
font-size: 13px;
color: #92a08f;
color: var(--app-text-muted);
margin-bottom: 32px;
}
.login-card .el-form-item {
@@ -593,13 +607,13 @@ code {
.login-card .login-note {
font-size: 11px;
margin: 25px 0;
color: #a3ada0;
color: var(--app-text-muted);
}
.loading {
height: 100vh;
display: grid;
place-items: center;
color: #718c79;
color: var(--app-text-muted);
}
.el-message-box__message {
white-space: pre-wrap;
@@ -648,26 +662,15 @@ code {
.el-select {
width: 100%;
}
@media (min-width: 1600px) {
.content {
padding: 45px 55px;
}
}
@media (max-width: 1050px) {
.sidebar {
width: 185px;
padding: 28px 15px;
}
.workspace {
margin-left: 185px;
width: calc(100% - 185px);
}
.app-shell { --app-sidebar-width: 192px; }
.content {
padding: 26px 22px;
padding: 16px;
}
.topbar {
padding: 0 22px;
padding: 4px 12px;
}
.environment { display: none; }
.stats {
gap: 10px;
}
@@ -681,10 +684,11 @@ code {
padding: 40px;
}
.login-story h1 {
font-size: 38px;
font-size: 30px;
}
}
@media (max-width: 720px) {
.app-shell { --app-sidebar-width: 52px; }
.proxy-table {
display: none;
}
@@ -692,55 +696,41 @@ code {
display: block;
}
.sidebar {
width: 66px;
padding: 25px 8px;
padding: 0;
}
.sidebar .brand {
font-size: 0;
margin: 0 6px 35px;
margin: 0 0 8px;
padding: 0;
justify-content: center;
}
.sidebar .brand-mark {
font-size: 30px;
}
.nav-caption,
.sidebar-bottom {
.nav-caption, .sidebar .nav-label {
display: none;
}
nav button {
font-size: 0;
padding: 12px;
}
nav button span {
font-size: 21px;
}
nav i {
.sidebar nav { padding: 0 4px 8px; }
.sidebar nav i {
display: none;
}
.workspace {
margin-left: 66px;
width: calc(100% - 66px);
}
.content {
padding: 22px 14px;
}
.topbar {
padding: 0 16px;
height: 56px;
}
.environment {
display: none;
padding: 4px 8px;
gap: 8px;
}
.workspace-location { gap: 6px; }
.workspace-location__root, .workspace-location > b { display: none; }
.page-heading {
align-items: flex-start;
}
.page-heading h1 {
font-size: 25px;
font-size: 18px;
}
.page-heading p {
line-height: 1.8;
}
.page-heading .el-button {
padding: 16px 12px;
padding: 0 12px;
font-size: 12px;
}
.stats {
@@ -779,90 +769,23 @@ code {
}
}
:root {
--app-text: #243733;
--app-bg: #f5f7f6;
--app-surface: #ffffff;
--app-subtle: #f0f5f2;
--app-border: #dfe6e2;
--app-sidebar: #142d2b;
}
:root[data-theme="ocean"] {
--app-text: #253b53;
--app-bg: #eff5fc;
--app-subtle: #eaf2fb;
--app-border: #d6e2ef;
--app-sidebar: #132b49;
--el-color-primary: #286fc4;
--el-color-primary-light-3: #689bd6;
--el-color-primary-light-5: #94b7e1;
--el-color-primary-light-7: #bed4ed;
--el-color-primary-light-8: #d4e3f3;
--el-color-primary-light-9: #e9f1f9;
--el-color-primary-dark-2: #20599d;
}
:root[data-theme="dark"] {
--app-text: #e0e7ec;
--app-bg: #14191f;
--app-surface: #1d252e;
--app-subtle: #252f39;
--app-border: #37434e;
--app-sidebar: #10161c;
--el-color-primary: #67c9b4;
--el-color-primary-light-3: #529f90;
--el-color-primary-light-5: #427c72;
--el-color-primary-light-7: #315a55;
--el-color-primary-light-8: #294944;
--el-color-primary-light-9: #203833;
--el-color-primary-dark-2: #85d4c3;
--el-bg-color: var(--app-surface);
--el-bg-color-overlay: var(--app-surface);
--el-bg-color-page: var(--app-bg);
--el-fill-color-blank: var(--app-surface);
--el-fill-color-light: var(--app-subtle);
--el-text-color-primary: var(--app-text);
--el-text-color-regular: #c2ccd5;
--el-text-color-secondary: #a3b1bd;
--el-border-color: var(--app-border);
--el-border-color-light: var(--app-border);
--el-border-color-lighter: var(--app-border);
}
[data-theme="dark"] .tabs button.active,
[data-theme="ocean"] .tabs button.active { background: var(--el-color-primary-light-9); color: var(--el-color-primary); }
[data-theme="dark"] .type-icon { background: var(--app-subtle); border-color: var(--app-border); color: #bad7ec; }
[data-theme="dark"] .mode-tag { color: #c2ccd5; }
[data-theme="dark"] .cell-sub,
[data-theme="dark"] .form-help,
[data-theme="dark"] .setting-row p,
[data-theme="dark"] .page-heading p,
[data-theme="dark"] .target-card p { color: #a3b1bd; }
[data-theme="dark"] .status.running { color: #79d6a7; }
[data-theme="dark"] .status.failed { color: #f0a082; }
[data-theme="ocean"] nav button.active { background: #214b78; }
.theme-picker { position: fixed; z-index: 1000; top: 15px; right: 22px; display: flex; align-items: center; gap: 8px; color: var(--app-text); background: var(--app-surface); padding: 5px 9px; border: 1px solid var(--app-border); border-radius: 8px; font-size: 12px; }
.theme-picker select { color: inherit; background: inherit; border: 0; padding: 3px; font: inherit; max-width: 130px; }
.theme-picker select:focus-visible { outline: 2px solid var(--el-color-primary); }
.topbar .environment { margin-right: 190px; }
.target-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(310px, 100%), 1fr)); gap: 18px; }
.login-theme-picker { position: fixed; z-index: 10; top: 12px; right: 16px; padding: 4px 8px; border: 1px solid var(--app-border); border-radius: var(--app-radius); background: var(--app-chrome); }
.target-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(310px, 100%), 1fr)); gap: 12px; }
.target-filter { grid-column: 1 / -1; display: flex; justify-content: flex-end; }
.target-filter .el-select { width: min(240px, 100%); }
.target-card { min-width: 0; padding: 24px; border: 1px solid var(--app-border); border-radius: 12px; background: var(--app-surface); overflow-wrap: anywhere; }
.target-card { min-width: 0; padding: 16px; border: 1px solid var(--app-border); border-radius: var(--app-radius-panel); background: var(--app-surface); overflow-wrap: anywhere; }
.target-card p { color: var(--el-text-color-secondary); font-size: 13px; }
.target-card h3 { margin: 0; }
.target-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.target-actions .el-button + .el-button { margin-left: 0; }
@media (max-width: 720px) {
.theme-picker { top: 11px; right: 10px; padding: 3px 5px; }
.theme-picker label { display: none; }
.theme-picker select { max-width: 96px; }
.topbar > span:first-child { font-size: 0; }
.topbar > span:first-child b { display: none; }
.target-card { padding: 18px; }
.login-theme-picker { top: 10px; right: 10px; }
.target-card { padding: 14px; }
}
.command-filters .el-select { width: min(250px, 100%); }
.command-filters { flex-wrap: wrap; gap: 12px; }
.command-filters > .el-input { width: min(250px, 100%); }
.audit-note { padding: 16px 22px 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.8; }
.audit-note { padding: 12px 14px 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.8; }
.command-preview { display: block; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 65px; overflow: hidden; font-size: 12px; }
.command-card { min-width: 0; }
.command-card .command-filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(210px, 100%), 1fr)); align-items: center; }
@@ -873,7 +796,4 @@ code {
.command-card .table-footer { display: flex; flex-wrap: wrap; gap: 12px; }
.command-record-dialog { max-height: 90dvh; display: flex; flex-direction: column; }
.command-record-dialog .el-dialog__body { min-height: 0; overflow-y: auto; }
.command-detail { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 65vh; overflow: auto; background: var(--app-subtle); color: var(--app-text); padding: 18px; border-radius: 8px; }
[data-theme="dark"] .el-table td.el-table-fixed-column--right,
[data-theme="dark"] .el-table td.el-table-fixed-column--left { background: var(--app-surface); }
[data-theme="dark"] .el-table th.el-table-fixed-column--right { background: var(--app-subtle); }
.command-detail { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 65vh; overflow: auto; background: var(--app-subtle); color: var(--app-text); padding: 14px; border-radius: var(--app-radius-panel); }
+131
View File
@@ -0,0 +1,131 @@
:root {
--app-font-sans: "Geist Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
--app-font-mono: "Cascadia Code", "Cascadia Mono", "JetBrains Mono", Consolas, monospace;
--app-text: #0a0a0a;
--app-text-regular: #404040;
--app-text-muted: #737373;
--app-bg: #ffffff;
--app-surface: #ffffff;
--app-overlay: #ffffff;
--app-subtle: #f5f5f5;
--app-hover: #f0f0f0;
--app-border: #e5e5e5;
--app-input-border: #e5e5e5;
--app-sidebar: #fafafa;
--app-chrome: #f5f5f5;
--app-primary: #171717;
--app-primary-foreground: #fafafa;
--app-ring: #a1a1a1;
--app-success: #16803d;
--app-warning: #b66a05;
--app-danger: #e7000b;
--app-radius: 4px;
--app-radius-panel: 6px;
--app-sidebar-width: 224px;
--app-shadow: 0 8px 30px #00000012, 0 2px 8px #00000008;
}
:root[data-theme="ocean"] {
--app-primary: #2563eb;
--app-primary-foreground: #ffffff;
--app-ring: #60a5fa;
}
:root[data-theme="dark"] {
--app-text: #d7d7db;
--app-text-regular: #c0c0c7;
--app-text-muted: #97989d;
--app-bg: #131416;
--app-surface: #1b1b1e;
--app-overlay: #1e1e20;
--app-subtle: #2a2a2d;
--app-hover: #2e2f33;
--app-border: rgb(110 110 114 / 28%);
--app-input-border: rgb(110 110 114 / 34%);
--app-sidebar: #19191c;
--app-chrome: #1b1b1e;
--app-primary: #d0d0d6;
--app-primary-foreground: #131416;
--app-ring: #85868b;
--app-success: #4ade80;
--app-warning: #fbbf24;
--app-danger: #f3625f;
--app-shadow: 0 8px 30px #00000040, 0 2px 8px #00000026;
}
:root,
:root.dark {
font-family: var(--app-font-sans);
font-size: 13px;
color: var(--app-text);
background: var(--app-bg);
font-synthesis: none;
-webkit-font-smoothing: antialiased;
--el-font-family: var(--app-font-sans);
--el-font-size-base: 13px;
--el-font-size-small: 12px;
--el-border-radius-base: var(--app-radius);
--el-border-radius-small: var(--app-radius);
--el-color-primary: var(--app-primary);
--el-color-primary-light-3: color-mix(in srgb, var(--app-primary) 75%, var(--app-surface));
--el-color-primary-light-5: color-mix(in srgb, var(--app-primary) 50%, var(--app-surface));
--el-color-primary-light-7: color-mix(in srgb, var(--app-primary) 25%, var(--app-surface));
--el-color-primary-light-8: color-mix(in srgb, var(--app-primary) 16%, var(--app-surface));
--el-color-primary-light-9: color-mix(in srgb, var(--app-primary) 8%, var(--app-surface));
--el-color-primary-dark-2: color-mix(in srgb, var(--app-primary) 85%, var(--app-text));
--el-color-success: var(--app-success);
--el-color-success-light-3: color-mix(in srgb, var(--app-success) 75%, var(--app-surface));
--el-color-success-light-5: color-mix(in srgb, var(--app-success) 50%, var(--app-surface));
--el-color-success-light-7: color-mix(in srgb, var(--app-success) 25%, var(--app-surface));
--el-color-success-light-8: color-mix(in srgb, var(--app-success) 16%, var(--app-surface));
--el-color-success-light-9: color-mix(in srgb, var(--app-success) 8%, var(--app-surface));
--el-color-success-dark-2: color-mix(in srgb, var(--app-success) 85%, var(--app-text));
--el-color-warning: var(--app-warning);
--el-color-warning-light-3: color-mix(in srgb, var(--app-warning) 75%, var(--app-surface));
--el-color-warning-light-5: color-mix(in srgb, var(--app-warning) 50%, var(--app-surface));
--el-color-warning-light-7: color-mix(in srgb, var(--app-warning) 25%, var(--app-surface));
--el-color-warning-light-8: color-mix(in srgb, var(--app-warning) 16%, var(--app-surface));
--el-color-warning-light-9: color-mix(in srgb, var(--app-warning) 8%, var(--app-surface));
--el-color-warning-dark-2: color-mix(in srgb, var(--app-warning) 85%, var(--app-text));
--el-color-danger: var(--app-danger);
--el-color-danger-light-3: color-mix(in srgb, var(--app-danger) 75%, var(--app-surface));
--el-color-danger-light-5: color-mix(in srgb, var(--app-danger) 50%, var(--app-surface));
--el-color-danger-light-7: color-mix(in srgb, var(--app-danger) 25%, var(--app-surface));
--el-color-danger-light-8: color-mix(in srgb, var(--app-danger) 16%, var(--app-surface));
--el-color-danger-light-9: color-mix(in srgb, var(--app-danger) 8%, var(--app-surface));
--el-color-danger-dark-2: color-mix(in srgb, var(--app-danger) 85%, var(--app-text));
--el-color-info: var(--app-text-muted);
--el-color-info-light-3: color-mix(in srgb, var(--app-text-muted) 75%, var(--app-surface));
--el-color-info-light-5: color-mix(in srgb, var(--app-text-muted) 50%, var(--app-surface));
--el-color-info-light-7: color-mix(in srgb, var(--app-text-muted) 25%, var(--app-surface));
--el-color-info-light-8: color-mix(in srgb, var(--app-text-muted) 16%, var(--app-surface));
--el-color-info-light-9: var(--app-subtle);
--el-color-info-dark-2: var(--app-text-regular);
--el-bg-color: var(--app-surface);
--el-bg-color-overlay: var(--app-overlay);
--el-bg-color-page: var(--app-bg);
--el-fill-color: var(--app-subtle);
--el-fill-color-light: var(--app-subtle);
--el-fill-color-lighter: var(--app-sidebar);
--el-fill-color-extra-light: var(--app-sidebar);
--el-fill-color-dark: var(--app-hover);
--el-fill-color-darker: var(--app-hover);
--el-fill-color-blank: var(--app-surface);
--el-text-color-primary: var(--app-text);
--el-text-color-regular: var(--app-text-regular);
--el-text-color-secondary: var(--app-text-muted);
--el-text-color-placeholder: var(--app-text-muted);
--el-text-color-disabled: color-mix(in srgb, var(--app-text-muted) 65%, var(--app-surface));
--el-border-color: var(--app-input-border);
--el-border-color-light: var(--app-border);
--el-border-color-lighter: var(--app-border);
--el-border-color-extra-light: var(--app-border);
--el-border-color-dark: var(--app-ring);
--el-border-color-darker: var(--app-ring);
--el-box-shadow: var(--app-shadow);
--el-box-shadow-light: var(--app-shadow);
--el-box-shadow-lighter: 0 2px 8px #0000000d;
--el-disabled-bg-color: var(--app-subtle);
--el-disabled-text-color: var(--el-text-color-disabled);
--el-disabled-border-color: var(--app-border);
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { ref, watch } from 'vue';
export const themes = [
{ value: 'light', label: '浅色 · 森绿' },
{ value: 'dark', label: '深色 · 夜间' },
{ value: 'light', label: '浅色 · 经典' },
{ value: 'dark', label: '深色 · 石墨' },
{ value: 'ocean', label: '海蓝' },
{ value: 'system', label: '跟随系统' },
];
+109
View File
@@ -0,0 +1,109 @@
import { test, expect, type Page } from '@playwright/test';
const pages = ['代理', '资产管理', '目标服务', '运行概览', '网关服务', '连接与审计', '命令审计', '拖拽连线', '监控运维', '配置管理', '多端同步', '密码解密', 'SQLite 文件'];
const themes = [
{ name: 'light', background: 'rgb(255, 255, 255)', sidebar: 'rgb(250, 250, 250)', primary: 'rgb(23, 23, 23)', foreground: 'rgb(250, 250, 250)' },
{ name: 'dark', background: 'rgb(19, 20, 22)', sidebar: 'rgb(25, 25, 28)', primary: 'rgb(208, 208, 214)', foreground: 'rgb(19, 20, 22)' },
{ name: 'ocean', background: 'rgb(255, 255, 255)', sidebar: 'rgb(250, 250, 250)', primary: 'rgb(37, 99, 235)', foreground: 'rgb(255, 255, 255)' },
];
async function fitsViewport(page: Page) {
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
}
test('DBX 视觉体系覆盖全部菜单、主题和手机导航', async ({ page }, testInfo) => {
test.setTimeout(180000);
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto('/');
const navigation = page.getByRole('navigation', { name: '工作空间菜单' });
await expect(navigation.getByRole('button')).toHaveCount(pages.length);
for (const viewport of [{ width: 1440, height: 1000 }, { width: 390, height: 844 }]) {
await page.setViewportSize(viewport);
for (const theme of themes) {
await page.getByLabel('主题', { exact: true }).selectOption(theme.name);
await expect(page.locator('html')).toHaveCSS('background-color', theme.background);
await expect(page.locator('.sidebar')).toHaveCSS('background-color', theme.sidebar);
await expect(page.locator('.sidebar')).toHaveCSS('width', viewport.width === 390 ? '52px' : '224px');
await expect(page.locator('.workspace-tools').getByLabel('主题', { exact: true })).toBeInViewport();
for (const name of pages) {
const button = navigation.getByRole('button', { name, exact: true });
await button.click();
await expect(page.getByRole('heading', { name, exact: true }).first()).toBeVisible();
await expect(button).toHaveAttribute('aria-current', 'page');
await expect(button.locator('.app-icon')).toBeVisible();
await fitsViewport(page);
for (const panel of await page.locator('.table-card:visible').all()) {
await expect(panel).toHaveCSS('border-radius', '6px');
}
}
await navigation.getByRole('button', { name: '网关服务', exact: true }).click();
const create = page.getByRole('button', { name: /新建网关/ }).first();
await expect(create).toHaveCSS('border-radius', '4px');
await expect(create).toHaveCSS('background-color', theme.primary);
await expect(create).toHaveCSS('color', theme.foreground);
const toolbar = (await page.locator('.topbar').boundingBox())!;
expect(toolbar.height).toBeLessThanOrEqual(48);
await page.screenshot({ path: testInfo.outputPath(`workspace-${theme.name}-${viewport.width}.png`), fullPage: true });
}
}
expect(errors).toEqual([]);
});
test('DBX 编辑器与通用弹窗统一样式且保留键盘操作', async ({ page }, testInfo) => {
test.setTimeout(120000);
await page.goto('/');
const navigation = page.getByRole('navigation', { name: '工作空间菜单' });
for (const theme of ['light', 'dark']) {
await page.getByLabel('主题', { exact: true }).selectOption(theme);
for (const editor of [
{ menu: '目标服务', create: /新建目标/, title: '新建目标服务' },
{ menu: '网关服务', create: /新建网关/, title: '新建网关服务' },
{ menu: '代理', create: /新建代理/, title: '新建代理' },
{ menu: '资产管理', create: /新建资产/, title: '新建资产' },
]) {
await navigation.getByRole('button', { name: editor.menu, exact: true }).click();
await page.getByRole('button', { name: editor.create }).first().click();
const dialog = page.getByRole('dialog', { name: editor.title, exact: true });
await expect(dialog).toBeVisible();
const panel = dialog.locator('.el-dialog');
const surface = await panel.count() ? panel : dialog;
if (await surface.evaluate(element => element.classList.contains('el-dialog'))) {
await expect(surface).toHaveCSS('border-radius', '6px');
}
await expect(dialog.locator('.el-dialog__footer, .el-drawer__footer')).toBeInViewport();
const tablist = dialog.getByRole('tablist', { name: '连接配置分组', exact: true });
if (await tablist.count()) {
const tabs = tablist.getByRole('tab');
await expect(tabs.first()).toHaveCSS('border-radius', '4px');
await tabs.first().press('End');
await expect(tabs.last()).toHaveAttribute('aria-selected', 'true');
await tabs.last().press('Home');
await expect(tabs.first()).toHaveAttribute('aria-selected', 'true');
}
await fitsViewport(page);
await page.screenshot({ path: testInfo.outputPath(`editor-${theme}-${editor.menu}.png`) });
await dialog.getByRole('button', { name: '取消', exact: true }).click();
await expect(dialog).not.toBeVisible();
}
}
});
test('登录和工作区沿用同一主题且保留原有偏好值', async ({ page }, testInfo) => {
await page.goto(`http://127.0.0.1:${process.env.SERVICEPORT_E2E_AUTH_PORT || '18082'}`);
await page.getByLabel('主题', { exact: true }).selectOption('dark');
await expect(page.locator('.login-panel')).toHaveCSS('background-color', 'rgb(19, 20, 22)');
await page.reload();
await expect(page.getByLabel('主题', { exact: true })).toHaveValue('dark');
await page.screenshot({ path: testInfo.outputPath('login-dark.png'), fullPage: true });
await page.getByPlaceholder('输入账号', { exact: true }).fill('e2e-admin');
await page.getByPlaceholder('输入密码', { exact: true }).fill('e2e-isolated-admin-password');
await page.getByRole('button', { name: '登录控制台 →', exact: true }).click();
await expect(page.locator('.workspace-tools').getByLabel('主题', { exact: true })).toHaveValue('dark');
await expect(page.locator('.login-theme-picker')).toHaveCount(0);
await page.getByLabel('主题', { exact: true }).selectOption('system');
await page.emulateMedia({ colorScheme: 'light' });
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await page.emulateMedia({ colorScheme: 'dark' });
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
});
+2 -1
View File
@@ -71,7 +71,8 @@ test('全部工作区页面与编辑器适配手机、平板、低高度和宽
await fitsViewport(page, `${name} at ${viewport.width}px`);
await expect(nav.getByRole('button', { name })).toBeInViewport();
if (viewport.width <= 390) {
expect(await nav.getByRole('button', { name }).locator('span').evaluate(el => parseFloat(getComputedStyle(el).fontSize))).toBeGreaterThan(0);
await expect(nav.getByRole('button', { name }).locator('.app-icon')).toBeVisible();
expect((await nav.getByRole('button', { name }).locator('.app-icon').boundingBox())!.width).toBeGreaterThan(0);
}
}
for (const editor of [
+77
View File
@@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test';
import { connectionParameters, parseConnectionParameters, parseConnectionURL } from '../src/domain/connectionURL';
import { targetDraftPayload } from '../src/domain/targetDraft';
import { mergeColumns } from '../src/composables/useTableLayout';
import { resourceItems } from '../src/composables/useResources';
import { useWorkspaceTabs } from '../src/composables/useWorkspaceTabs';
import { matchesScope } from '../src/composables/useWorkspaceScope';
test('独立参数与完整 URL 共用字符集、应用名称和严格校验', () => {
const form = {type:'mysql',targetTLS:true,database:'app',connectionOptions:{connectTimeoutSeconds:5}};
expect(parseConnectionURL('mysql://db/app?charset=latin1','mysql').connectionOptions).toEqual({charset:'latin1'});
expect(parseConnectionParameters('charset=utf8mb4&database=app%20one&ssl=false',form)).toMatchObject({database:'app one',targetTLS:false,connectionOptions:{charset:'utf8mb4',connectTimeoutSeconds:5}});
expect(parseConnectionURL('postgres://db?application_name=service%20port','pgsql').connectionOptions.applicationName).toBe('service port');
expect(parseConnectionParameters('application_name=%E5%B7%A5%E4%BD%9C%E5%8C%BA&sslmode=verify-full',{type:'pgsql'})).toMatchObject({targetTLS:true,connectionOptions:{applicationName:'工作区'}});
});
test('重复、空值、未知及敏感参数失败时不部分修改配置', () => {
const form = {type:'mysql',targetTLS:true,database:'original',connectionOptions:{charset:'latin1'}};
const before = JSON.stringify(form);
for (const input of ['charset=', 'charset=utf8&CHARSET=utf8', 'tls=true&ssl=false', 'database=one&database=two', 'unknown=secret-value', 'password=secret-value', 'user=reader', 'charset=%FF', 'charset=%ZZ', 'charset', '=value']) {
expect(() => parseConnectionParameters(input,form)).toThrow();
try { parseConnectionParameters(input,form); } catch (failure) { expect(String(failure)).not.toContain('secret-value'); }
expect(JSON.stringify(form)).toBe(before);
}
expect(() => parseConnectionParameters('charset=utf8',{type:'pgsql'})).toThrow();
expect(() => parseConnectionParameters('sslmode=require',{type:'pgsql'})).toThrow();
expect(parseConnectionParameters('',form)).toMatchObject({connectionOptions:{charset:''}});
expect(parseConnectionParameters('',form)).not.toHaveProperty('database');
});
test('URL 参数回读保留 Mongo 节点、压缩、认证来源与账号边界', () => {
const form = {type:'mongodb',targetHost:'seed',targetPort:27017,targetTLS:true,authSource:'admin',targetCompression:'zlib',cluster:{mode:'mongodb-replica-set',name:'rs test',nodes:[{host:'::1',port:27018}],readPreference:'primary'},accounts:[{id:'secret',password:'never-serialize'}]};
const serialized = connectionParameters(form);
expect(serialized).not.toContain('never-serialize');
const patch = parseConnectionParameters(serialized,form);
expect(patch).toMatchObject({targetTLS:true,authSource:'admin',targetCompression:'zlib',cluster:{mode:'mongodb-replica-set',name:'rs test',nodes:[{host:'::1',port:27018}],readPreference:'primary'}});
expect(patch).not.toHaveProperty('accounts');
expect(() => parseConnectionParameters('directConnection=true',form)).toThrow();
});
test('测试和保存的连接选项一致,恢复默认会明确清空保活覆盖', () => {
const draft = {name:'draft',type:'mysql',connectionOptions:{charset:'utf8',keepAliveSeconds:0},accounts:[{id:'saved',username:'user',password:''}]};
expect(targetDraftPayload(draft).connectionOptions.keepAliveSeconds).toBe(0);
delete (draft.connectionOptions as any).keepAliveSeconds;
expect(targetDraftPayload(draft).connectionOptions.keepAliveSeconds).toBeNull();
expect(targetDraftPayload(draft).connectionOptions.connectTimeoutSeconds).toBe(0);
expect(targetDraftPayload(draft).accounts[0]).not.toHaveProperty('password');
});
test('列布局合并旧版本、限制宽度、保留关键列且去重', () => {
const definitions = [{id:'name',label:'名称',width:180,required:true},{id:'new',label:'新增',width:100},{id:'actions',label:'操作',width:200,required:true,fixed:true}];
const columns = mergeColumns(definitions,[{id:'actions',width:-8,visible:false},{id:'removed',width:5},{id:'name',width:9000,visible:false},{id:'name',width:1}]);
expect(columns).toEqual([{id:'name',width:800,visible:true},{id:'new',width:100,visible:true},{id:'actions',width:80,visible:true}]);
expect(mergeColumns(definitions,{invalid:true})).toHaveLength(3);
});
test('搜索资源索引不包含凭据、隐藏备注或资产内容,范围严格匹配', () => {
const items = resourceItems({targets:[{config:{id:'target',name:'database',type:'mysql',project:'one',environment:'test',targetHost:'db',targetPort:3306,remark:'private-note',showRemark:false,accounts:[{password:'private-password'}]}}],proxies:[{config:{id:'gateway',name:'gateway'},runtime:{error:'private-error',running:false}}],outbounds:[],assets:[{config:{id:'key',name:'key',content:'private-key'}}]});
const serialized = JSON.stringify(items);
for (const secret of ['private-note','private-password','private-error','private-key']) expect(serialized).not.toContain(secret);
expect(items.find(item => item.id === 'gateway')?.status).toBe('异常');
expect(items.filter(item => matchesScope(item,{project:'one',environment:'production'}))).toHaveLength(0);
expect(items.filter(item => matchesScope(item,{project:'one',environment:'test'}))).toHaveLength(1);
});
test('工作区标签按页面和资源唯一,范围重置后释放旧标签', () => {
const state = useWorkspaceTabs();
state.open('targets','first','one'); state.open('targets','renamed','one');
state.open('targets','second','two'); state.open('commands','命令审计');
expect(state.tabs.value).toHaveLength(4);
expect(state.tabs.value.find(item => item.resourceId === 'one')?.label).toBe('renamed');
state.remove('targets:one');
expect(state.tabs.value).toHaveLength(3);
state.reset('targets','目标服务');
expect(state.tabs.value.map(item => item.key)).toEqual(['targets']);
expect(JSON.stringify(state.tabs.value)).not.toMatch(/password|privateKey|draft/);
});
+110
View File
@@ -0,0 +1,110 @@
import { test, expect } from '@playwright/test';
test('未应用 URL 自动参与测试和保存,内联结果过期且参数可回读', async ({page}) => {
await page.goto('/');
const session = await (await page.request.get('/api/v1/session')).json();
const headers = {'X-CSRF-Token':session.csrfToken};
let id = '';
try {
await page.getByRole('navigation',{name:'工作空间菜单'}).getByRole('button',{name:'目标服务',exact:true}).click();
await page.getByRole('button',{name:'+ 新建目标',exact:true}).click();
const editor = page.getByRole('dialog',{name:'新建目标服务',exact:true});
await editor.locator('[data-field="name"] input').fill('参数与标识回归');
await editor.getByLabel('连接 URL',{exact:true}).fill('mysql://reader:isolated-secret@fixture.invalid/app?charset=latin1&tls=false');
await page.route('**/api/v1/targets/test',async route => {
const body = route.request().postDataJSON();
expect(body.config).toMatchObject({targetHost:'fixture.invalid',database:'app',connectionOptions:{charset:'latin1'}});
expect(body.config.accounts[0].password).toBe('isolated-secret');
await route.fulfill({json:{version:'fixture',tls:false}});
});
await editor.getByRole('button',{name:'测试连接',exact:true}).click();
const details = page.getByRole('dialog',{name:'连接测试',exact:true});
await expect(details).toContainText('fixture');
await details.getByRole('button',{name:'关闭',exact:true}).click();
await expect(editor.locator('.test-summary')).toContainText('连接成功');
await editor.getByLabel('URL 参数',{exact:true}).fill('charset=utf8mb4&tls=false&database=next');
await expect(editor.locator('.test-summary')).toContainText('配置已变化');
await editor.getByLabel('连接备注',{exact:true}).fill('回归测试用途');
await editor.getByRole('checkbox',{name:'在资源列表和侧栏显示备注'}).check();
await editor.getByRole('tab',{name:'高级',exact:true}).click();
await editor.getByLabel('网络连接超时',{exact:true}).fill('8');
await expect(editor.getByLabel('查询超时',{exact:true})).toBeDisabled();
const saving = page.waitForResponse(response => response.url().endsWith('/api/v1/targets') && response.request().method() === 'POST');
await editor.getByRole('button',{name:'保存配置',exact:true}).click();
const response = await saving;
expect(response.ok()).toBeTruthy(); id = (await response.json()).config.id;
const row = page.locator('.target-card').filter({hasText:'参数与标识回归'});
await expect(row).toContainText('回归测试用途');
await row.getByRole('button',{name:'配置目标',exact:true}).click();
const edit = page.getByRole('dialog',{name:'编辑目标服务',exact:true});
await expect(edit.getByLabel('URL 参数',{exact:true})).toHaveValue(/charset=utf8mb4/);
const saved = await (await page.request.get(`/api/v1/targets/${id}`)).json();
expect(saved.config).toMatchObject({database:'next',remark:'回归测试用途',showRemark:true,connectionOptions:{charset:'utf8mb4',connectTimeoutSeconds:8}});
expect(JSON.stringify(await page.evaluate(() => ({...localStorage})))).not.toContain('isolated-secret');
} finally { await page.unrouteAll({behavior:'wait'}); if (id) await page.request.delete(`/api/v1/targets/${id}`,{headers}); }
});
test('搜索和树导航复用资源标签,切换保护草稿且最近记录仅存标识', async ({page}) => {
await page.goto('/');
const session = await (await page.request.get('/api/v1/session')).json();
const headers = {'X-CSRF-Token':session.csrfToken};
const response = await page.request.post('/api/v1/targets',{headers,data:{name:'工作区导航回归',project:'导航回归项目',environment:'test',type:'mysql',targetHost:'fixture.invalid',targetPort:3306,color:'#22c55e',accounts:[{id:'account',name:'account',username:'reader',password:'navigation-fixture-secret'}]}});
expect(response.ok()).toBeTruthy(); const id = (await response.json()).config.id;
try {
await page.reload();
await page.keyboard.press('Control+k');
const search = page.getByRole('dialog',{name:'搜索资源 · 当前项目与环境'});
await search.getByRole('combobox',{name:'搜索资源关键词'}).fill('工作区导航回归');
await search.getByRole('combobox',{name:'搜索资源关键词'}).press('Enter');
const editor = page.getByRole('dialog',{name:'编辑目标服务',exact:true});
await expect(editor).toBeVisible();
await editor.getByLabel('连接备注',{exact:true}).fill('unsaved');
await editor.getByRole('button',{name:'关闭当前标签',exact:true}).click();
await expect(page.getByRole('dialog',{name:'未保存的更改',exact:true})).toBeVisible();
await page.getByRole('button',{name:'继续编辑',exact:true}).click();
await expect(editor.getByLabel('连接备注',{exact:true})).toHaveValue('unsaved');
await editor.getByRole('button',{name:'关闭当前标签',exact:true}).click();
await page.getByRole('button',{name:'放弃更改',exact:true}).click();
await expect(editor).not.toBeVisible();
await page.getByRole('navigation',{name:'工作空间菜单'}).getByRole('button',{name:'运行概览',exact:true}).click();
await expect(page.locator('.workspace-home')).toContainText('工作区导航回归');
const history = JSON.parse(await page.evaluate(() => localStorage.getItem('serviceport.recent.v1')) || '[]');
expect(history.find((item:any) => item.id === id)).toMatchObject({kind:'targets',id});
expect(Object.keys(history.find((item:any) => item.id === id)).sort()).toEqual(['at','id','kind']);
await page.setViewportSize({width:390,height:844});
await page.getByRole('button',{name:'展开资源树',exact:true}).click();
const tree = page.getByRole('dialog',{name:'资源树',exact:true});
await expect(tree).toContainText('导航回归项目');
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth-innerWidth)).toBeLessThanOrEqual(1);
} finally { await page.request.delete(`/api/v1/targets/${id}`,{headers}); }
});
test('表格列显隐、排序和宽度偏好可恢复,右键菜单有键盘入口', async ({page}) => {
await page.goto('/');
await page.getByRole('button',{name:'表格列设置',exact:true}).click();
const picker = page.getByLabel('表格列设置面板',{exact:true});
await picker.getByRole('checkbox',{name:'目标服务',exact:true}).uncheck();
await picker.getByRole('button',{name:'上移状态列',exact:true}).click();
await picker.getByRole('spinbutton',{name:'状态列宽',exact:true}).fill('160');
await page.getByRole('heading',{name:'网关服务',exact:true}).click();
await page.reload();
await expect(page.locator('.proxy-table').getByRole('columnheader',{name:'目标服务',exact:true})).toHaveCount(0);
await page.getByRole('button',{name:'表格列设置',exact:true}).click();
await picker.getByRole('button',{name:'恢复默认列',exact:true}).click();
await page.getByRole('heading',{name:'网关服务',exact:true}).click();
await expect(page.locator('.proxy-table').getByRole('columnheader',{name:'目标服务',exact:true})).toBeVisible();
const session = await (await page.request.get('/api/v1/session')).json();
const headers = {'X-CSRF-Token':session.csrfToken};
const response = await page.request.post('/api/v1/targets',{headers,data:{name:'菜单回归目标',type:'mysql',targetHost:'fixture.invalid',targetPort:3306,accounts:[{id:'account',name:'account',username:'reader',password:'fixture-secret'}]}});
expect(response.ok()).toBeTruthy(); const id = (await response.json()).config.id;
try {
await page.reload();
await page.getByRole('navigation',{name:'工作空间菜单'}).getByRole('button',{name:'目标服务',exact:true}).click();
await page.locator('.target-card').filter({hasText:'菜单回归目标'}).click({button:'right'});
const menu = page.getByRole('menu',{name:'资源操作',exact:true});
await expect(menu.getByRole('menuitem',{name:'编辑配置',exact:true})).toBeFocused();
await page.keyboard.press('End');
await expect(menu.getByRole('menuitem',{name:'删除目标',exact:true})).toBeFocused();
await page.keyboard.press('Escape'); await expect(menu).not.toBeVisible();
} finally { await page.request.delete(`/api/v1/targets/${id}`,{headers}); }
});