桌面 GUI 外链修复 + 主题顺序调整(含结构扁平化进行中的工作区基线)
- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化) - 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转 - 设置→外观 主题顺序调整为 深色/浅色/跟随系统 - release 二进制重编并验证(浏览器实测关于页链接可开文档页) - 附带工作区中未提交的扁平化重构现状(另一会话进行中) - homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
No files matched your search
@@ -14,18 +14,19 @@ coverage/
|
||||
.turbo/
|
||||
.idea/
|
||||
.vscode/
|
||||
apps/server/opsxapi
|
||||
apps/server/server
|
||||
apps/desktop/src-tauri/gen/
|
||||
*.exe
|
||||
*.dylib
|
||||
*.so
|
||||
Cargo.lock
|
||||
!apps/desktop/src-tauri/Cargo.lock
|
||||
!src-tauri/Cargo.lock
|
||||
|
||||
logs/
|
||||
.*.pid
|
||||
apps/server/bin/
|
||||
server/bin/
|
||||
server/data/*.db
|
||||
server/data/*.db-shm
|
||||
server/data/*.db-wal
|
||||
src-tauri/gen/
|
||||
*.tsbuildinfo
|
||||
|
||||
# UI review / acceptance screenshots (local only)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
install:
|
||||
npm install
|
||||
npm --prefix apps/desktop install
|
||||
|
||||
web:
|
||||
./run.sh web
|
||||
@@ -26,7 +25,7 @@ desktop:
|
||||
./run.sh desktop
|
||||
|
||||
build-web:
|
||||
npm run build:web
|
||||
npm run build
|
||||
|
||||
test:
|
||||
npm run test:core
|
||||
npm test
|
||||
@@ -5,15 +5,16 @@
|
||||
**HTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT,一个工作台全搞定;数据全在本地。**
|
||||
|
||||
```
|
||||
packages/core 数据模型 / 树操作 / 导入导出器(纯 TS,21 个单测)
|
||||
packages/transport browser | gin-proxy | tauri-native + 流式/SSE/WS隧道/Socket.IO/MQTT 客户端
|
||||
packages/ui 共享工作台 UI(集合树 / 多标签 / 编辑器 / 对话框)
|
||||
apps/web Vite + React 入口(:5173)
|
||||
apps/desktop Tauri 2 壳(frontendDist → web dist)
|
||||
apps/server Gin:HTTP 代理 + 流式代理 + WebSocket 隧道(:8787)
|
||||
src/ 前端入口(React;进 Tauri WebView,勿当浏览器产品)
|
||||
src-tauri/ Tauri 2 壳(唯一操作入口)
|
||||
server/ Gin:代理 API + health(:8787;本地托管 homepage/dist)
|
||||
homepage/ 官网落地页 + 文档静态站(不变)
|
||||
src/core 数据模型 / 树操作 / 导入导出器
|
||||
src/transport browser | gin-proxy | tauri-native
|
||||
src 工作台 UI
|
||||
```
|
||||
|
||||
核心原则:**一套 UI、一份请求契约,Web / Desktop 只换 Transport。**
|
||||
核心原则:**一套 UI、一份请求契约;浏览器只看官网/docs,操作只走桌面。**
|
||||
|
||||
## 功能总览
|
||||
|
||||
@@ -53,14 +54,13 @@ apps/server Gin:HTTP 代理 + 流式代理 + WebSocket 隧道(:8787
|
||||
## 怎么跑
|
||||
|
||||
```bash
|
||||
make install # npm install(含 desktop 依赖)
|
||||
./run.sh web # 浏览器:Gin(:8787) + Vite(:5173) ← 推荐
|
||||
./run.sh gui # 桌面 Tauri 窗口(共用 Vite)
|
||||
./run.sh all # 全开
|
||||
make install
|
||||
./run.sh web # 官网 + docs → http://127.0.0.1:8787/
|
||||
./run.sh gui # 桌面操作入口(推荐)
|
||||
./run.sh stop | status
|
||||
```
|
||||
|
||||
打开 http://127.0.0.1:5173 —— 内置「示例集合」覆盖全部协议,开箱即用。
|
||||
打开 http://127.0.0.1:8787/ 看落地页;操作请用 `./run.sh gui`。`:5173` 只是桌面 WebView 源。
|
||||
|
||||
## 用户文档站(/docs)
|
||||
|
||||
@@ -69,7 +69,7 @@ Gin 内置产品文档,Markdown 嵌入二进制、SSR 渲染,随服务一键
|
||||
- 地址:`http://127.0.0.1:8787/docs`(开发态 `http://127.0.0.1:5173/docs`,Vite 已反代;工作台顶栏「📖 文档」直达)
|
||||
- 布局:顶栏搜索 + 左侧分组导航(16 篇:开始使用 / 请求调试 / 数据管理 / 参考)+ 右侧本页目录
|
||||
- 能力:全文搜索、代码块复制 + 高亮、提示块、上一页/下一页、反馈组件、打印导出 PDF、移动端响应式、404 页
|
||||
- 内容在 `apps/server/internal/docs/content/*.md`,改完重新 `go build` 即生效
|
||||
- 内容在 `server/internal/docs/content/*.md`(本地预览优先托管 `homepage/dist`)
|
||||
|
||||
## 产品官网(/home)
|
||||
|
||||
@@ -81,7 +81,7 @@ Gin 内置营销单页,与 /docs 同为 SSR 嵌入交付(`internal/site`)
|
||||
- 让利机制四项:早鸟创始价(前 20 席 5–7 折永久)、教育/开源 6–8 折、多年预付 8–9 折、案例换授权;销售原则:不直接降价,用「永久授权 + 1 年升级 + 5 人团队 1–2 年回本」回应预算顾虑
|
||||
- 交付前替换:联系邮箱 `hello@opsxapi.dev`(template.html 内两处)与对比表脚注中的价格核对
|
||||
|
||||
## 服务端端点(apps/server)
|
||||
## 服务端端点(server/)
|
||||
|
||||
| 端点 | 用途 |
|
||||
| --- | --- |
|
||||
@@ -96,7 +96,7 @@ Gin 内置营销单页,与 /docs 同为 SSR 嵌入交付(`internal/site`)
|
||||
|
||||
## 架构决策
|
||||
|
||||
- **一套 UI**:集合 / 编辑器 / 对话框全部在 `packages/ui`,Web 与桌面零分叉
|
||||
- **一套 UI**:集合 / 编辑器 / 对话框全部在 `src`,Web 与桌面零分叉
|
||||
- **判别联合数据模型**:`node.kind === "http"` 自动收窄 `node.data` 类型
|
||||
- **实时会话不落盘**:WS/SSE/Socket.IO/MQTT 会话存模块级注册表,消息(500 条上限)与状态进 store
|
||||
- **zustand 选择器必须返回稳定引用**:`?? []` 会触发 useSyncExternalStore 无限重渲染(踩过的坑)
|
||||
@@ -104,13 +104,56 @@ Gin 内置营销单页,与 /docs 同为 SSR 嵌入交付(`internal/site`)
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
make test # core 21 个单测:导入导出全格式往返、树操作、cURL 解析
|
||||
cd apps/server && go test ./...
|
||||
npm test # core / transport / features 全量测试
|
||||
cd server && go test ./...
|
||||
```
|
||||
|
||||
## P0 自动化演示(本地)
|
||||
|
||||
1. `./run.sh web` → http://127.0.0.1:5173
|
||||
1. `./run.sh gui` → 桌面工作台(Gin `:8787` + Vite 源)
|
||||
2. 进入自动化测试 → **装载回归示例** → **运行**(依赖本机 Gin `:8787/v1/echo`)
|
||||
3. AI:**保存并运行** 与手动/定时共用同一执行器与报告
|
||||
4. 定时:支持 5 段 Cron 或间隔;应用保持打开;见 `docs/HANDTEST-P0.md`
|
||||
|
||||
## 根目录 Tauri 开发布局
|
||||
|
||||
前端全部位于 `src/`(`core/`、`transport/`、`features/`、`components/`),Rust 位于 `src-tauri/`。不再使用 `apps/` 或 npm workspace;`homepage/` 落地页保持独立,`server/` 保留本地辅助服务。
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # 先类型检查,再构建前端
|
||||
npm test # 全量前端单测
|
||||
cargo test --manifest-path src-tauri/Cargo.toml --lib
|
||||
./run.sh gui # 编译当前 Rust 后启动桌面,默认附带本地 Gin
|
||||
```
|
||||
|
||||
`./run.sh gui` 每次通过 Cargo 增量编译当前源码,不再回退到旧 release。只运行原生桌面可用 `OPSXAPI_GUI_WITH_SERVER=0 ./run.sh gui`。标准 Tauri 开发热重载入口为 `npm run dev:desktop`。
|
||||
|
||||
## 请求代理与内网 Mock
|
||||
|
||||
### API 文档与 OpenAPI 同步
|
||||
|
||||
侧栏「文档」以现有集合为项目,按 OpenAPI 标签分组浏览 HTTP 接口,显示参数、请求体、响应示例及说明。导入入口支持 OpenAPI 3.0/3.1、Swagger 2.0 的 JSON/YAML 文件、粘贴内容与 URL;导入时选择新建、按同名项目合并、或覆盖。合并按 HTTP 方法和相对路径更新同一接口,保留没有出现在本次导入里的接口;手动编辑并保存的说明与文档字段会在下次合并时保留。覆盖会移除该项目未导入的接口,使用前先检查选择。文档字段与请求一起保存在工作区备份;选择云端空间时也遵循现有工作区同步规则。
|
||||
|
||||
侧栏按「接口 → 文档 → Mock → 测试」排列。文档详情可直接打开请求页试用,或从 2xx 响应 example/schema 生成 Mock 草稿;已有相同方法和具体路径的 Mock 会显示关联状态并直接打开编辑,不覆盖手动修改的模拟响应。生成时把路径参数示例实例化为具体路径(例如 `/pets/{id}` 和 `id=42` 对应 `/pets/42`);发布后文档详情和内网只读文档快照均可展示对应 Mock 地址(快照需重新发布才会更新)。当前 Mock 服务只支持完整路径匹配,暂无动态路径匹配、请求体条件或多响应规则。
|
||||
|
||||
「发布当前项目」会将所选项目的名称、接口说明、参数和示例生成单独的本机 SQLite 只读快照;鉴权、环境变量及运行历史不会加入快照。分享地址是 `<IP或域名>:8788/docs/<随机令牌>`,和 Mock 响应共用独立监听与来源限制。修改文档后需点「更新分享快照」;「撤销分享」立即令旧链接失效。公开访问需明确配置 `OPSXAPI_MOCK_ALLOW_PUBLIC=1`,自行处理 DNS/端口映射/HTTPS。分享页面不提供请求执行或编辑入口。
|
||||
|
||||
- 顶栏仅保留环境管理和环境切换;请求代理在「设置 → 网络」配置。支持自动检测、自定义 HTTP/HTTPS 代理、不使用代理、绕过主机和代理认证。配置仅存本机,不进入工作区同步/导出;适用于桌面 HTTP、GraphQL 及自动化测试,实时协议不受此设置控制。
|
||||
- 侧栏「Mock」是独立接口管理页面:新建接口 → 选择方法与路径 → 编辑 JSON/文本响应、状态码、延迟 → 保存草稿 → 发布全部已保存接口。也可在 HTTP 编辑器通过「创建 Mock」或「用响应创建 Mock」复制为独立接口,不修改原请求。
|
||||
- 草稿与未保存编辑缓存在本机浏览器存储;发布结果保存在本机 SQLite,不因登录而切换到团队或公网存储。发布会替换此本机服务的全部接口,禁用/删除接口需发布后生效;未保存编辑不参与发布。接口页提供已发布地址、复制和试运行。
|
||||
- Mock 页面顶部可直接启用或关闭 HTTP 响应,并可一键创建 `GET /demo` 示例;保存草稿后点击「发布全部已保存接口」,在已发布版本中试运行。设置 → Mock 服务保留相同开关及分享地址、环境关联配置。关闭后返回 503,保留数据和监听端口;彻底停止进程使用 `./run.sh stop`。随机数据生成规则位于「设置 → 数据生成」。
|
||||
- 第一版使用单服务、方法 + 完整路径匹配(忽略尾斜杠),暂不支持路径参数、多响应条件及请求体匹配。JSON 可使用已有 `@integer` 等模板表达式;文本响应原样返回。
|
||||
- `./run.sh gui` 默认启动本机管理服务 `127.0.0.1:8787` 和独立 Mock 响应监听 `0.0.0.0:8788`。后者默认仅接受回环/私有网段来源,不注册账号、发布或代理管理路由;内网同事按实际 HTTP 方法访问 `http://本机内网IP:8788/mock/<token>/接口路径`。
|
||||
- Mock 页的路径输入框只填写 `/example` 等相对路径;保存并发布后,编辑器顶部会显示可复制的完整地址。`http://localhost:5173/mock/example` 指向桌面开发界面,不是 Mock 响应。默认无需额外 Header,完整地址包含随机地址令牌。若要进一步限制已获知地址的访问,可在「设置 → Mock 服务 → 访问验证」生成并保存 Bearer 密钥;访问者须发送 `Authorization: Bearer <密钥>`,服务只保存密钥摘要。浏览器地址栏无法设置此 Header,请使用接口请求页或 curl;试运行框可临时填写密钥。
|
||||
- 分享域名需由内网 DNS/hosts 解析到运行本机服务的机器;HTTPS 由内网反向代理提供,可将域名解析到内网 IP,也可自行端口映射或反向代理至公网 IP;数据仍保存在自有机器。输入分享地址不会自动修改 DNS、监听或部署。
|
||||
- 若只允许本机访问,启动前设置 `OPSXAPI_MOCK_ADDR=127.0.0.1:8788`;更改监听配置需停止后重新启动。设置页会显示配置的监听地址及本机地址候选;访问仍需端口可达。
|
||||
- 更新后运行 `./run.sh gui` 会检查 Mock 管理接口,并在本项目旧后端缺少该接口时重启服务;也可手动运行 `./run.sh stop && ./run.sh gui`。`http://localhost:5173/mock` 不是内网 Mock 服务入口,`:5173` 只是桌面界面的开发源。已发布示例的分享地址为 `http://本机内网IP:8788/mock/<随机令牌>/demo`;令牌可在 Mock 页面「已发布版本」中复制。`homepage/` 不参与 Mock 数据保存。
|
||||
|
||||
### 环境地址联动与自定义域名
|
||||
|
||||
请求建议保存 `/pet/1` 等相对路径,环境分别填写前置 URL(例如本地 `http://127.0.0.1:3000`、正式 `https://api.example.com`)。顶栏切换环境会同时改变地址栏前缀、实际请求和代码生成地址;方法及接口路径不变。完整 URL 保持固定,可通过「关联环境」把匹配已知环境前缀的 URL 转为相对路径。
|
||||
|
||||
Mock 设置支持「使用此 Mock 环境」,生成前置 URL `<分享地址>/mock/<token>`,同一接口路径即可在本地、正式、自托管 Mock 之间切换。下线后 token 失效,重新发布后需再次更新 Mock 环境。
|
||||
|
||||
分享地址支持自定义域名,用户自行配置 DNS、端口映射或反向代理及 HTTPS。若需要公网来源直接访问 Mock 响应端口,先停止服务,再运行 `OPSXAPI_MOCK_ALLOW_PUBLIC=1 ./run.sh gui`。此开关仅影响独立 Mock 响应端口,不开放发布、账号、工作区或代理管理接口,也不改变本地数据存储方式。
|
||||
@@ -1,220 +0,0 @@
|
||||
{
|
||||
"name": "@opsxapi/desktop",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@opsxapi/desktop",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "@opsxapi/desktop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.4.0"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 57 KiB |
@@ -1,293 +0,0 @@
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BodySpec {
|
||||
mode: String,
|
||||
raw: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HttpSpec {
|
||||
method: String,
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
query: Option<HashMap<String, String>>,
|
||||
body: Option<BodySpec>,
|
||||
timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HttpResult {
|
||||
status: u16,
|
||||
headers: HashMap<String, String>,
|
||||
body: String,
|
||||
time_ms: u128,
|
||||
size: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WsSpec {
|
||||
url: String,
|
||||
protocols: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WsEvent {
|
||||
session_id: String,
|
||||
kind: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
enum WsCmd {
|
||||
Send(String),
|
||||
Close,
|
||||
}
|
||||
|
||||
struct WsHandle {
|
||||
tx: mpsc::UnboundedSender<WsCmd>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct WsState {
|
||||
sessions: Mutex<HashMap<String, WsHandle>>,
|
||||
}
|
||||
|
||||
fn validate_http_url(raw: &str) -> Result<url::Url, String> {
|
||||
let parsed = url::Url::parse(raw).map_err(|e| format!("invalid URL: {e}"))?;
|
||||
match parsed.scheme() {
|
||||
"http" | "https" => {}
|
||||
_ => return Err("only http/https schemes are allowed".into()),
|
||||
}
|
||||
match parsed.host_str().unwrap_or("").to_ascii_lowercase().as_str() {
|
||||
"169.254.169.254" | "metadata.google.internal" => Err("blocked metadata host".into()),
|
||||
_ => Ok(parsed),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_ws_url(raw: &str) -> Result<String, String> {
|
||||
let mut parsed = url::Url::parse(raw).map_err(|e| format!("invalid URL: {e}"))?;
|
||||
match parsed.scheme() {
|
||||
"ws" | "wss" => {}
|
||||
"http" => {
|
||||
parsed
|
||||
.set_scheme("ws")
|
||||
.map_err(|_| "cannot set ws scheme".to_string())?;
|
||||
}
|
||||
"https" => {
|
||||
parsed
|
||||
.set_scheme("wss")
|
||||
.map_err(|_| "cannot set wss scheme".to_string())?;
|
||||
}
|
||||
_ => return Err("only ws/wss schemes are allowed".into()),
|
||||
}
|
||||
match parsed.host_str().unwrap_or("").to_ascii_lowercase().as_str() {
|
||||
"169.254.169.254" | "metadata.google.internal" => {
|
||||
return Err("blocked metadata host".into())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(parsed.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn http_send(spec: HttpSpec) -> Result<HttpResult, String> {
|
||||
let mut url = validate_http_url(&spec.url)?;
|
||||
if let Some(query) = &spec.query {
|
||||
let mut pairs = url.query_pairs_mut();
|
||||
for (key, value) in query {
|
||||
pairs.append_pair(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
let method = reqwest::Method::from_bytes(spec.method.as_bytes())
|
||||
.map_err(|e| format!("invalid method: {e}"))?;
|
||||
let timeout = Duration::from_millis(spec.timeout_ms.unwrap_or(30_000));
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut request = client.request(method, url);
|
||||
for (key, value) in &spec.headers {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
if let Some(body) = spec.body {
|
||||
if body.mode != "none" {
|
||||
request = request.body(body.raw.unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
let response = request.send().await.map_err(|e| e.to_string())?;
|
||||
let status = response.status().as_u16();
|
||||
let headers = response
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
|
||||
.collect();
|
||||
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
|
||||
let size = bytes.len();
|
||||
let body = String::from_utf8_lossy(&bytes).into_owned();
|
||||
Ok(HttpResult {
|
||||
status,
|
||||
headers,
|
||||
body,
|
||||
time_ms: started.elapsed().as_millis(),
|
||||
size,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn ws_connect(
|
||||
app: AppHandle,
|
||||
state: State<'_, WsState>,
|
||||
spec: WsSpec,
|
||||
) -> Result<String, String> {
|
||||
let url = validate_ws_url(&spec.url)?;
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
|
||||
let mut req = http::Request::builder().method("GET").uri(&url);
|
||||
for (k, v) in &spec.headers {
|
||||
req = req.header(k.as_str(), v.as_str());
|
||||
}
|
||||
if let Some(protocols) = &spec.protocols {
|
||||
let joined = protocols
|
||||
.iter()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if !joined.is_empty() {
|
||||
req = req.header("Sec-WebSocket-Protocol", joined);
|
||||
}
|
||||
}
|
||||
let request = req
|
||||
.body(())
|
||||
.map_err(|e| format!("build ws request: {e}"))?;
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<WsCmd>();
|
||||
{
|
||||
let mut map = state.sessions.lock().map_err(|e| e.to_string())?;
|
||||
map.insert(session_id.clone(), WsHandle { tx });
|
||||
}
|
||||
|
||||
let sid = session_id.clone();
|
||||
let app2 = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let emit = |kind: &str, data: String| {
|
||||
let _ = app2.emit(
|
||||
"ws-event",
|
||||
WsEvent {
|
||||
session_id: sid.clone(),
|
||||
kind: kind.to_string(),
|
||||
data,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
match tokio_tungstenite::connect_async(request).await {
|
||||
Ok((ws, _)) => {
|
||||
emit("status", "open".into());
|
||||
let (mut write, mut read) = ws.split();
|
||||
loop {
|
||||
tokio::select! {
|
||||
cmd = rx.recv() => {
|
||||
match cmd {
|
||||
Some(WsCmd::Send(data)) => {
|
||||
if write.send(Message::Text(data.into())).await.is_err() {
|
||||
emit("status", "error".into());
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(WsCmd::Close) | None => {
|
||||
let _ = write.close().await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
msg = read.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(t))) => emit("message", t.to_string()),
|
||||
Some(Ok(Message::Binary(b))) => {
|
||||
emit("message", format!("[binary {} bytes]", b.len()))
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(e)) => {
|
||||
emit("message", format!("[ws error] {e}"));
|
||||
emit("status", "error".into());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
emit("status", "closed".into());
|
||||
}
|
||||
Err(e) => {
|
||||
emit("message", format!("[ws_connect error] {e}"));
|
||||
emit("status", "error".into());
|
||||
emit("status", "closed".into());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(st) = app2.try_state::<WsState>() {
|
||||
if let Ok(mut map) = st.sessions.lock() {
|
||||
map.remove(&sid);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn ws_send(state: State<'_, WsState>, session_id: String, data: String) -> Result<(), String> {
|
||||
let map = state.sessions.lock().map_err(|e| e.to_string())?;
|
||||
let handle = map
|
||||
.get(&session_id)
|
||||
.ok_or_else(|| format!("unknown ws session {session_id}"))?;
|
||||
handle
|
||||
.tx
|
||||
.send(WsCmd::Send(data))
|
||||
.map_err(|_| "ws session closed".to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn ws_close(state: State<'_, WsState>, session_id: String) -> Result<(), String> {
|
||||
let mut map = state.sessions.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(handle) = map.remove(&session_id) {
|
||||
let _ = handle.tx.send(WsCmd::Close);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(WsState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
http_send,
|
||||
ws_connect,
|
||||
ws_send,
|
||||
ws_close
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running OpsxAPI desktop");
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"opsxapi/server/internal/api"
|
||||
"opsxapi/server/internal/docs"
|
||||
"opsxapi/server/internal/echo"
|
||||
"opsxapi/server/internal/mocksrv"
|
||||
"opsxapi/server/internal/proxy"
|
||||
"opsxapi/server/internal/site"
|
||||
)
|
||||
|
||||
func NewRouter() *gin.Engine {
|
||||
r := gin.New()
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
r.Use(gin.Logger(), gin.Recovery(), localhostCORS())
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "service": "opsxapi"})
|
||||
})
|
||||
p := proxy.NewHandler()
|
||||
r.POST("/v1/proxy/http", p.ProxyHTTP)
|
||||
r.POST("/v1/proxy/http/stream", p.ProxyHTTPStream)
|
||||
|
||||
tunnel := proxy.NewWSTunnel()
|
||||
r.POST("/v1/proxy/ws/connect", tunnel.Connect)
|
||||
r.GET("/v1/proxy/ws/attach", tunnel.Attach)
|
||||
r.POST("/v1/proxy/ws/close", tunnel.Close)
|
||||
|
||||
// 账号 / 团队 / 云端空间(SQLite),store 同时供 Mock 服务复用
|
||||
accountAPI, err := api.New(os.Getenv("OPSXAPI_DB"))
|
||||
if err != nil {
|
||||
panic("init sqlite store: " + err.Error())
|
||||
}
|
||||
accountAPI.Register(r)
|
||||
|
||||
mocksrv.New(accountAPI.Store()).Register(r)
|
||||
|
||||
echo.Mount(r)
|
||||
docs.New().Register(r)
|
||||
site.New().Register(r)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func localhostCORS() gin.HandlerFunc {
|
||||
allowed := map[string]struct{}{
|
||||
"http://localhost:5173": {},
|
||||
"http://127.0.0.1:5173": {},
|
||||
"http://localhost:1420": {},
|
||||
"http://127.0.0.1:1420": {},
|
||||
"tauri://localhost": {},
|
||||
"http://tauri.localhost": {},
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if _, ok := allowed[strings.TrimSuffix(origin, "/")]; ok {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Vary", "Origin")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie, X-Opsx-Probe, X-Opsx-Cookie")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "@opsxapi/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 5173",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 5173"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opsxapi/core": "*",
|
||||
"@opsxapi/transport": "*",
|
||||
"@opsxapi/ui": "*",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.3",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.2"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { OpsxWorkspace } from "@opsxapi/ui";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<OpsxWorkspace />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# OpsxAPI 与 Bruno / Hoppscotch / Yaak 对照(2026-10-15)
|
||||
|
||||
评估以本机 `/Users/logan/devops/demo/api/{bruno,hoppscotch,yaak}` 的源码为准;这里比较交互模式,不代表三个项目的全部功能或版本承诺。OpsxAPI 保持桌面请求工作台 + 内网 Mock / 文档分享的产品方向。
|
||||
|
||||
| 任务 | 参照实现 | OpsxAPI 现状 | 取舍与优先级 |
|
||||
| --- | --- | --- | --- |
|
||||
| 阅读 HTTP 响应 | Bruno 的响应格式选择和响应头计数(`bruno-app/src/components/ResponsePane/index.js`、`QueryResultTypeSelector/index.jsx`);Yaak 的 Pretty / Raw 与复制、保存动作(`yaak-client/components/HttpResponsePane.tsx`) | 已有 Raw、响应头、状态与耗时。此次补齐 Content-Type 判断、折行、按行数增长的行号、可折叠 Pretty JSON、键/值/标点分色、窄窗布局、图标复制及大响应保护 | **高,已落地基础项。** 下一步优先做响应内搜索;图片/PDF/时间线先按真实需求排期。 |
|
||||
| 请求与响应示例 | Hoppscotch 将当前响应保存为具名示例(`hoppscotch-common/src/components/http/Response.vue`) | 文档有响应示例,Mock 可从实际响应生成。此次增加“存为示例”,按状态码写入文档,同状态码先确认覆盖 | **高,已落地基础项。** 后续可考虑为同一状态码保留多条具名示例,但需先扩展内部文档结构和导入/导出格式。 |
|
||||
| 环境与变量 | Hoppscotch 在参数和请求体编辑器中突出环境变量(`http/Parameters.vue`、`RawBody.vue`);Yaak 将主题、界面与代理设置分组 | OpsxAPI 已有环境切换、相对路径拼接、变量补全与本机代理。此次外观新增跟随系统主题 | **中。** 在请求地址旁持续展示解析后的目标;缺失变量应就地提示,不把更多说明塞进设置页。 |
|
||||
| 自动化测试 | Hoppscotch 的请求前脚本、测试结果和 runner(`http/PreRequestScript.vue`、`http/test/Runner.vue`);Bruno 的测试结果与时间线(`ResponsePane/index.js`) | 已有用例步骤、断言、变量提取、报告和定时运行;没有任意脚本能力 | **中。** 先改善失败步骤定位及上次运行对照。脚本沙箱、变量作用域和权限边界明确后再引入脚本,不直接复制整个测试平台。 |
|
||||
| 设置与快捷操作 | Yaak 将代理、证书、快捷键、主题分开(`yaak-client/components/Settings/Settings.tsx`) | 代理、外观、大模型、数据管理、Mock 服务已分区;快捷键和证书管理尚无完整入口 | **中。** 先补“发送 / 保存 / 切换请求 / 搜索”的快捷键及可发现提示,再做冲突处理;证书管理需结合桌面传输实现。 |
|
||||
| 请求数据生成 | Bruno 在编辑器补全中提供 `$` 动态变量,并在 Mock 响应生成对话框中按 schema 生成示例;Hoppscotch 在编辑器高亮预定义变量(`HoppPredefinedVariables.ts`),通用设置页未单列随机规则;Yaak 使用请求模板函数及 Faker 插件 | OpsxAPI 的 `@` 表达式、字段匹配和预览在发送请求时生效,与 8788 内网 Mock 服务独立。保留开关、规则及预览,将“数据生成”从设置一级导航移到“网络 → 请求数据生成”,并在 HTTP Body 编辑器提供入口 | **保留能力、降低层级。** 暂不引入插件市场、脚本式生成器或更多设置开关;如后续发现规则维护频繁,可再做请求级规则作用域。 |
|
||||
| 大模型配置 | OpsxShell 的供应商列表、详情表单、连接测试和明确的默认状态(`opsxshell/src/components/settings/AiProviderSettings.tsx`) | 目前仅支持 OpenAI 兼容 Chat Completions,用于自动化测试用例生成;按“模型 / 连接 / 测试与保存”重排并显示默认、未保存与测试反馈 | **保持协议边界。** OpsxShell 的 Anthropic、Responses、多模型与计费字段依赖其专用客户端和数据结构,不能仅通过增加表单照搬。当前 API Key 随工作区保存,团队空间同步会包含此字段,后续应单独评估系统凭据存储。 |
|
||||
| 分享与 Mock | 三者重心分别偏向可复现集合、协作及本地请求体验 | OpsxAPI 的文档与 Mock 均能经独立本机服务分享到内网,且有服务开关与访问控制 | **保持优势。** 下一步应优先完善文档与 Mock 的同源状态提示、发布差异预览和真实内网访问反馈。 |
|
||||
|
||||
交互原则:保留“响应体 / Raw / 响应头”这几个稳定入口;把状态、耗时、大小与复制动作做成在窄窗口仍可阅读的工具栏;设置项按任务归类,只有影响安全或数据保存的说明才占据常显空间。暂不引入竞品的全部脚本、插件或媒体预览,以免把请求工作台变成难维护的多套运行时。
|
||||
|
||||
本轮验证:`npm test -- --run`(78 项)、`npm run build`、`git diff --check`。本机 GUI 的自动查看被运行环境拒绝,窄窗的实际视觉效果仍待桌面窗口验收。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 2026-10-14 开发代码恢复记录
|
||||
|
||||
## 恢复依据
|
||||
|
||||
当前 HEAD 为 `6452026`,reflog 保留了迁移半成品提交 `ca24b9c`。`stash@{0}` 及其第三父提交(未跟踪文件快照)保存了此前的开发内容。恢复采用逐文件映射,没有执行整体 stash apply、reset 或覆盖当前暂存的目录迁移。
|
||||
|
||||
已安装 `/Applications/OpsxAPI.app` 的可执行文件时间为 2026-09-19;包内仅找到可执行文件、图标和 Info.plist,未见独立源码或 source map。因此优先采用 Git 中的原始源码,未修改已安装 App。
|
||||
|
||||
截图中的环境管理(前置 URL、变量搜索)和八组合回归测试在恢复前源码中已经存在。
|
||||
|
||||
## 已恢复
|
||||
|
||||
- `src-tauri/src/`:Command、Service、Model、AppError 分层及相关测试。
|
||||
- `packages/transport/src/`:统一 IPC 类型/错误处理、WebSocket 生命周期修复和测试。
|
||||
- `packages/ui/src/features/`:workspace/testing 功能模块,执行状态共享与去重。
|
||||
- 通用按钮/空态、报告表格、主题 token、首屏主题恢复、关于页调整。
|
||||
- `package.json`:完整测试入口及独立类型检查命令。
|
||||
|
||||
保留当前 `src-tauri/`、`server/`、`src/` 与 `packages/` 布局,没有继续完成或回退整库目录迁移。
|
||||
|
||||
## 排除的半成品
|
||||
|
||||
- 外链 opener 调用缺少当前前后端插件接入,未恢复;保留现有链接方式。
|
||||
- Mock 状态卡依赖不存在的 GET `/mock/local`,未恢复该卡片及其调用函数。
|
||||
- 已撤销的 AGENTS.md 规则没有重新安装;历史文档仅保存在恢复备份中。
|
||||
|
||||
## 验证
|
||||
|
||||
- `npm run typecheck`:通过。
|
||||
- `npm run build`:通过,仍有大于 500 kB 的分包提示。
|
||||
- `npm test`:59 项通过(core 53、transport 4、UI 逻辑 2)。
|
||||
- `cargo test --manifest-path src-tauri/Cargo.toml --lib --offline`:3 项通过,1 项回环集成测试默认忽略。
|
||||
- 尚未完成桌面视觉、外部服务和真实 WebSocket 回环验收;不代表全部遗失开发均已找回。
|
||||
|
||||
恢复前文件、工作区/暂存区补丁、恢复映射及历史文档保存在 `/tmp/opsxapi-recovery-20261014`(临时目录,应另行长期备份)。原 stash 保持不变,恢复内容未自动提交。
|
||||
@@ -5,6 +5,15 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#080d14" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script>
|
||||
try {
|
||||
const theme = localStorage.getItem("opsxapi.theme")
|
||||
?? JSON.parse(localStorage.getItem("opsxapi.workspace.v2") || "null")?.settings?.theme;
|
||||
document.documentElement.dataset.theme = theme === "system"
|
||||
? (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
|
||||
: theme === "light" ? "light" : "dark";
|
||||
} catch {}
|
||||
</script>
|
||||
<title>OpsxAPI</title>
|
||||
</head>
|
||||
<body>
|
||||
@@ -5,44 +5,33 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "opsxapi",
|
||||
"workspaces": [
|
||||
"apps/web",
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-opener": "^2.7.1",
|
||||
"js-yaml": "^5.4.2",
|
||||
"mqtt": "^5.16.0",
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"playwright": "^1.55.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"apps/web": {
|
||||
"name": "@opsxapi/web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@opsxapi/core": "*",
|
||||
"@opsxapi/transport": "*",
|
||||
"@opsxapi/ui": "*",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.4.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.20.3",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"playwright": "^1.55.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.2"
|
||||
"vite": "^6.2.2",
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -841,22 +830,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@opsxapi/core": {
|
||||
"resolved": "packages/core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@opsxapi/transport": {
|
||||
"resolved": "packages/transport",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@opsxapi/ui": {
|
||||
"resolved": "packages/ui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@opsxapi/web": {
|
||||
"resolved": "apps/web",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1195,14 +1168,256 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
||||
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
|
||||
"version": "2.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.3.tgz",
|
||||
"integrity": "sha512-9s0PKw0p4r3kFq92jhpivYSp+cSht15o0gXpZxuQd0+dV9KCexEVkNaLELVoLdJWyoH4KlfQo5h3W2LIM8siJg==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.2.tgz",
|
||||
"integrity": "sha512-PMSppzAnlT+BvZtRKuidxjRSm2r5t8kDpJ/czmmQUa0AtDNTwrjLE8vApK3phCWGg7isfnYL46Dl1jB4ArI0hA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.12.2",
|
||||
"@tauri-apps/cli-darwin-x64": "2.12.2",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.12.2",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.12.2",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.12.2",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.12.2",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.12.2",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.12.2",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.12.2",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.12.2",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.12.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.2.tgz",
|
||||
"integrity": "sha512-Lk3zaBTfzNvA7670Al5PNgo2M/95uaDPe1yp+ILJeDOmol87XiYuK5E5+FUbK5e6U6OORx9qpZdOo2U+5tETtQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.2.tgz",
|
||||
"integrity": "sha512-2CyYjJ833JB3sKRz4B0W/Tv4oOVC16ws1bxdSCWj1XvrmanWwvZi99e5tNIoCHwHhcOm0jh+sh0PCGQyls6Z+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.2.tgz",
|
||||
"integrity": "sha512-uvt2xbone4JftKarelssi86Kqnl7JiG1Smh5+Ae+nZmtM+JUhTGmqkcqfUPXRW8Ygwog92POZvd4oGDN9j8eXQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.2.tgz",
|
||||
"integrity": "sha512-DZlDJ24u3tlocKOYqzR+b2M+Qr+5WFcoMBZ7EfdhHN5j3ZXwBBpms4G4gyNvPCrak5vMzmdZ65ecBXPJcC+5sA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.2.tgz",
|
||||
"integrity": "sha512-pb+RTZUYH/p0gR4j4ecCCq+FNfz3W3eKuUQvVEBn7MQZx6DzfLO7FaYzt2NyDKWpdNN3lP10mdzOvw3R5P5aAQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.2.tgz",
|
||||
"integrity": "sha512-nidEvDUJyHYNbIv9MUk6KiEix8IXfRh4Wj2G2SA5g8nFmgbBCwazUNWYiG/LhNeNbfZpBMLerRBUtoQ5K0GidA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.2.tgz",
|
||||
"integrity": "sha512-gkXyYn3w+Xfc8nz/DleEbkm1M7bBuU2cejqUXNmI98cA2P5kp+oPmS4ALTorb1FaNWjpsKoYZBWsbS9BXxYFkQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.2.tgz",
|
||||
"integrity": "sha512-MrjgoZ09s8Hsn99XvxSNvK8NQTZxi7XiyK3FxxytzfHgz2jb3qUT69CZtsokRkN/zdi9rCgrMtq+w9XNICjcfQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.2.tgz",
|
||||
"integrity": "sha512-BR+CYM7iF3lkfyTtVkwMQU/s9hwegLpj3UqDjaxKCPmixQCa1csBBPy3C75V+FhLtOjIXNna4Gt6Vm9oxbV+qA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.2.tgz",
|
||||
"integrity": "sha512-zODJo9l3TbNBM+GrO/yPqGeELNMK5cjIc+QHh1WQc3l8tD+jDZGTQyYuAxViCsM6Uh5uWJ//RnCZG9LXrvsXww==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.2.tgz",
|
||||
"integrity": "sha512-cQXI2ZaZmwBDt0XATCqASVanYizxkEE3ceQVlVHXaQ51HzsQUWA04c9Nz7LrrY2lgAu3jFMyat+8Yz3Z2R2qBA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.7.1.tgz",
|
||||
"integrity": "sha512-XkXdt30rontti9TII/gBBxFRXWfj8DiprFW7RgBFRBj1OhdfjBNVcFqSEWkkZ4hXafHnuEnB7F1sFsmu6uyywQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -3662,6 +3877,7 @@
|
||||
"version": "5.0.15",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
|
||||
"integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
@@ -3685,78 +3901,6 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opsxapi/core",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"js-yaml": "^4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.9"
|
||||
}
|
||||
},
|
||||
"packages/core/node_modules/js-yaml": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"packages/transport": {
|
||||
"name": "@opsxapi/transport",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@opsxapi/core": "*",
|
||||
"mqtt": "^5.10.1",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@tauri-apps/api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opsxapi/ui",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@opsxapi/core": "*",
|
||||
"@opsxapi/transport": "*",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,48 @@
|
||||
{
|
||||
"name": "opsxapi",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/web",
|
||||
"packages/*"
|
||||
],
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:web": "npm run dev -w @opsxapi/web",
|
||||
"build:web": "npm run build -w @opsxapi/web",
|
||||
"test": "npm run test:core",
|
||||
"test:core": "npm run test -w @opsxapi/core",
|
||||
"dev:desktop": "npm --prefix apps/desktop run tauri -- dev",
|
||||
"build:desktop": "npm --prefix apps/desktop run tauri -- build"
|
||||
"dev": "vite --host 127.0.0.1 --port 5173",
|
||||
"dev:ui": "vite --host 127.0.0.1 --port 5173",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"build:ui": "npm run build",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 5173",
|
||||
"tauri": "tauri",
|
||||
"dev:desktop": "tauri dev",
|
||||
"build:desktop": "tauri build",
|
||||
"test": "vitest run",
|
||||
"test:core": "vitest run src/core",
|
||||
"test:transport": "vitest run src/transport",
|
||||
"test:ui": "vitest run src/features",
|
||||
"typecheck": "tsc -p tsconfig.app.json --noEmit"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-opener": "^2.7.1",
|
||||
"js-yaml": "^5.4.2",
|
||||
"mqtt": "^5.16.0",
|
||||
"socket.io-client": "^4.8.3"
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.4.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"playwright": "^1.55.0"
|
||||
"@types/node": "^22.20.3",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"playwright": "^1.55.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.2",
|
||||
"vitest": "^3.2.7"
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "@opsxapi/core",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-yaml": "^4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.9"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "@opsxapi/transport",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opsxapi/core": "*",
|
||||
"mqtt": "^5.10.1",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@tauri-apps/api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxapi/core";
|
||||
|
||||
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
try {
|
||||
const { invoke: tauriInvoke } = await import("@tauri-apps/api/core");
|
||||
return await tauriInvoke<T>(cmd, args);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(
|
||||
`tauri-native invoke("${cmd}") failed (are you running inside OpsxAPI desktop?): ${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function isTauriRuntime(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return Boolean((window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__);
|
||||
}
|
||||
|
||||
type WsEventPayload = {
|
||||
sessionId: string;
|
||||
kind: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export const tauriNativeTransport: Transport = {
|
||||
async http(spec: HttpSpec): Promise<HttpResult> {
|
||||
return invoke<HttpResult>("http_send", { spec });
|
||||
},
|
||||
|
||||
ws(spec: WsSpec): WsSession {
|
||||
const messageCbs: Array<(data: string) => void> = [];
|
||||
const statusCbs: Array<(s: string) => void> = [];
|
||||
let sessionId: string | null = null;
|
||||
let unlisten: (() => void) | null = null;
|
||||
let closed = false;
|
||||
|
||||
const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s));
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
emitStatus("connecting");
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
unlisten = await listen<WsEventPayload>("ws-event", (ev) => {
|
||||
const p = ev.payload;
|
||||
if (!sessionId || p.sessionId !== sessionId) return;
|
||||
if (p.kind === "message") {
|
||||
messageCbs.forEach((cb) => cb(p.data));
|
||||
return;
|
||||
}
|
||||
if (p.kind === "status") {
|
||||
emitStatus(p.data);
|
||||
if (p.data === "closed" || p.data === "error") {
|
||||
// keep listening until explicit close()
|
||||
}
|
||||
}
|
||||
});
|
||||
sessionId = await invoke<string>("ws_connect", { spec });
|
||||
// open status also arrives via event; emit early for snappy UI
|
||||
} catch (e) {
|
||||
emitStatus("error");
|
||||
messageCbs.forEach((cb) =>
|
||||
cb(`[ws_connect error] ${e instanceof Error ? e.message : String(e)}`),
|
||||
);
|
||||
emitStatus("closed");
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
onMessage(cb) {
|
||||
messageCbs.push(cb);
|
||||
},
|
||||
onStatus(cb) {
|
||||
statusCbs.push(cb);
|
||||
},
|
||||
send(data) {
|
||||
if (!sessionId) {
|
||||
messageCbs.forEach((cb) => cb("[ws] not connected"));
|
||||
return;
|
||||
}
|
||||
void invoke("ws_send", { sessionId, data }).catch((e) => {
|
||||
messageCbs.forEach((cb) =>
|
||||
cb(`[ws_send error] ${e instanceof Error ? e.message : String(e)}`),
|
||||
);
|
||||
});
|
||||
},
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
const id = sessionId;
|
||||
sessionId = null;
|
||||
unlisten?.();
|
||||
unlisten = null;
|
||||
if (!id) {
|
||||
emitStatus("closed");
|
||||
return;
|
||||
}
|
||||
void invoke("ws_close", { sessionId: id }).finally(() => emitStatus("closed"));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "@opsxapi/ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opsxapi/core": "*",
|
||||
"@opsxapi/transport": "*",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
/**
|
||||
* OpsxAPI 工作台入口:组装顶栏 / 侧栏 / 标签页 / 编辑器 / 对话框。
|
||||
*/
|
||||
import { findNode } from "@opsxapi/core";
|
||||
import { useEffect } from "react";
|
||||
import { closeSession } from "./send";
|
||||
import { Icon } from "./components/icons";
|
||||
import { Logo } from "./components/Logo";
|
||||
import { useWorkspace } from "./workspaceStore";
|
||||
import { ActivityRail } from "./components/ActivityRail";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { TestingView } from "./components/TestingView";
|
||||
import { startScheduler, stopScheduler } from "./scheduler";
|
||||
import { TabBar } from "./components/TabBar";
|
||||
import { TopBar } from "./components/TopBar";
|
||||
import { GraphqlEditor } from "./components/editors/GraphqlEditor";
|
||||
import { HttpEditor } from "./components/editors/HttpEditor";
|
||||
import { MqttEditor } from "./components/editors/MqttEditor";
|
||||
import { SocketIoEditor } from "./components/editors/SocketIoEditor";
|
||||
import { SseEditor } from "./components/editors/SseEditor";
|
||||
import { WebsocketEditor } from "./components/editors/WebsocketEditor";
|
||||
import { ExportDialog } from "./components/dialogs/ExportDialog";
|
||||
import { ImportDialog } from "./components/dialogs/ImportDialog";
|
||||
import { AuthDialog, TeamDialog } from "./components/dialogs/AccountDialogs";
|
||||
import { MockLinkDialog } from "./components/dialogs/MockLinkDialog";
|
||||
import {
|
||||
EnvironmentsDialog,
|
||||
NewItemDialog,
|
||||
RenameDialog,
|
||||
SettingsDialog,
|
||||
} from "./components/dialogs/WorkspaceDialogs";
|
||||
|
||||
export function OpsxWorkspace() {
|
||||
const init = useWorkspace((s) => s.init);
|
||||
const inited = useWorkspace((s) => s.collections.length > 0 || s.environments.length > 0);
|
||||
const collections = useWorkspace((s) => s.collections);
|
||||
const activeTabId = useWorkspace((s) => s.activeTabId);
|
||||
const dialog = useWorkspace((s) => s.dialog);
|
||||
const toast = useWorkspace((s) => s.toast);
|
||||
const clearToast = useWorkspace((s) => s.clearToast);
|
||||
const openTab = useWorkspace((s) => s.openTab);
|
||||
const view = useWorkspace((s) => s.view);
|
||||
|
||||
useEffect(() => {
|
||||
init();
|
||||
}, [init]);
|
||||
|
||||
// 关闭标签页时断开实时会话
|
||||
useEffect(() => {
|
||||
useWorkspace.subscribe((state, prev) => {
|
||||
const closed = prev.openTabIds.filter((id) => !state.openTabIds.includes(id));
|
||||
for (const id of closed) closeSession(id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 定时任务调度器(应用运行期间常驻)
|
||||
useEffect(() => {
|
||||
startScheduler();
|
||||
return () => stopScheduler();
|
||||
}, []);
|
||||
|
||||
const activeRef = activeTabId ? findNode(collections, activeTabId) : null;
|
||||
const activeNode = activeRef?.node;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen min-h-[640px] flex-col overflow-hidden">
|
||||
<TopBar />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<ActivityRail />
|
||||
{view === "testing" ? (
|
||||
<div className="flex min-h-0 min-w-0 flex-1">
|
||||
<TestingView />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<Sidebar />
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<TabBar />
|
||||
{inited && activeNode === undefined && (
|
||||
<EmptyState onQuick={(kind) => collections[0] && openTab(collections[0].id)} />
|
||||
)}
|
||||
{activeNode && activeNode.type === "request" && (
|
||||
<>
|
||||
{activeNode.kind === "http" && <HttpEditor node={activeNode} />}
|
||||
{activeNode.kind === "graphql" && <GraphqlEditor node={activeNode} />}
|
||||
{activeNode.kind === "websocket" && <WebsocketEditor node={activeNode} />}
|
||||
{activeNode.kind === "sse" && <SseEditor node={activeNode} />}
|
||||
{activeNode.kind === "socketio" && <SocketIoEditor node={activeNode} />}
|
||||
{activeNode.kind === "mqtt" && <MqttEditor node={activeNode} />}
|
||||
</>
|
||||
)}
|
||||
{activeNode && activeNode.type === "folder" && (
|
||||
<EmptyState onQuick={() => undefined} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
{dialog?.type === "new" && <NewItemDialog collectionId={dialog.collectionId} parentId={dialog.parentId} />}
|
||||
{dialog?.type === "import" && <ImportDialog />}
|
||||
{dialog?.type === "export" && <ExportDialog collectionId={dialog.collectionId} />}
|
||||
{dialog?.type === "environments" && <EnvironmentsDialog />}
|
||||
{dialog?.type === "settings" && <SettingsDialog tab={dialog.tab} />}
|
||||
{dialog?.type === "rename" && <RenameDialog id={dialog.id} />}
|
||||
{dialog?.type === "mocklink" &&
|
||||
(() => {
|
||||
const ref = findNode(collections, dialog.nodeId);
|
||||
return ref?.node.type === "request" && ref.node.kind === "http" ? (
|
||||
<MockLinkDialog node={ref.node} />
|
||||
) : null;
|
||||
})()}
|
||||
{dialog?.type === "auth" && <AuthDialog />}
|
||||
{dialog?.type === "team" && <TeamDialog />}
|
||||
|
||||
{toast && (
|
||||
<div
|
||||
className="ops-toast"
|
||||
role="status"
|
||||
data-tone={toast.tone}
|
||||
onClick={() => clearToast()}
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 24,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 80,
|
||||
maxWidth: 420,
|
||||
padding: "10px 14px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--panel)",
|
||||
color: "var(--text)",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,.35)",
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ onQuick }: { onQuick: (kind: string) => void }) {
|
||||
const setDialog = useWorkspace((s) => s.setDialog);
|
||||
void onQuick;
|
||||
return (
|
||||
<div className="grid min-h-0 flex-1 place-items-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-3 opacity-80"><Logo size={56} /></div>
|
||||
<div className="ops-dim mb-1 text-sm font-semibold">OpsxAPI · 接口工作台</div>
|
||||
<div className="ops-faint mb-5 text-xs">
|
||||
从左侧选择接口,或
|
||||
<button className="ops-link mx-1" onClick={() => setDialog({ type: "import" })}>
|
||||
导入数据
|
||||
</button>
|
||||
/
|
||||
<button
|
||||
className="ops-link ml-1"
|
||||
onClick={() =>
|
||||
setDialog({ type: "new", collectionId: useWorkspace.getState().collections[0]?.id ?? "", parentId: null })
|
||||
}
|
||||
>
|
||||
新建请求
|
||||
</button>
|
||||
</div>
|
||||
<div className="ops-faint text-[11px] leading-6">
|
||||
HTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT
|
||||
<br />
|
||||
支持 OpenAPI / Postman / Apifox / cURL / HAR 等格式导入导出
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,756 +0,0 @@
|
||||
/**
|
||||
* 自动化测试视图:场景用例列表 / 步骤编排(断言 + 变量提取)/ 运行报告 / 定时任务。
|
||||
* 三种触发方式:手动运行(本视图)、AI 生成(AiCaseDialog)、定时调度(scheduler + 定时设置)。
|
||||
*/
|
||||
import type {
|
||||
AssertOp,
|
||||
AssertTarget,
|
||||
Collection,
|
||||
HttpRequestData,
|
||||
RequestNode,
|
||||
TestCase,
|
||||
TestRun,
|
||||
} from "@opsxapi/core";
|
||||
import {
|
||||
ASSERT_OP_LABELS,
|
||||
createSeedWorkspace,
|
||||
exportRunJson,
|
||||
exportRunMarkdown,
|
||||
isValidCron,
|
||||
newAssertion,
|
||||
newExtract,
|
||||
newStep,
|
||||
runStats,
|
||||
} from "@opsxapi/core";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { nextRunAt } from "../scheduler";
|
||||
import { runTestCase } from "../testRunner";
|
||||
import { useWorkspace } from "../workspaceStore";
|
||||
import { cx } from "./common";
|
||||
import { Icon } from "./icons";
|
||||
import { AiCaseDialog } from "./dialogs/AiCaseDialog";
|
||||
import { ScheduleDialog } from "./dialogs/ScheduleDialog";
|
||||
|
||||
const TARGET_LABELS: Record<AssertTarget, string> = {
|
||||
status: "状态码",
|
||||
timeMs: "耗时(ms)",
|
||||
header: "Header",
|
||||
body: "Body",
|
||||
};
|
||||
|
||||
/** 汇总所有集合中的 HTTP 节点为选择器选项 */
|
||||
function useHttpNodeOptions(): { id: string; label: string; method: string }[] {
|
||||
const collections = useWorkspace((s) => s.collections);
|
||||
return useMemo(() => {
|
||||
const out: { id: string; label: string; method: string }[] = [];
|
||||
const walk = (nodes: Collection["children"], prefix: string): void => {
|
||||
for (const n of nodes) {
|
||||
if (n.type === "folder") walk(n.children, prefix ? `${prefix}/${n.name}` : n.name);
|
||||
else if (n.kind === "http") {
|
||||
const d = (n as RequestNode & { kind: "http" }).data as HttpRequestData;
|
||||
out.push({ id: n.id, label: `${prefix ? `${prefix}/` : ""}${n.name}`, method: d.method });
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const c of collections) walk(c.children, c.name);
|
||||
return out;
|
||||
}, [collections]);
|
||||
}
|
||||
|
||||
|
||||
const REGRESSION_CASE_ID = "tc-opsx-http8-regression";
|
||||
|
||||
/** 装载 / 刷新本地 Gin echo 八组合回归用例(不整库清空) */
|
||||
function installHttp8Regression(): string {
|
||||
const seed = createSeedWorkspace();
|
||||
const state = useWorkspace.getState();
|
||||
const seedCase = seed.testCases.find((c) => c.id === REGRESSION_CASE_ID) ?? seed.testCases[0]!;
|
||||
const localEnv = seed.environments.find((e) => e.name === "本地") ?? seed.environments[0];
|
||||
const hasCase = state.testCases.some((c) => c.id === REGRESSION_CASE_ID);
|
||||
const hasCollection = state.collections.some((c) => c.name === "示例集合");
|
||||
const localExisting = state.environments.find((e) => e.name === "本地");
|
||||
state.loadWorkspaceData({
|
||||
version: 2,
|
||||
collections: hasCollection
|
||||
? state.collections.map((c) => (c.name === "示例集合" ? seed.collections[0]! : c))
|
||||
: [...state.collections, ...seed.collections],
|
||||
environments: localExisting
|
||||
? state.environments
|
||||
: [...state.environments, ...seed.environments.filter((e) => e.name === "本地")],
|
||||
activeEnvironmentId: localExisting?.id ?? localEnv?.id ?? state.activeEnvironmentId,
|
||||
history: state.history,
|
||||
settings: state.settings,
|
||||
testCases: hasCase
|
||||
? state.testCases.map((c) => (c.id === REGRESSION_CASE_ID ? seedCase : c))
|
||||
: [...state.testCases, seedCase],
|
||||
testRuns: state.testRuns,
|
||||
schedules: state.schedules,
|
||||
});
|
||||
return seedCase.id;
|
||||
}
|
||||
|
||||
export function TestingView() {
|
||||
const testCases = useWorkspace((s) => s.testCases);
|
||||
const testRuns = useWorkspace((s) => s.testRuns);
|
||||
const schedules = useWorkspace((s) => s.schedules);
|
||||
const setView = useWorkspace((s) => s.setView);
|
||||
const addTestCase = useWorkspace((s) => s.addTestCase);
|
||||
|
||||
const [selected, setSelected] = useState<string | null>(testCases[0]?.id ?? null);
|
||||
const [showAi, setShowAi] = useState(false);
|
||||
const [showSchedule, setShowSchedule] = useState(false);
|
||||
const [runAllBusy, setRunAllBusy] = useState(false);
|
||||
|
||||
const tc = testCases.find((c) => c.id === selected) ?? null;
|
||||
const lastRunOf = (caseId: string): TestRun | undefined => testRuns.find((r) => r.caseId === caseId);
|
||||
|
||||
const runAll = async () => {
|
||||
setRunAllBusy(true);
|
||||
try {
|
||||
for (const c of testCases) await runTestCase(c.id, "manual");
|
||||
} finally {
|
||||
setRunAllBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* 左栏:用例列表 */}
|
||||
<aside className="ops-panel flex w-64 shrink-0 flex-col" style={{ borderRight: "1px solid var(--border)" }}>
|
||||
<div className="flex items-center gap-1.5 p-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<Icon name="list-checks" size={15} className="ops-accent" />
|
||||
<span className="text-[13px] font-semibold">场景用例</span>
|
||||
<span className="ops-faint ml-auto text-[11px]">{testCases.length}</span>
|
||||
</div>
|
||||
<div className="p-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<button className="ops-btn ops-btn-secondary !px-2" onClick={() => setSelected(addTestCase())}>
|
||||
<Icon name="plus" size={13} /> 新建
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-secondary !px-2" onClick={() => setShowAi(true)}>
|
||||
<Icon name="bot" size={13} /> AI 生成
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="ops-btn ops-btn-secondary mt-1.5 w-full !px-2"
|
||||
title="装载本地 Gin /v1/echo 八组合回归用例"
|
||||
onClick={() => setSelected(installHttp8Regression())}
|
||||
>
|
||||
<Icon name="play" size={13} /> 装载回归示例
|
||||
</button>
|
||||
</div>
|
||||
<div className="ops-scroll min-h-0 flex-1 p-2">
|
||||
{testCases.length === 0 && (
|
||||
<div className="ops-faint px-2 py-8 text-center text-xs leading-5">
|
||||
还没有用例
|
||||
<br />
|
||||
点「新建」手动编排,或用「AI 生成」
|
||||
</div>
|
||||
)}
|
||||
{testCases.map((c) => {
|
||||
const last = lastRunOf(c.id);
|
||||
const sched = schedules.find((s) => s.caseId === c.id && s.enabled);
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
className={cx("ops-tree-row w-full text-left", selected === c.id && "ops-tree-row-active")}
|
||||
onClick={() => setSelected(c.id)}
|
||||
>
|
||||
<span className="truncate text-[12px] font-medium">{c.name}</span>
|
||||
<span className="ops-faint ml-auto shrink-0 text-[11px]">{c.steps.length} 步</span>
|
||||
{sched && <Icon name="calendar-clock" size={11} className="ops-accent" />}
|
||||
{last && (
|
||||
<Icon
|
||||
name={last.passed ? "circle-check" : "circle-x"}
|
||||
size={12}
|
||||
className={last.passed ? "ops-success" : "ops-danger"}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-1.5 p-2.5" style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<button className="ops-btn ops-btn-secondary w-full" onClick={() => setShowSchedule(true)}>
|
||||
<Icon name="calendar-clock" size={13} /> 定时任务
|
||||
{schedules.filter((s) => s.enabled).length > 0 && (
|
||||
<span className="ops-count">{schedules.filter((s) => s.enabled).length}</span>
|
||||
)}
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-primary w-full" disabled={runAllBusy || testCases.length === 0} onClick={runAll}>
|
||||
<Icon name="play" size={13} /> {runAllBusy ? "运行中…" : "运行全部"}
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-ghost w-full" onClick={() => setView("api")}>
|
||||
<Icon name="arrow-left-right" size={13} /> 返回接口工作台
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 右侧:用例详情 */}
|
||||
{tc ? (
|
||||
<CaseDetail key={tc.id} tc={tc} />
|
||||
) : (
|
||||
<div className="grid min-w-0 flex-1 place-items-center">
|
||||
<div className="text-center">
|
||||
<div className="ops-dim mb-2 flex justify-center"><Icon name="list-checks" size={40} /></div>
|
||||
<div className="ops-dim text-sm font-semibold">自动化测试</div>
|
||||
<div className="ops-faint mt-1 text-xs leading-5">
|
||||
手动编排 · AI 生成 · 定时调度,三种方式共用同一套场景用例
|
||||
<br />
|
||||
左侧新建用例;侧栏底部「自动化测试」随时进出本视图
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAi && <AiCaseDialog onClose={() => setShowAi(false)} onSaved={(id) => setSelected(id)} />}
|
||||
{showSchedule && <ScheduleDialog onClose={() => setShowSchedule(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 用例详情:编辑 + 报告 ---------------- */
|
||||
|
||||
function CaseDetail({ tc }: { tc: TestCase }) {
|
||||
const updateTestCase = useWorkspace((s) => s.updateTestCase);
|
||||
const removeTestCase = useWorkspace((s) => s.removeTestCase);
|
||||
const allRuns = useWorkspace((s) => s.testRuns);
|
||||
const runs = useMemo(() => allRuns.filter((r) => r.caseId === tc.id), [allRuns, tc.id]);
|
||||
const schedules = useWorkspace((s) => s.schedules);
|
||||
const nodeOptions = useHttpNodeOptions();
|
||||
const [tab, setTab] = useState<"edit" | "report">("edit");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const sched = schedules.find((s) => s.caseId === tc.id);
|
||||
|
||||
const patch = (p: Partial<TestCase>) => updateTestCase(tc.id, p);
|
||||
|
||||
const patchStep = (stepId: string, p: Partial<TestCase["steps"][number]>) =>
|
||||
patch({ steps: tc.steps.map((st) => (st.id === stepId ? { ...st, ...p } : st)) });
|
||||
|
||||
const moveStep = (idx: number, dir: -1 | 1) => {
|
||||
const steps = [...tc.steps];
|
||||
const j = idx + dir;
|
||||
if (j < 0 || j >= steps.length) return;
|
||||
[steps[idx], steps[j]] = [steps[j], steps[idx]];
|
||||
patch({ steps });
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
setBusy(true);
|
||||
setTab("report");
|
||||
try {
|
||||
await runTestCase(tc.id, "manual");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* 头部 */}
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<input
|
||||
className="ops-input ops-input-sm min-w-40 flex-1 font-semibold"
|
||||
value={tc.name}
|
||||
onChange={(e) => patch({ name: e.target.value })}
|
||||
placeholder="用例名称"
|
||||
/>
|
||||
<label className="ops-faint flex items-center gap-1.5 text-[11px]" title="某步失败后是否继续执行后续步骤">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tc.continueOnFailure}
|
||||
onChange={(e) => patch({ continueOnFailure: e.target.checked })}
|
||||
/>
|
||||
失败后继续
|
||||
</label>
|
||||
<ScheduleInline caseId={tc.id} />
|
||||
<button className="ops-btn ops-btn-primary" disabled={busy || tc.steps.length === 0} onClick={run}>
|
||||
<Icon name="play" size={13} /> {busy ? "运行中…" : "运行"}
|
||||
</button>
|
||||
<button
|
||||
className="ops-btn ops-btn-ghost ops-danger"
|
||||
title="删除用例"
|
||||
onClick={() => {
|
||||
if (confirm(`删除用例「${tc.name}」及其运行记录?`)) removeTestCase(tc.id);
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex shrink-0 px-2" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<button className={cx("ops-tab", tab === "edit" && "ops-tab-active")} onClick={() => setTab("edit")}>
|
||||
用例编排
|
||||
</button>
|
||||
<button className={cx("ops-tab", tab === "report" && "ops-tab-active")} onClick={() => setTab("report")}>
|
||||
测试报告 {runs.length > 0 && <span className="ops-count">{runs.length}</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "edit" ? (
|
||||
<div className="ops-scroll flex-1 p-3.5">
|
||||
<textarea
|
||||
className="ops-input mb-3 min-h-14 w-full resize-y text-[12px]"
|
||||
placeholder="用例说明(可选):场景目的、前置条件…"
|
||||
value={tc.description ?? ""}
|
||||
onChange={(e) => patch({ description: e.target.value })}
|
||||
/>
|
||||
|
||||
{tc.steps.length === 0 && (
|
||||
<div className="ops-faint py-8 text-center text-xs leading-5">
|
||||
还没有步骤,点击下方「添加步骤」从集合中选择 HTTP 接口
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{tc.steps.map((step, idx) => (
|
||||
<StepEditor
|
||||
key={step.id}
|
||||
index={idx}
|
||||
step={step}
|
||||
nodeOptions={nodeOptions}
|
||||
onPatch={(p) => patchStep(step.id, p)}
|
||||
onRemove={() => patch({ steps: tc.steps.filter((st) => st.id !== step.id) })}
|
||||
onMove={(dir) => moveStep(idx, dir)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<button
|
||||
className="ops-btn ops-btn-secondary w-full border-dashed"
|
||||
onClick={() => patch({ steps: [...tc.steps, newStep("")] })}
|
||||
>
|
||||
<Icon name="plus" size={13} /> 添加步骤
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ReportPanel caseId={tc.id} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 单步骤编辑 ---------------- */
|
||||
|
||||
function StepEditor({
|
||||
index,
|
||||
step,
|
||||
nodeOptions,
|
||||
onPatch,
|
||||
onRemove,
|
||||
onMove,
|
||||
}: {
|
||||
index: number;
|
||||
step: TestCase["steps"][number];
|
||||
nodeOptions: { id: string; label: string; method: string }[];
|
||||
onPatch: (p: Partial<TestCase["steps"][number]>) => void;
|
||||
onRemove: () => void;
|
||||
onMove: (dir: -1 | 1) => void;
|
||||
}) {
|
||||
const node = nodeOptions.find((o) => o.id === step.nodeId);
|
||||
const [showDetail, setShowDetail] = useState(true);
|
||||
|
||||
return (
|
||||
<div className={cx("rounded-xl", !step.enabled && "opacity-50")} style={{ border: "1px solid var(--border)" }}>
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-3 py-2" style={{ borderBottom: showDetail ? "1px solid var(--border)" : "none" }}>
|
||||
<span className="ops-faint w-5 text-center text-[11px] font-bold">{index + 1}</span>
|
||||
<select
|
||||
className="ops-input ops-input-sm min-w-52 flex-1"
|
||||
value={step.nodeId}
|
||||
onChange={(e) => onPatch({ nodeId: e.target.value })}
|
||||
>
|
||||
<option value="">选择 HTTP 接口…</option>
|
||||
{nodeOptions.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.method} · {o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{node && (
|
||||
<span className={cx("ops-chip", `ops-method-${node.method}`)} style={{ padding: "1px 8px", fontSize: 11 }}>
|
||||
{node.method}
|
||||
</span>
|
||||
)}
|
||||
<button className="ops-btn ops-btn-ghost px-1.5" title="上移" onClick={() => onMove(-1)}>
|
||||
<Icon name="chevron-right" size={12} style={{ transform: "rotate(-90deg)" }} />
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-ghost px-1.5" title="下移" onClick={() => onMove(1)}>
|
||||
<Icon name="chevron-down" size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="ops-btn ops-btn-ghost px-1.5"
|
||||
title={step.enabled ? "禁用步骤" : "启用步骤"}
|
||||
onClick={() => onPatch({ enabled: !step.enabled })}
|
||||
>
|
||||
<Icon name={step.enabled ? "circle-check" : "circle-x"} size={13} className={step.enabled ? "ops-success" : "ops-faint"} />
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-ghost px-1.5" title="删除步骤" onClick={onRemove}>
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDetail && (
|
||||
<div className="space-y-2.5 px-3 py-2.5">
|
||||
{/* 断言 */}
|
||||
<div>
|
||||
<div className="ops-faint mb-1 text-[11px] font-semibold uppercase tracking-wider">断言</div>
|
||||
{step.assertions.map((a) => (
|
||||
<div key={a.id} className="test-assert-row">
|
||||
<select
|
||||
className="ops-input ops-input-sm"
|
||||
value={a.target}
|
||||
onChange={(e) =>
|
||||
onPatch({
|
||||
assertions: step.assertions.map((x) =>
|
||||
x.id === a.id ? { ...x, target: e.target.value as AssertTarget, locator: "", expected: "" } : x,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
{(Object.keys(TARGET_LABELS) as AssertTarget[]).map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{TARGET_LABELS[t]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(a.target === "header" || a.op.startsWith("jsonpath")) && (
|
||||
<input
|
||||
className="ops-input ops-input-sm test-assert-locator"
|
||||
placeholder={a.target === "header" ? "Header 名" : "$.data.id"}
|
||||
value={a.locator}
|
||||
onChange={(e) =>
|
||||
onPatch({ assertions: step.assertions.map((x) => (x.id === a.id ? { ...x, locator: e.target.value } : x)) })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
className="ops-input ops-input-sm"
|
||||
value={a.op}
|
||||
onChange={(e) =>
|
||||
onPatch({ assertions: step.assertions.map((x) => (x.id === a.id ? { ...x, op: e.target.value as AssertOp } : x)) })
|
||||
}
|
||||
>
|
||||
{(Object.keys(ASSERT_OP_LABELS) as AssertOp[]).map((op) => (
|
||||
<option key={op} value={op}>
|
||||
{ASSERT_OP_LABELS[op]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{a.op !== "exists" && a.op !== "not_exists" && (
|
||||
<input
|
||||
className="ops-input ops-input-sm test-assert-expected"
|
||||
placeholder="期望值"
|
||||
value={a.expected}
|
||||
onChange={(e) =>
|
||||
onPatch({ assertions: step.assertions.map((x) => (x.id === a.id ? { ...x, expected: e.target.value } : x)) })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="ops-btn ops-btn-ghost px-1.5"
|
||||
title={a.enabled ? "禁用" : "启用"}
|
||||
onClick={() =>
|
||||
onPatch({ assertions: step.assertions.map((x) => (x.id === a.id ? { ...x, enabled: !x.enabled } : x)) })
|
||||
}
|
||||
>
|
||||
<Icon name={a.enabled ? "circle-check" : "circle-x"} size={12} className={a.enabled ? "ops-success" : "ops-faint"} />
|
||||
</button>
|
||||
<button
|
||||
className="ops-btn ops-btn-ghost ops-danger px-1.5"
|
||||
title="删除断言"
|
||||
onClick={() => onPatch({ assertions: step.assertions.filter((x) => x.id !== a.id) })}
|
||||
>
|
||||
<Icon name="x" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="ops-btn ops-btn-ghost mt-1 !py-0.5 text-[11px]" onClick={() => onPatch({ assertions: [...step.assertions, newAssertion()] })}>
|
||||
<Icon name="plus" size={11} /> 添加断言
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 变量提取 */}
|
||||
<div>
|
||||
<div className="ops-faint mb-1 text-[11px] font-semibold uppercase tracking-wider">
|
||||
变量提取 <span className="normal-case">(响应 → 当前环境,后续步骤用 {"{{变量名}}"} 引用)</span>
|
||||
</div>
|
||||
{step.extracts.length === 0 && (
|
||||
<button
|
||||
className="ops-btn ops-btn-ghost !py-0.5 text-[11px]"
|
||||
onClick={() => onPatch({ extracts: [...step.extracts, newExtract()] })}
|
||||
>
|
||||
<Icon name="plus" size={11} /> 添加提取
|
||||
</button>
|
||||
)}
|
||||
{step.extracts.map((ex) => (
|
||||
<div key={ex.id} className="test-assert-row">
|
||||
<input
|
||||
className="ops-input ops-input-sm test-assert-locator"
|
||||
placeholder="$.data.token"
|
||||
value={ex.jsonpath}
|
||||
onChange={(e) =>
|
||||
onPatch({ extracts: step.extracts.map((x) => (x.id === ex.id ? { ...x, jsonpath: e.target.value } : x)) })
|
||||
}
|
||||
/>
|
||||
<span className="ops-faint text-[11px]">→</span>
|
||||
<input
|
||||
className="ops-input ops-input-sm test-assert-expected"
|
||||
placeholder="变量名 token"
|
||||
value={ex.varName}
|
||||
onChange={(e) =>
|
||||
onPatch({ extracts: step.extracts.map((x) => (x.id === ex.id ? { ...x, varName: e.target.value } : x)) })
|
||||
}
|
||||
/>
|
||||
<button
|
||||
className="ops-btn ops-btn-ghost ops-danger px-1.5"
|
||||
title="删除"
|
||||
onClick={() => onPatch({ extracts: step.extracts.filter((x) => x.id !== ex.id) })}
|
||||
>
|
||||
<Icon name="x" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{step.extracts.length > 0 && (
|
||||
<button className="ops-btn ops-btn-ghost mt-1 !py-0.5 text-[11px]" onClick={() => onPatch({ extracts: [...step.extracts, newExtract()] })}>
|
||||
<Icon name="plus" size={11} /> 添加提取
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 行内定时配置 ---------------- */
|
||||
|
||||
function ScheduleInline({ caseId }: { caseId: string }) {
|
||||
const schedules = useWorkspace((s) => s.schedules);
|
||||
const upsertSchedule = useWorkspace((s) => s.upsertSchedule);
|
||||
const removeSchedule = useWorkspace((s) => s.removeSchedule);
|
||||
const sc = schedules.find((s) => s.caseId === caseId);
|
||||
|
||||
if (!sc) {
|
||||
return (
|
||||
<button className="ops-btn ops-btn-secondary !px-2" title="设置定时运行" onClick={() => upsertSchedule(caseId, {})}>
|
||||
<Icon name="calendar-clock" size={13} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const cron = sc.cron ?? "";
|
||||
const cronOk = !cron.trim() || isValidCron(cron.trim());
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<input
|
||||
className="ops-input ops-input-sm !w-36 font-mono text-[11px]"
|
||||
placeholder="Cron 可选 */5 * * * *"
|
||||
value={cron}
|
||||
title={cronOk ? "5 段 Cron(分 时 日 月 周);留空则用间隔" : "Cron 格式无效"}
|
||||
onChange={(e) => upsertSchedule(caseId, { cron: e.target.value.trim() || null })}
|
||||
style={!cronOk ? { borderColor: "var(--danger)" } : undefined}
|
||||
/>
|
||||
<select
|
||||
className="ops-input ops-input-sm !w-24"
|
||||
value={sc.intervalMinutes}
|
||||
disabled={Boolean(cron.trim())}
|
||||
onChange={(e) => upsertSchedule(caseId, { intervalMinutes: Number(e.target.value), cron: null })}
|
||||
title={
|
||||
cron.trim()
|
||||
? "已启用 Cron,间隔停用"
|
||||
: `下次运行:${new Date(nextRunAt(sc.intervalMinutes, sc.lastRunAt, sc.cron)).toLocaleTimeString("zh-CN")}`
|
||||
}
|
||||
>
|
||||
{[5, 15, 30, 60, 360, 1440].map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m < 60 ? `每 ${m} 分钟` : m < 1440 ? `每 ${m / 60} 小时` : "每天"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="ops-btn ops-btn-ghost px-1.5"
|
||||
title={sc.enabled ? "暂停定时" : "启用定时"}
|
||||
onClick={() => upsertSchedule(caseId, { enabled: !sc.enabled })}
|
||||
>
|
||||
<Icon name={sc.enabled ? "calendar-clock" : "circle-x"} size={13} className={sc.enabled ? "ops-accent" : "ops-faint"} />
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-ghost ops-danger px-1.5" title="删除定时" onClick={() => removeSchedule(caseId)}>
|
||||
<Icon name="x" size={12} />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 运行报告 ---------------- */
|
||||
|
||||
function ReportPanel({ caseId }: { caseId: string }) {
|
||||
const allRuns = useWorkspace((s) => s.testRuns);
|
||||
const runs = useMemo(() => allRuns.filter((r) => r.caseId === caseId), [allRuns, caseId]);
|
||||
const [selectedRun, setSelectedRun] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<"all" | "pass" | "fail">("all");
|
||||
const run = runs.find((r) => r.id === selectedRun) ?? runs[0];
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (run && selectedRun && !runs.some((r) => r.id === selectedRun)) setSelectedRun(null);
|
||||
}, [runs, selectedRun, run]);
|
||||
|
||||
if (!run) {
|
||||
return (
|
||||
<div className="ops-faint grid flex-1 place-items-center text-center text-xs leading-5">
|
||||
还没有运行记录
|
||||
<br />
|
||||
点击右上「运行」执行用例后在此查看报告
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = runStats(run);
|
||||
const assertCount = run.stepResults.reduce((acc, s) => acc + s.assertionResults.length, 0);
|
||||
const shown = run.stepResults.filter((s) => (filter === "all" ? true : filter === "pass" ? s.ok : !s.ok));
|
||||
|
||||
const download = (format: "md" | "json") => {
|
||||
const body = format === "json" ? exportRunJson(run) : exportRunMarkdown(run);
|
||||
const blob = new Blob([body], {
|
||||
type: format === "json" ? "application/json;charset=utf-8" : "text/markdown;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
const day = new Date(run.at).toISOString().slice(0, 10);
|
||||
a.download = `${run.caseName}-报告-${day}.${format === "json" ? "json" : "md"}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ops-scroll flex-1 p-3.5">
|
||||
{/* 运行选择 + 操作 */}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="ops-input ops-input-sm w-64"
|
||||
value={run.id}
|
||||
onChange={(e) => setSelectedRun(e.target.value)}
|
||||
>
|
||||
{runs.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{new Date(r.at).toLocaleString("zh-CN")} · {r.trigger === "schedule" ? "定时" : r.trigger === "ai" ? "AI" : "手动"} ·{" "}
|
||||
{r.passed ? "通过" : "失败"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="ml-auto flex gap-1.5">
|
||||
<button
|
||||
className="ops-btn ops-btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await runTestCase(caseId, "manual");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="rotate-cw" size={13} /> 再次运行
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-secondary" onClick={() => download("md")}>
|
||||
<Icon name="file-down" size={13} /> 导出 MD
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-secondary" onClick={() => download("json")}>
|
||||
<Icon name="file-down" size={13} /> 导出 JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡 */}
|
||||
<div className="test-stat-grid mb-3">
|
||||
<div className={cx("test-stat", run.passed && "ok")}>
|
||||
<div className="test-stat-value">{stats.ok}</div>
|
||||
<div className="test-stat-label">通过步骤</div>
|
||||
</div>
|
||||
<div className={cx("test-stat", stats.fail > 0 && "bad")}>
|
||||
<div className="test-stat-value">{stats.fail}</div>
|
||||
<div className="test-stat-label">失败步骤</div>
|
||||
</div>
|
||||
<div className="test-stat">
|
||||
<div className="test-stat-value">{run.totalMs}</div>
|
||||
<div className="test-stat-label">总耗时 (ms)</div>
|
||||
</div>
|
||||
<div className="test-stat">
|
||||
<div className="test-stat-value">{assertCount}</div>
|
||||
<div className="test-stat-label">断言数</div>
|
||||
</div>
|
||||
<div className="test-stat">
|
||||
<div className="test-stat-value" style={{ fontSize: 16 }}>
|
||||
{run.stepResults.length > 0 ? `${Math.round((stats.ok / run.stepResults.length) * 100)}%` : "—"}
|
||||
</div>
|
||||
<div className="test-stat-label">通过率</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 过滤 */}
|
||||
<div className="mb-2 flex gap-1.5">
|
||||
{([["all", `全部 ${run.stepResults.length}`], ["pass", `通过 ${stats.ok}`], ["fail", `失败 ${stats.fail}`]] as const).map(
|
||||
([k, label]) => (
|
||||
<button key={k} className={cx("ops-chip", filter === k && "ops-chip-active")} onClick={() => setFilter(k as never)}>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 步骤明细 */}
|
||||
<div className="space-y-2">
|
||||
{shown.map((s) => (
|
||||
<div key={s.stepId} className="test-step-result">
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-2">
|
||||
<Icon name={s.ok ? "circle-check" : "circle-x"} size={15} className={s.ok ? "ops-success" : "ops-danger"} />
|
||||
<span className="text-[13px] font-medium">{s.name}</span>
|
||||
{s.method && s.url !== "—" && (
|
||||
<span className="ops-faint truncate font-mono text-[11px]">
|
||||
{s.method} {s.url}
|
||||
</span>
|
||||
)}
|
||||
{s.httpStatus !== null && (
|
||||
<span className={cx("text-[11px] font-bold", s.httpStatus < 400 ? "ops-success" : "ops-danger")}>{s.httpStatus}</span>
|
||||
)}
|
||||
{s.durationMs !== null && (
|
||||
<span className="ops-faint ml-auto flex items-center gap-1 text-[11px]">
|
||||
<Icon name="timer" size={11} /> {s.durationMs} ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{s.error && <div className="ops-danger px-3 pb-2 text-[11px]">{s.error}</div>}
|
||||
{s.assertionResults.length > 0 && (
|
||||
<div className="px-3 pb-2">
|
||||
<table className="test-assert-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>断言</th>
|
||||
<th>期望</th>
|
||||
<th>实际</th>
|
||||
<th>结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{s.assertionResults.map((a, j) => (
|
||||
<tr key={j}>
|
||||
<td>{a.label}</td>
|
||||
<td className="font-mono">{a.expected || "—"}</td>
|
||||
<td className="font-mono">{a.actual || "—"}</td>
|
||||
<td className={a.passed ? "ops-success" : "ops-danger"}>{a.passed ? "通过" : "失败"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
/**
|
||||
* Mock 本机联调弹窗:发布到本机 Mock 服务(/mock/{token}{路径})。
|
||||
* 默认不绑云端账号;响应体支持 @表达式按次随机生成。
|
||||
*/
|
||||
import { smartFillJson } from "@opsxapi/core";
|
||||
import type { HttpApiNode, MockSettings } from "@opsxapi/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { publishLocalMock, publishMock, revokeLocalMock, revokeMock } from "../../account";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { Modal, cx } from "../common";
|
||||
import { Icon } from "../icons";
|
||||
|
||||
const LOCAL_TOKEN_KEY = "opsxapi.localMockToken.v1";
|
||||
|
||||
function loadLocalToken(): string {
|
||||
try {
|
||||
return localStorage.getItem(LOCAL_TOKEN_KEY) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function saveLocalToken(token: string) {
|
||||
try {
|
||||
if (!token) localStorage.removeItem(LOCAL_TOKEN_KEY);
|
||||
else localStorage.setItem(LOCAL_TOKEN_KEY, token);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
||||
const account = useWorkspace((s) => s.account);
|
||||
const settings = useWorkspace((s) => s.settings);
|
||||
const collections = useWorkspace((s) => s.collections);
|
||||
const setDialog = useWorkspace((s) => s.setDialog);
|
||||
|
||||
const space = account.spaces.find((sp) => sp.id === account.activeSpaceId) ?? null;
|
||||
const useCloud = !!(account.token && space);
|
||||
const [localToken, setLocalToken] = useState(loadLocalToken);
|
||||
const [status, setStatus] = useState(200);
|
||||
const [delayMs, setDelayMs] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [ttlSeconds, setTtlSeconds] = useState(0);
|
||||
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
const urlPath = extractPath(node.data.url);
|
||||
const method = node.data.method;
|
||||
|
||||
let template = "{}";
|
||||
if (node.data.bodyMode === "json" && node.data.bodyRaw.trim()) {
|
||||
template = node.data.bodyRaw;
|
||||
if (settings.mock.enabled && settings.mock.smart) {
|
||||
const filled = smartFillJson(template, settings.mock);
|
||||
if (filled.filled > 0) template = filled.text;
|
||||
}
|
||||
}
|
||||
|
||||
const mockSeg = useCloud
|
||||
? space?.mockToken ?? (space ? String(space.id) : "")
|
||||
: localToken;
|
||||
const mockUrl = mockSeg ? `${location.origin.replace(/:\d+$/, ":8787")}/mock/${mockSeg}${urlPath}` : "";
|
||||
|
||||
useEffect(() => {
|
||||
setLocalToken(loadLocalToken());
|
||||
}, []);
|
||||
|
||||
const publish = async () => {
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const endpoints = collectEndpoints(collections, settings);
|
||||
const mine = endpoints.find((e) => e.method === method && e.path === urlPath);
|
||||
if (mine) {
|
||||
mine.status = status;
|
||||
mine.delayMs = delayMs;
|
||||
}
|
||||
if (useCloud && account.token && space) {
|
||||
await publishMock(account.token, space.id, endpoints, ttlSeconds);
|
||||
setResult({ ok: true, message: `已发布 ${endpoints.length} 个接口到本机联调(云端空间)` });
|
||||
} else {
|
||||
const r = await publishLocalMock(endpoints, ttlSeconds);
|
||||
saveLocalToken(r.token);
|
||||
setLocalToken(r.token);
|
||||
setResult({ ok: true, message: `已发布 ${endpoints.length} 个接口到本机联调(本地空间)` });
|
||||
}
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const preview = async () => {
|
||||
if (!mockUrl) {
|
||||
setResult({ ok: false, message: "请先发布,生成本机联调地址" });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch(mockUrl, { method: "GET" });
|
||||
const text = await res.text();
|
||||
setResult({ ok: res.ok, message: `HTTP ${res.status} · ${text.slice(0, 400)}` });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async () => {
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
try {
|
||||
if (useCloud && account.token && space) {
|
||||
await revokeMock(account.token, space.id);
|
||||
} else {
|
||||
await revokeLocalMock();
|
||||
saveLocalToken("");
|
||||
setLocalToken("");
|
||||
}
|
||||
setResult({ ok: true, message: "已作废:旧链接立即失效,需重新发布才可用" });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="本机联调地址"
|
||||
onClose={() => setDialog(null)}
|
||||
width="620px"
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="ops-faint text-[11px]">仅本机 · 限流 20 QPS / 60 QPM(超限 429)</span>
|
||||
<div className="flex gap-2">
|
||||
<button className="ops-btn ops-btn-ghost ops-danger" onClick={revoke} disabled={busy || !mockUrl} title="作废当前 token,旧链接立刻 4xx">
|
||||
下线 / 作废
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-secondary" onClick={preview} disabled={busy || !mockUrl}>
|
||||
<Icon name="play" size={13} /> 试一试
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-primary" onClick={publish} disabled={busy}>
|
||||
<Icon name="upload" size={13} /> {busy ? "发布中…" : "发布 / 更新"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3.5">
|
||||
<div className="ops-hint">
|
||||
<Icon name="info" size={14} />
|
||||
<span>
|
||||
Mock 默认只在本机 <code>127.0.0.1:8787</code> 提供联调,不上官网/文档站。
|
||||
{useCloud ? "当前走云端空间隔离。" : "当前本地空间,无需登录即可发布。"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ops-label">本机联调地址</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="ops-input flex-1 font-mono !text-[12px]"
|
||||
readOnly
|
||||
value={mockUrl || "发布后显示本机地址"}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button
|
||||
className="ops-btn ops-btn-secondary"
|
||||
disabled={!mockUrl}
|
||||
onClick={() => {
|
||||
if (!mockUrl) return;
|
||||
void navigator.clipboard.writeText(mockUrl);
|
||||
setResult({ ok: true, message: "已复制本机联调地址" });
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="ops-faint mt-1 text-[11px]">
|
||||
{method} {urlPath} · 非方法可用 <code>?_method={method}</code> 测试 · 发布包含当前集合全部 HTTP 接口
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="ops-label">有效期(可选 TTL)</span>
|
||||
<select className="ops-input w-full" value={ttlSeconds} onChange={(e) => setTtlSeconds(Number(e.target.value) || 0)}>
|
||||
<option value={0}>不过期</option>
|
||||
<option value={3600}>1 小时</option>
|
||||
<option value={21600}>6 小时</option>
|
||||
<option value={86400}>24 小时</option>
|
||||
<option value={604800}>7 天</option>
|
||||
</select>
|
||||
<div className="ops-faint text-[11px]">到期后旧链接自动失效;作废可立刻让旧 token 4xx。</div>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block space-y-1">
|
||||
<span className="ops-label">状态码</span>
|
||||
<input
|
||||
className="ops-input w-full"
|
||||
type="number"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(Number(e.target.value) || 200)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="ops-label">延迟 ms</span>
|
||||
<input
|
||||
className="ops-input w-full"
|
||||
type="number"
|
||||
value={delayMs}
|
||||
onChange={(e) => setDelayMs(Number(e.target.value) || 0)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="ops-label mb-1">响应模板预览</div>
|
||||
<pre className={cx("ops-code max-h-40 overflow-auto text-[11px]")}>{template.slice(0, 2000)}</pre>
|
||||
<div className="ops-faint mt-1 text-[11px]">
|
||||
模板取自当前请求的 JSON Body(开启智能 Mock 时自动烘焙 @ 表达式);改 Body 后重新发布即可生效。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className={cx("rounded border px-2.5 py-1.5 text-[12px]", result.ok ? "ops-hint" : "")} style={!result.ok ? { color: "var(--ops-danger)" } : undefined}>
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function extractPath(url: string): string {
|
||||
try {
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
return new URL(url).pathname || "/";
|
||||
}
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
const q = url.indexOf("?");
|
||||
const path = (q >= 0 ? url.slice(0, q) : url).trim() || "/";
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
}
|
||||
|
||||
function collectEndpoints(
|
||||
collections: { children: unknown[] }[],
|
||||
settings: { mock: MockSettings },
|
||||
): { method: string; path: string; status: number; contentType: string; body: string; delayMs: number }[] {
|
||||
const out: { method: string; path: string; status: number; contentType: string; body: string; delayMs: number }[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
const walk = (nodes: unknown[]): void => {
|
||||
for (const n of nodes as {
|
||||
type?: string;
|
||||
kind?: string;
|
||||
children?: unknown[];
|
||||
data?: { url?: string; method?: string; bodyMode?: string; bodyRaw?: string };
|
||||
}[]) {
|
||||
if (n.type === "folder") {
|
||||
walk(n.children ?? []);
|
||||
} else if (n.type === "request" && n.kind === "http" && n.data) {
|
||||
let body = "{}";
|
||||
if (n.data.bodyMode === "json" && n.data.bodyRaw?.trim()) {
|
||||
body = n.data.bodyRaw;
|
||||
if (settings.mock.enabled && settings.mock.smart) {
|
||||
const filled = smartFillJson(body, settings.mock);
|
||||
if (filled.filled > 0) body = filled.text;
|
||||
}
|
||||
}
|
||||
const ep = {
|
||||
method: n.data.method ?? "GET",
|
||||
path: extractPath(n.data.url ?? ""),
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body,
|
||||
delayMs: 0,
|
||||
};
|
||||
const key = `${ep.method} ${ep.path}`;
|
||||
const prev = seen.get(key);
|
||||
if (prev !== undefined) out[prev] = ep;
|
||||
else {
|
||||
seen.set(key, out.length);
|
||||
out.push(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const c of collections) walk(c.children);
|
||||
return out;
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* HTTP / GraphQL 响应面板:Pretty / Raw / 响应头 + 状态条。
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { CopyButton, JsonView, cx, formatBytes } from "../common";
|
||||
import { ProxyErrorBanner } from "../ProxyErrorBanner";
|
||||
|
||||
export function ResponsePanel({ nodeId }: { nodeId: string }) {
|
||||
const runtime = useWorkspace((s) => s.runtime[nodeId]);
|
||||
const setRuntime = useWorkspace((s) => s.setRuntime);
|
||||
const tab = runtime?.responseTab ?? "body";
|
||||
const result = runtime?.result ?? null;
|
||||
const error = runtime?.error ?? null;
|
||||
const loading = runtime?.loading ?? false;
|
||||
|
||||
const contentType = useMemo(() => {
|
||||
const ct = result?.headers?.["content-type"] ?? "";
|
||||
return ct.toLowerCase();
|
||||
}, [result]);
|
||||
|
||||
const isJson = contentType.includes("json") || (!contentType && looksLikeJson(result?.body ?? ""));
|
||||
const prettyBody = useMemo(() => {
|
||||
if (!result?.body) return "";
|
||||
if (isJson) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(result.body), null, 2);
|
||||
} catch {
|
||||
return result.body;
|
||||
}
|
||||
}
|
||||
return result.body;
|
||||
}, [result, isJson]);
|
||||
|
||||
return (
|
||||
<section className="flex min-h-0 flex-1 flex-col" style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between px-3"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="flex">
|
||||
{(
|
||||
[
|
||||
["body", "响应体"],
|
||||
["raw", "Raw"],
|
||||
["headers", "响应头"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
className={cx("ops-tab", tab === key && "ops-tab-active")}
|
||||
onClick={() => setRuntime(nodeId, { responseTab: key })}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pr-1 text-xs">
|
||||
{result ? (
|
||||
<>
|
||||
<span className={cx(
|
||||
"ops-status-pill",
|
||||
result.status >= 500 ? "ops-status-5xx" : result.status >= 400 ? "ops-status-4xx" : result.status >= 300 ? "ops-status-3xx" : "ops-status-2xx",
|
||||
)}>
|
||||
{result.status}
|
||||
</span>
|
||||
<span className="ops-dim">
|
||||
耗时 <b>{result.timeMs} ms</b>
|
||||
</span>
|
||||
<span className="ops-dim">
|
||||
大小 <b>{formatBytes(result.size)}</b>
|
||||
</span>
|
||||
{contentType && (
|
||||
<span className="ops-faint hidden truncate lg:inline" style={{ maxWidth: 200 }}>
|
||||
{result.headers["content-type"]}
|
||||
</span>
|
||||
)}
|
||||
<CopyButton text={result.body} label="复制体" />
|
||||
</>
|
||||
) : (
|
||||
<span className="ops-faint">Status · Time · Size</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ops-scroll min-h-0 flex-1 p-3">
|
||||
{loading && <div className="ops-dim animate-pulse py-6 text-center text-sm">请求发送中…</div>}
|
||||
{!loading && error && (
|
||||
<ProxyErrorBanner
|
||||
message={error}
|
||||
onDismiss={() => setRuntime(nodeId, { error: null })}
|
||||
/>
|
||||
)}
|
||||
{!loading && !error && !result && (
|
||||
<div className="ops-faint grid h-full place-items-center text-sm">
|
||||
发送请求后这里显示响应
|
||||
</div>
|
||||
)}
|
||||
{!loading && result && tab === "body" && (
|
||||
<div className="ops-code h-full overflow-auto">
|
||||
{isJson ? <JsonView text={prettyBody} /> : <pre className="m-0 whitespace-pre-wrap">{result.body}</pre>}
|
||||
</div>
|
||||
)}
|
||||
{!loading && result && tab === "raw" && (
|
||||
<pre className="ops-code m-0 h-full overflow-auto whitespace-pre-wrap break-all">{result.body}</pre>
|
||||
)}
|
||||
{!loading && result && tab === "headers" && (
|
||||
<table className="ops-code w-full border-collapse text-left">
|
||||
<tbody>
|
||||
{Object.entries(result.headers).map(([k, v]) => (
|
||||
<tr key={k} className="align-top">
|
||||
<td className="ops-accent pr-4 whitespace-nowrap">{k}</td>
|
||||
<td className="break-all">{v}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeJson(s: string): boolean {
|
||||
const t = s.trimStart();
|
||||
return t.startsWith("{") || t.startsWith("[");
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# OpsxAPI launcher — scene profiles, not random process soup
|
||||
#
|
||||
# ./run.sh # = web
|
||||
# ./run.sh web # Gin 代理 + Vite(浏览器)
|
||||
# ./run.sh gui # Vite + 临时 .app 桌面(Dock 用当前 icon.icns)
|
||||
# ./run.sh all # Gin + Vite + Desktop
|
||||
# ./run.sh # = web:仅 Gin 官网 + docs(:8787,homepage/dist)
|
||||
# ./run.sh web # 同上(浏览器不当客户端)
|
||||
# ./run.sh gui # 桌面唯一操作入口:临时 .app(Dock 名 OpsxAPI)+ 默认 Gin
|
||||
# ./run.sh all # 同 gui
|
||||
# ./run.sh server|web-only|desktop # 单组件
|
||||
# ./run.sh stop|restart|status|help
|
||||
#
|
||||
# 设计:一份 Vite UI,Web/GUI 共用;Gin 对 Web 刚需,对 GUI 可选。
|
||||
# 产品形态:浏览器 = 官网/docs(homepage/);操作 = 桌面 Tauri(src/ + src-tauri/)。
|
||||
# Vite :5173 仅作桌面 WebView 源,不要用浏览器当工作台。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_NAME="OpsxAPI"
|
||||
SERVER_PORT="${OPSXAPI_SERVER_PORT:-8787}"
|
||||
WEB_PORT="${OPSXAPI_WEB_PORT:-5173}"
|
||||
SERVER_DIR="apps/server"
|
||||
SERVER_DIR="server"
|
||||
SERVER_BIN="opsxapi-server"
|
||||
SERVER_LABEL="com.opsx.api.server"
|
||||
WEB_LABEL="com.opsx.api.web"
|
||||
@@ -45,6 +46,35 @@ export PATH="/Users/logan/.nvm/versions/node/v24.19.0/bin:/opt/homebrew/bin:/usr
|
||||
|
||||
check_port() { lsof -ti:"$1" 2>/dev/null | head -1 || true; }
|
||||
|
||||
mock_api_ready() {
|
||||
# /health and the original Mock route exist on older builds; require the access-control shape.
|
||||
curl -fsS --max-time 2 "http://127.0.0.1:$SERVER_PORT/api/mock/local" 2>/dev/null \
|
||||
| grep -q '"bearerRequired"'
|
||||
}
|
||||
|
||||
stop_owned_server() {
|
||||
stop_detached "$SERVER_LABEL"
|
||||
local pid cwd command_line process_name
|
||||
pid="$(check_port "$SERVER_PORT")"
|
||||
[ -z "$pid" ] && return 0
|
||||
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -1)"
|
||||
command_line="$(ps -p "$pid" -o command= 2>/dev/null || true)"
|
||||
process_name="$(lsof -nP -a -p "$pid" -iTCP:"$SERVER_PORT" -sTCP:LISTEN -Fc 2>/dev/null | sed -n 's/^c//p' | head -1)"
|
||||
if [ "$process_name" = "opsxapi-server" ] && { [ "$cwd" = "$SCRIPT_DIR/server" ] || [ "$cwd" = "$SCRIPT_DIR/apps/server" ]; }; then
|
||||
info "停止本项目旧后端 (PID $pid)"
|
||||
kill "$pid" 2>/dev/null || { error "无法停止本项目后端 (PID $pid)"; return 1; }
|
||||
return 0
|
||||
fi
|
||||
case "$command_line:$cwd" in
|
||||
*"$SCRIPT_DIR/server/bin/opsxapi-server"*|*"$SCRIPT_DIR/apps/server/bin/opsxapi-server"*|*"opsxapi-server:$SCRIPT_DIR/server"|*"opsxapi-server:$SCRIPT_DIR/apps/server")
|
||||
info "停止本项目旧后端 (PID $pid)"
|
||||
kill "$pid" 2>/dev/null || { error "无法停止本项目后端 (PID $pid)"; return 1; }
|
||||
return 0 ;;
|
||||
esac
|
||||
error "端口 $SERVER_PORT 被其他进程占用 (PID $pid),未停止该进程"
|
||||
return 1
|
||||
}
|
||||
|
||||
kill_port() {
|
||||
local pids
|
||||
pids="$(lsof -ti:"$1" 2>/dev/null || true)"
|
||||
@@ -74,13 +104,9 @@ stop_detached() {
|
||||
}
|
||||
|
||||
ensure_npm() {
|
||||
if [ ! -d "$SCRIPT_DIR/node_modules" ]; then
|
||||
warning "缺少 node_modules,正在 npm install..."
|
||||
npm install
|
||||
fi
|
||||
if [ ! -d "$SCRIPT_DIR/apps/desktop/node_modules" ]; then
|
||||
warning "desktop 缺少依赖,正在安装..."
|
||||
npm --prefix "$SCRIPT_DIR/apps/desktop" install
|
||||
if [ ! -d "$SCRIPT_DIR/node_modules" ] || ! npm ls --depth=0 >/dev/null 2>&1; then
|
||||
warning "桌面依赖缺失或与 package.json 不一致,正在 npm install..."
|
||||
npm install || { error "桌面依赖安装失败,请检查 npm registry 与网络后运行 npm install"; return 1; }
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -103,13 +129,25 @@ wait_http() {
|
||||
}
|
||||
|
||||
start_server() {
|
||||
if curl -fsS "http://127.0.0.1:$SERVER_PORT/health" >/dev/null 2>&1; then
|
||||
if mock_api_ready; then
|
||||
success "后端已在运行 :$SERVER_PORT"
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$(check_port "$SERVER_PORT")" ]; then
|
||||
warning "现有后端缺少 Mock 管理接口,准备更新本项目服务"
|
||||
stop_owned_server
|
||||
local retries=0
|
||||
while [ -n "$(check_port "$SERVER_PORT")" ]; do
|
||||
retries=$((retries + 1))
|
||||
if [ "$retries" -ge 20 ]; then
|
||||
error "旧后端未能释放端口 ${SERVER_PORT},请检查占用进程"
|
||||
return 1
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
fi
|
||||
info "启动后端 :$SERVER_PORT ..."
|
||||
: >"$SERVER_LOG"
|
||||
kill_port "$SERVER_PORT"
|
||||
build_server
|
||||
# 默认仅本机;对外需 OPSXAPI_BIND_PUBLIC=1(或显式 OPSXAPI_ADDR)
|
||||
local bind_env=""
|
||||
@@ -121,41 +159,44 @@ start_server() {
|
||||
bind_env="OPSXAPI_ADDR=127.0.0.1:$SERVER_PORT"
|
||||
fi
|
||||
start_detached "$SERVER_LABEL" "$SERVER_LOG" \
|
||||
"export PATH=\"/opt/homebrew/bin:/usr/local/bin:\$PATH\"; cd \"$SCRIPT_DIR/$SERVER_DIR\" && exec env $bind_env GIN_MODE=debug \"./bin/$SERVER_BIN\""
|
||||
"export PATH=\"/opt/homebrew/bin:/usr/local/bin:\$PATH\"; cd \"$SCRIPT_DIR/$SERVER_DIR\" && exec env $bind_env OPSXAPI_MOCK_ADDR=\"${OPSXAPI_MOCK_ADDR:-0.0.0.0:8788}\" OPSXAPI_MOCK_ALLOW_PUBLIC=\"${OPSXAPI_MOCK_ALLOW_PUBLIC:-0}\" GIN_MODE=debug \"./bin/$SERVER_BIN\""
|
||||
wait_http "http://127.0.0.1:$SERVER_PORT/health" "后端"
|
||||
if ! mock_api_ready; then
|
||||
error "后端已启动,但 Mock 管理接口仍不可用,请检查 $SERVER_LOG"
|
||||
return 1
|
||||
fi
|
||||
success "后端 UP http://127.0.0.1:$SERVER_PORT/health"
|
||||
}
|
||||
|
||||
stop_server() {
|
||||
stop_detached "$SERVER_LABEL"
|
||||
kill_port "$SERVER_PORT"
|
||||
stop_owned_server || return 1
|
||||
success "后端已停止"
|
||||
}
|
||||
|
||||
start_web() {
|
||||
start_vite_for_desktop() {
|
||||
ensure_npm
|
||||
if curl -fsS "http://127.0.0.1:$WEB_PORT/" >/dev/null 2>&1; then
|
||||
success "Web 已在运行 :$WEB_PORT"
|
||||
success "桌面 UI(Vite) 已在运行 :$WEB_PORT"
|
||||
return 0
|
||||
fi
|
||||
info "启动 Web UI :$WEB_PORT ..."
|
||||
info "启动桌面 UI 源 :${WEB_PORT}(仅本机给 Tauri,勿当浏览器客户端)..."
|
||||
: >"$WEB_LOG"
|
||||
ensure_npm
|
||||
kill_port "$WEB_PORT"
|
||||
start_detached "$WEB_LABEL" "$WEB_LOG" \
|
||||
"export PATH=\"/Users/logan/.nvm/versions/node/v24.19.0/bin:/opt/homebrew/bin:/usr/local/bin:\$PATH\"; cd \"$SCRIPT_DIR\" && exec npm run dev -w @opsxapi/web"
|
||||
wait_http "http://127.0.0.1:$WEB_PORT/" "Web UI" 80
|
||||
success "Web UP http://127.0.0.1:$WEB_PORT"
|
||||
nohup /bin/bash -lc "export PATH=\"/Users/logan/.nvm/versions/node/v24.19.0/bin:/opt/homebrew/bin:/usr/local/bin:\$PATH\"; cd \"$SCRIPT_DIR\" && exec npm run dev" >>"$WEB_LOG" 2>&1 </dev/null &
|
||||
wait_http "http://127.0.0.1:$WEB_PORT/" "Vite" 80 || { tail -n 20 "$WEB_LOG"; return 1; }
|
||||
success "桌面 UI(Vite) UP http://127.0.0.1:${WEB_PORT}(请用 ./run.sh gui)"
|
||||
}
|
||||
|
||||
start_web() { start_vite_for_desktop; }
|
||||
|
||||
stop_web() {
|
||||
stop_detached "$WEB_LABEL"
|
||||
kill_port "$WEB_PORT"
|
||||
success "Web 已停止"
|
||||
success "Vite 已停止"
|
||||
}
|
||||
|
||||
start_desktop() {
|
||||
# Vite 照常(浏览器联调);Dock 桌面走临时 .app(每次刷当前 icons/icon.icns)
|
||||
start_web
|
||||
start_vite_for_desktop
|
||||
info "启动桌面 OpsxAPI(临时 .app + 当前图标)..."
|
||||
: >"$DESKTOP_LOG"
|
||||
ensure_npm
|
||||
@@ -168,7 +209,7 @@ start_desktop() {
|
||||
|
||||
local i=0
|
||||
while true; do
|
||||
if pgrep -x OpsxAPI >/dev/null 2>&1; then
|
||||
if gui_is_running; then
|
||||
success "桌面 OpsxAPI 已启动(临时 .app,Dock 用当前 icon.icns)"
|
||||
info "路径: $GUI_APP"
|
||||
return 0
|
||||
@@ -196,11 +237,15 @@ status() {
|
||||
warning "server :$SERVER_PORT DOWN"
|
||||
fi
|
||||
if curl -fsS "http://127.0.0.1:$WEB_PORT/" >/dev/null 2>&1; then
|
||||
success "web :$WEB_PORT UP http://127.0.0.1:$WEB_PORT"
|
||||
success "vite :$WEB_PORT UP(桌面源,非浏览器产品)"
|
||||
else
|
||||
warning "web :$WEB_PORT DOWN"
|
||||
warning "vite :$WEB_PORT DOWN"
|
||||
fi
|
||||
if pgrep -x OpsxAPI >/dev/null 2>&1; then
|
||||
if curl -fsS "http://127.0.0.1:$SERVER_PORT/" >/dev/null 2>&1 \
|
||||
|| curl -fsS "http://127.0.0.1:$SERVER_PORT/docs" >/dev/null 2>&1; then
|
||||
success "site :$SERVER_PORT 官网/docs 可达"
|
||||
fi
|
||||
if gui_is_running; then
|
||||
success "gui OpsxAPI UP"
|
||||
else
|
||||
warning "gui OpsxAPI DOWN"
|
||||
@@ -212,12 +257,12 @@ usage() {
|
||||
$PROJECT_NAME 启动脚本(场景 profile)
|
||||
|
||||
./run.sh # 同 web
|
||||
./run.sh web # 浏览器:Gin + Vite
|
||||
./run.sh gui # 桌面:Vite + 临时 .app(Dock=icons/icon.icns)
|
||||
./run.sh all # Gin + Vite + Desktop
|
||||
./run.sh web # 仅 Gin:官网 + docs → http://127.0.0.1:$SERVER_PORT/
|
||||
./run.sh gui # 桌面:临时 .app(Dock=OpsxAPI / icon.icns;默认也起 Gin)
|
||||
./run.sh all # 同 gui
|
||||
|
||||
./run.sh server # 只 Gin
|
||||
./run.sh web-only # 只 Vite
|
||||
./run.sh server # 只 Gin(官网/docs + 代理 API)
|
||||
./run.sh web-only # 只 Vite(桌面源,不是浏览器工作台)
|
||||
./run.sh desktop # 只 Desktop(会确保 Vite)
|
||||
|
||||
./run.sh stop | restart | status | help
|
||||
@@ -229,9 +274,10 @@ USAGE
|
||||
|
||||
start_web_scene() {
|
||||
start_server
|
||||
start_web
|
||||
stop_web || true
|
||||
echo
|
||||
success "Web 场景就绪 → http://127.0.0.1:$WEB_PORT"
|
||||
success "官网/文档就绪 → http://127.0.0.1:$SERVER_PORT/ · docs → http://127.0.0.1:$SERVER_PORT/docs"
|
||||
info "操作请用 ./run.sh gui(桌面)。:$WEB_PORT 不是产品入口。"
|
||||
}
|
||||
|
||||
start_gui_scene() {
|
||||
@@ -246,17 +292,13 @@ start_gui_scene() {
|
||||
}
|
||||
|
||||
start_all_scene() {
|
||||
start_server
|
||||
start_web
|
||||
start_desktop
|
||||
echo
|
||||
success "All 就绪:Web + Desktop + Gin"
|
||||
start_gui_scene
|
||||
}
|
||||
|
||||
stop_all() {
|
||||
stop_desktop || true
|
||||
stop_web || true
|
||||
stop_server || true
|
||||
stop_server
|
||||
}
|
||||
|
||||
cmd="${1:-web}"
|
||||
@@ -265,7 +307,10 @@ case "$cmd" in
|
||||
gui|desktop-scene) start_gui_scene ;;
|
||||
all) start_all_scene ;;
|
||||
server) start_server ;;
|
||||
web-only) start_web ;;
|
||||
web-only|vite)
|
||||
warning "Vite 仅桌面源,不是浏览器工作台;请用: ./run.sh gui"
|
||||
start_vite_for_desktop
|
||||
;;
|
||||
desktop|gui-only) start_desktop ;;
|
||||
stop) stop_all ;;
|
||||
restart)
|
||||
|
||||
@@ -4,19 +4,8 @@
|
||||
# .noindex:避免 Spotlight 把临时 Dock 壳当成可打开的历史 App
|
||||
GUI_APP_DIR="$SCRIPT_DIR/src-tauri/target/gui-app.noindex"
|
||||
GUI_APP="$GUI_APP_DIR/OpsxAPI.app"
|
||||
# 源码在 apps/desktop/src-tauri;编译产物可能在根 src-tauri/target(历史路径)
|
||||
ICONS_DIR="$SCRIPT_DIR/apps/desktop/src-tauri/icons"
|
||||
[ -f "$ICONS_DIR/icon.icns" ] || ICONS_DIR="$SCRIPT_DIR/src-tauri/icons"
|
||||
REL_APP="$SCRIPT_DIR/apps/desktop/src-tauri/target/release/bundle/macos/OpsxAPI.app"
|
||||
[ -d "$REL_APP" ] || REL_APP="$SCRIPT_DIR/src-tauri/target/release/bundle/macos/OpsxAPI.app"
|
||||
DBG_APP="$SCRIPT_DIR/apps/desktop/src-tauri/target/debug/bundle/macos/OpsxAPI.app"
|
||||
[ -d "$DBG_APP" ] || DBG_APP="$SCRIPT_DIR/src-tauri/target/debug/bundle/macos/OpsxAPI.app"
|
||||
REL_BIN="$SCRIPT_DIR/apps/desktop/src-tauri/target/release/OpsxAPI"
|
||||
[ -x "$REL_BIN" ] || REL_BIN="$SCRIPT_DIR/src-tauri/target/release/OpsxAPI"
|
||||
DBG_BIN_A="$SCRIPT_DIR/apps/desktop/src-tauri/target/debug/OpsxAPI"
|
||||
[ -x "$DBG_BIN_A" ] || DBG_BIN_A="$SCRIPT_DIR/src-tauri/target/debug/OpsxAPI"
|
||||
DBG_BIN_B="$SCRIPT_DIR/apps/desktop/src-tauri/target/debug/opsxapi"
|
||||
[ -x "$DBG_BIN_B" ] || DBG_BIN_B="$SCRIPT_DIR/src-tauri/target/debug/opsxapi"
|
||||
ICONS_DIR="$SCRIPT_DIR/src-tauri/icons"
|
||||
DBG_BIN_A="$SCRIPT_DIR/src-tauri/target/debug/OpsxAPI"
|
||||
GUI_DEV_NEEDS_VITE=0
|
||||
|
||||
_gui_write_plist() {
|
||||
@@ -55,28 +44,10 @@ ensure_gui_dev_app() {
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$GUI_APP_DIR"
|
||||
if [ -d "$DBG_APP" ]; then
|
||||
rm -rf "$GUI_APP"
|
||||
cp -R "$DBG_APP" "$GUI_APP"
|
||||
GUI_DEV_NEEDS_VITE=1
|
||||
elif [ -x "$DBG_BIN_A" ]; then
|
||||
rm -rf "$GUI_APP"
|
||||
_gui_wrap_bin "$DBG_BIN_A"
|
||||
GUI_DEV_NEEDS_VITE=1
|
||||
elif [ -x "$DBG_BIN_B" ]; then
|
||||
rm -rf "$GUI_APP"
|
||||
_gui_wrap_bin "$DBG_BIN_B"
|
||||
GUI_DEV_NEEDS_VITE=1
|
||||
elif [ -d "$REL_APP" ]; then
|
||||
rm -rf "$GUI_APP"
|
||||
cp -R "$REL_APP" "$GUI_APP"
|
||||
elif [ -x "$REL_BIN" ]; then
|
||||
rm -rf "$GUI_APP"
|
||||
_gui_wrap_bin "$REL_BIN"
|
||||
else
|
||||
error "没有可包装的桌面二进制。请先 ./run.sh gui 走一次 tauri 编译,或 npm run tauri -- build --no-bundle。"
|
||||
return 1
|
||||
fi
|
||||
info "编译当前桌面源码..."
|
||||
(cd "$SCRIPT_DIR" && cargo build --manifest-path src-tauri/Cargo.toml --bin OpsxAPI) || return 1
|
||||
_gui_wrap_bin "$DBG_BIN_A"
|
||||
GUI_DEV_NEEDS_VITE=1
|
||||
mkdir -p "$GUI_APP/Contents/Resources"
|
||||
cp "$icon" "$GUI_APP/Contents/Resources/icon.icns"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName OpsxAPI" "$GUI_APP/Contents/Info.plist" 2>/dev/null || true
|
||||
@@ -94,14 +65,11 @@ ensure_gui_dev_app() {
|
||||
kill_opsxapi_gui() {
|
||||
stop_detached "$DESKTOP_LABEL"
|
||||
pkill -f "$SCRIPT_DIR/src-tauri/target/debug/OpsxAPI" 2>/dev/null || true
|
||||
pkill -f "$SCRIPT_DIR/apps/desktop/src-tauri/target/debug/OpsxAPI" 2>/dev/null || true
|
||||
pkill -f "$SCRIPT_DIR/node_modules/.bin/tauri" 2>/dev/null || true
|
||||
pkill -f "$SCRIPT_DIR/src-tauri/target/gui-app/OpsxAPI.app" 2>/dev/null || true
|
||||
pkill -f "$GUI_APP" 2>/dev/null || true
|
||||
pkill -x OpsxAPI 2>/dev/null || true
|
||||
}
|
||||
|
||||
gui_is_running() {
|
||||
pgrep -f "$GUI_APP/Contents/MacOS/" >/dev/null 2>&1 \
|
||||
|| pgrep -x OpsxAPI >/dev/null 2>&1
|
||||
pgrep -f "$GUI_APP/Contents/MacOS/" >/dev/null 2>&1
|
||||
}
|
||||
@@ -10,6 +10,17 @@ import (
|
||||
|
||||
func main() {
|
||||
addr := resolveListenAddr()
|
||||
mockAddr := strings.TrimSpace(os.Getenv("OPSXAPI_MOCK_ADDR"))
|
||||
if mockAddr == "" {
|
||||
mockAddr = "0.0.0.0:8788"
|
||||
}
|
||||
mockRouter := server.NewMockRouter()
|
||||
go func() {
|
||||
log.Printf("OpsxAPI private-network Mock listening on %s", mockAddr)
|
||||
if err := mockRouter.Run(mockAddr); err != nil {
|
||||
log.Fatalf("Mock listener failed: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Printf("OpsxAPI server listening on %s", addr)
|
||||
if err := server.NewRouter().Run(addr); err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -4,6 +4,7 @@ package api
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -44,6 +45,12 @@ func (a *API) Register(r *gin.Engine) {
|
||||
g.GET("/workspaces/:id", a.auth(a.handleGetWorkspace))
|
||||
g.PUT("/workspaces/:id", a.auth(a.handlePutWorkspace))
|
||||
g.PUT("/workspaces/:id/mock", a.auth(a.handlePublishMock))
|
||||
g.GET("/mock/local", a.handleLocalMockStatus)
|
||||
g.GET("/docs/local", a.localDocs)
|
||||
g.PUT("/docs/local", a.publishLocalDocs)
|
||||
g.DELETE("/docs/local/:projectId", a.revokeLocalDocs)
|
||||
g.PATCH("/mock/local", a.handleLocalMockToggle)
|
||||
g.PUT("/mock/local/access", a.handleLocalMockAccess)
|
||||
g.PUT("/mock/local", a.handlePublishLocalMock)
|
||||
g.DELETE("/mock/local", a.handleRevokeLocalMock)
|
||||
g.DELETE("/workspaces/:id/mock", a.auth(a.handleRevokeMock))
|
||||
@@ -422,6 +429,7 @@ func (a *API) handlePublishLocalMock(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "本机联调发布仅允许从 127.0.0.1 / ::1 调用"})
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5*1024*1024)
|
||||
var req struct {
|
||||
Endpoints []mocksrv.Endpoint `json:"endpoints" binding:"required"`
|
||||
TTLSeconds int64 `json:"ttlSeconds"`
|
||||
@@ -434,6 +442,14 @@ func (a *API) handlePublishLocalMock(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "单空间最多发布 200 个 Mock 接口"})
|
||||
return
|
||||
}
|
||||
for _, ep := range req.Endpoints {
|
||||
method := strings.ToUpper(ep.Method)
|
||||
allowed := method == "GET" || method == "POST" || method == "PUT" || method == "PATCH" || method == "DELETE" || method == "HEAD" || method == "OPTIONS"
|
||||
if !allowed || !strings.HasPrefix(ep.Path, "/") || strings.ContainsAny(ep.Path, "?#\r\n") || ep.Status < 200 || ep.Status > 599 || ep.DelayMs < 0 || ep.DelayMs > 30000 || strings.ContainsAny(ep.ContentType, "\r\n") {
|
||||
c.JSON(400, gin.H{"error": "Mock 接口参数无效:检查方法、路径、状态码(200~599)和延迟(0~30000ms)"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.TTLSeconds < 0 || req.TTLSeconds > 30*24*3600 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ttlSeconds 需在 0~2592000(30 天)之间"})
|
||||
return
|
||||
@@ -476,14 +492,98 @@ func (a *API) handleRevokeLocalMock(c *gin.Context) {
|
||||
}
|
||||
|
||||
func isLoopbackRequest(c *gin.Context) bool {
|
||||
ip := net.ParseIP(c.ClientIP())
|
||||
if ip != nil && ip.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
host, _, err := net.SplitHostPort(c.Request.RemoteAddr)
|
||||
if err != nil {
|
||||
host = c.Request.RemoteAddr
|
||||
}
|
||||
ip = net.ParseIP(host)
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func (a *API) handleLocalMockStatus(c *gin.Context) {
|
||||
if !isLoopbackRequest(c) {
|
||||
c.JSON(403, gin.H{"error": "仅允许本机管理 Mock"})
|
||||
return
|
||||
}
|
||||
endpoints, token, expires, err := a.store.LocalMockSnapshot()
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "读取本机 Mock 失败"})
|
||||
return
|
||||
}
|
||||
addr := os.Getenv("OPSXAPI_MOCK_ADDR")
|
||||
if addr == "" {
|
||||
addr = "0.0.0.0:8788"
|
||||
}
|
||||
bindHost, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Mock 监听地址配置无效"})
|
||||
return
|
||||
}
|
||||
addresses := []string{"http://127.0.0.1:" + port}
|
||||
if list, err := net.InterfaceAddrs(); err == nil && (bindHost == "" || bindHost == "0.0.0.0" || bindHost == "::") {
|
||||
for _, item := range list {
|
||||
if ipnet, ok := item.(*net.IPNet); ok && ipnet.IP.To4() != nil && ipnet.IP.IsPrivate() {
|
||||
addresses = append(addresses, "http://"+net.JoinHostPort(ipnet.IP.String(), port))
|
||||
}
|
||||
}
|
||||
}
|
||||
if ip := net.ParseIP(bindHost); ip != nil && ip.IsPrivate() {
|
||||
addresses = append(addresses, "http://"+net.JoinHostPort(bindHost, port))
|
||||
}
|
||||
enabled, err := a.store.MockEnabled()
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "读取 Mock 服务状态失败"})
|
||||
return
|
||||
}
|
||||
bearerRequired, bearerConfigured, err := a.store.MockAccess()
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "读取 Mock 访问控制失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"enabled": enabled, "bearerRequired": bearerRequired, "bearerConfigured": bearerConfigured, "endpoints": endpoints, "token": token, "expiresAt": expires, "active": len(endpoints) > 0 && (expires == 0 || time.Now().Unix() < expires), "allowPublic": os.Getenv("OPSXAPI_MOCK_ALLOW_PUBLIC") == "1", "listenAddr": addr, "addresses": addresses})
|
||||
}
|
||||
|
||||
func (a *API) handleLocalMockAccess(c *gin.Context) {
|
||||
if !isLoopbackRequest(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "仅允许本机配置 Mock 访问控制"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil || req.Enabled == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请指定是否启用 Bearer 验证"})
|
||||
return
|
||||
}
|
||||
if *req.Enabled {
|
||||
if len(req.Token) < 16 || len(req.Token) > 256 || strings.TrimSpace(req.Token) != req.Token {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Bearer 密钥须为 16~256 字符且不能有首尾空格"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := a.store.SetMockAccess(*req.Enabled, req.Token); err != nil {
|
||||
c.JSON(500, gin.H{"error": "保存 Mock 访问控制失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) handleLocalMockToggle(c *gin.Context) {
|
||||
if !isLoopbackRequest(c) {
|
||||
c.JSON(403, gin.H{"error": "仅允许本机管理 Mock"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil || req.Enabled == nil {
|
||||
c.JSON(400, gin.H{"error": "缺少 enabled"})
|
||||
return
|
||||
}
|
||||
if err := a.store.SetMockEnabled(*req.Enabled); err != nil {
|
||||
c.JSON(500, gin.H{"error": "更新 Mock 服务状态失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"enabled": *req.Enabled})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (a *API) localDocs(c *gin.Context) {
|
||||
if !isLoopbackRequest(c) {
|
||||
c.JSON(403, gin.H{"error": "仅允许本机管理文档"})
|
||||
return
|
||||
}
|
||||
docs, err := a.store.PublishedDocs()
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "文档读取失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"projects": docs})
|
||||
}
|
||||
|
||||
func (a *API) publishLocalDocs(c *gin.Context) {
|
||||
if !isLoopbackRequest(c) {
|
||||
c.JSON(403, gin.H{"error": "仅允许本机发布文档"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
Name string `json:"name"`
|
||||
Endpoints []json.RawMessage `json:"endpoints"`
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(c.Request.Body, 2<<20+1))
|
||||
if err != nil || len(data) > 2<<20 || json.Unmarshal(data, &req) != nil || len(req.Endpoints) > 500 || len(req.Endpoints) == 0 || len(req.ProjectID) > 128 || len(req.Name) > 200 || strings.TrimSpace(req.ProjectID) == "" || strings.TrimSpace(req.Name) == "" {
|
||||
c.JSON(400, gin.H{"error": "文档无效或超过限制(2 MB / 500 个接口)"})
|
||||
return
|
||||
}
|
||||
for _, endpoint := range req.Endpoints {
|
||||
var item struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if json.Unmarshal(endpoint, &item) != nil || item.Name == "" || item.Method == "" || !strings.HasPrefix(item.Path, "/") {
|
||||
c.JSON(400, gin.H{"error": "接口方法、名称或路径无效"})
|
||||
return
|
||||
}
|
||||
}
|
||||
doc, err := a.store.PublishDoc(req.ProjectID, req.Name, string(data))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "文档发布失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, doc)
|
||||
}
|
||||
|
||||
func (a *API) revokeLocalDocs(c *gin.Context) {
|
||||
if !isLoopbackRequest(c) {
|
||||
c.JSON(403, gin.H{"error": "仅允许本机管理文档"})
|
||||
return
|
||||
}
|
||||
if err := a.store.RevokeDoc(c.Param("projectId")); err != nil {
|
||||
c.JSON(500, gin.H{"error": "撤销文档失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"opsxapi/server/internal/mocksrv"
|
||||
)
|
||||
|
||||
func TestLocalMockLifecycleAndPeerBoundary(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
a, err := New(filepath.Join(t.TempDir(), "mock.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Store().Close()
|
||||
r := gin.New()
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
a.Register(r)
|
||||
mocksrv.New(a.Store()).Register(r)
|
||||
callWithBearer := func(method, path, body, peer, bearer string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.RemoteAddr = peer
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
req.Header.Set("X-Forwarded-For", "127.0.0.1")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
call := func(method, path, body, peer string) *httptest.ResponseRecorder {
|
||||
return callWithBearer(method, path, body, peer, "")
|
||||
}
|
||||
if w := call("GET", "/api/mock/local", "", "192.168.1.5:3000"); w.Code != 403 {
|
||||
t.Fatalf("remote management: %d", w.Code)
|
||||
}
|
||||
payload := `{"endpoints":[{"method":"POST","path":"/demo","status":201,"contentType":"application/json","body":"{\"ok\":true}","delayMs":0}],"ttlSeconds":0}`
|
||||
if w := call("PUT", "/api/mock/local", payload, "127.0.0.1:3000"); w.Code != 200 {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
w := call("GET", "/api/mock/local", "", "127.0.0.1:3000")
|
||||
var status struct {
|
||||
Token string `json:"token"`
|
||||
Active bool `json:"active"`
|
||||
Endpoints []mocksrv.Endpoint `json:"endpoints"`
|
||||
}
|
||||
if err = json.Unmarshal(w.Body.Bytes(), &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !status.Active || len(status.Endpoints) != 1 {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
path := "/mock/" + status.Token + "/demo"
|
||||
if w = call("POST", path, "", "192.168.1.5:3000"); w.Code != 201 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if w = call("PUT", "/api/mock/local/access", `{"enabled":true,"token":"test-secret-1234567890"}`, "192.168.1.5:3000"); w.Code != 403 {
|
||||
t.Fatalf("remote access control: %d", w.Code)
|
||||
}
|
||||
if w = call("PUT", "/api/mock/local/access", `{"enabled":true,"token":"short"}`, "127.0.0.1:3000"); w.Code != 400 {
|
||||
t.Fatalf("short token accepted: %d", w.Code)
|
||||
}
|
||||
if w = call("PUT", "/api/mock/local/access", `{"enabled":true,"token":"test-secret-1234567890"}`, "127.0.0.1:3000"); w.Code != 200 {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
if w = call("GET", "/api/mock/local", "", "127.0.0.1:3000"); w.Code != 200 || !strings.Contains(w.Body.String(), `"bearerRequired":true`) || strings.Contains(w.Body.String(), "test-secret-1234567890") {
|
||||
t.Fatalf("status leaked secret or wrong access state: %s", w.Body.String())
|
||||
}
|
||||
if w = call("POST", path, "", "192.168.1.5:3000"); w.Code != 401 || w.Header().Get("WWW-Authenticate") == "" {
|
||||
t.Fatalf("missing bearer: %d", w.Code)
|
||||
}
|
||||
if w = callWithBearer("POST", path, "", "192.168.1.5:3000", "wrong-secret-1234567890"); w.Code != 401 {
|
||||
t.Fatalf("wrong bearer: %d", w.Code)
|
||||
}
|
||||
if w = callWithBearer("POST", path, "", "192.168.1.5:3000", "test-secret-1234567890"); w.Code != 201 {
|
||||
t.Fatalf("correct bearer: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w = call("PUT", "/api/mock/local/access", `{"enabled":true,"token":"replacement-1234567890"}`, "127.0.0.1:3000"); w.Code != 200 {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
if w = callWithBearer("POST", path, "", "192.168.1.5:3000", "test-secret-1234567890"); w.Code != 401 {
|
||||
t.Fatalf("old bearer still works after replacement: %d", w.Code)
|
||||
}
|
||||
if w = callWithBearer("POST", path, "", "192.168.1.5:3000", "replacement-1234567890"); w.Code != 201 {
|
||||
t.Fatalf("replacement bearer: %d", w.Code)
|
||||
}
|
||||
if w = call("PUT", "/api/mock/local/access", `{"enabled":false}`, "127.0.0.1:3000"); w.Code != 200 {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
if w = call("POST", path, "", "192.168.1.5:3000"); w.Code != 201 {
|
||||
t.Fatalf("disabled bearer still required: %d", w.Code)
|
||||
}
|
||||
if w = call("PATCH", "/api/mock/local", `{"enabled":false}`, "192.168.1.5:3000"); w.Code != 403 {
|
||||
t.Fatal("remote toggle allowed")
|
||||
}
|
||||
if w = call("PATCH", "/api/mock/local", `{"enabled":false}`, "127.0.0.1:3000"); w.Code != 200 {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
if w = call("POST", path, "", "192.168.1.5:3000"); w.Code != 503 {
|
||||
t.Fatal("disabled mock still responds", w.Code)
|
||||
}
|
||||
if w = call("PATCH", "/api/mock/local", `{"enabled":true}`, "127.0.0.1:3000"); w.Code != 200 {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
if w = call("POST", path, "", "192.168.1.5:3000"); w.Code != 201 {
|
||||
t.Fatal("toggle lost mock data", w.Code)
|
||||
}
|
||||
if w = call("DELETE", "/api/mock/local", "", "127.0.0.1:3000"); w.Code != 200 {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
if w = call("POST", path, "", "192.168.1.5:3000"); w.Code != 404 {
|
||||
t.Fatalf("revoked token: %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
# AI 助手
|
||||
|
||||
接入 **OpenAI 兼容协议**的大模型,为接口调试与自动化测试提供 AI 能力。密钥只保存在本机(localStorage),请求直连供应商,不经过任何第三方中转。
|
||||
接入 **OpenAI 兼容协议**的大模型,为自动化测试提供 AI 能力。模型调用直连供应商;供应商配置(包括密钥)随当前工作区保存,团队工作区会同步工作区数据。
|
||||
|
||||
## 配置模型供应商
|
||||
|
||||
**设置 → AI 助手 → 模型供应商**,点「添加供应商」,内置常用预设:
|
||||
**设置 → 大模型**,选择预设并点击「+」,在右侧填写模型 ID、密钥和 Base URL,测试连接后点击「保存配置」。内置常用预设:
|
||||
|
||||
| 供应商 | Base URL | 模型示例 |
|
||||
| --- | --- | --- |
|
||||
@@ -15,8 +15,8 @@
|
||||
| Ollama 本地 | `http://127.0.0.1:11434/v1` | `qwen2.5:7b` |
|
||||
|
||||
- **API Key**:Ollama 等本地服务可留空;
|
||||
- **测试连接**:保存前先发一条 ping 验证地址 / 密钥 / 模型名;
|
||||
- 多个供应商时可切换「当前使用」的模型。
|
||||
- **测试连接**:发一条 ping 验证地址 / 密钥 / 模型名;离线时也可先保存,之后再测试;
|
||||
- 多个供应商时可在左侧选择,保存后点「设为默认」指定自动化测试使用的模型。
|
||||
|
||||
## AI 生成场景用例
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
## 隐私与边界
|
||||
|
||||
- API Key 仅存本机,随工作区数据一起持久化;导出全量备份时会包含,分享备份文件前请注意;
|
||||
- API Key 随当前工作区数据保存;团队工作区会同步,导出全量备份时也会包含。分享工作区或备份文件前请检查密钥;
|
||||
- 供应商收到的内容为接口清单摘要(方法 / URL / Body 片段)与你的指令,**不含**环境变量值与 API Key;
|
||||
- 未配置供应商时,Mock 数据、环境变量等离线功能不受影响。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 账号与团队
|
||||
|
||||
登录后可使用**云端空间**:个人空间「我的空间」与团队共享空间,数据保存在本机服务的 SQLite 数据库(`apps/server/data/opsxapi.db`),不上传任何第三方。
|
||||
登录后可使用**云端空间**:个人空间「我的空间」与团队共享空间,数据保存在本机服务的 SQLite 数据库(`server/data/opsxapi.db`),不上传任何第三方。
|
||||
|
||||
## 注册 / 登录
|
||||
|
||||
@@ -10,27 +10,27 @@ import (
|
||||
|
||||
func TestGenerateFormats(t *testing.T) {
|
||||
cases := []struct {
|
||||
expr string
|
||||
re string
|
||||
expr string
|
||||
re string
|
||||
}{
|
||||
{"@integer(3, 7), extra", ""},
|
||||
}
|
||||
_ = cases
|
||||
checks := map[string]*regexp.Regexp{
|
||||
"@integer(3, 7)": regexp.MustCompile(`^[3-7]$`),
|
||||
"@float(1, 9, 2)": regexp.MustCompile(`^\d\.\d{2}$`),
|
||||
"@email": regexp.MustCompile(`^[\w.]+@[\w.]+\.\w+$`),
|
||||
"@phone": regexp.MustCompile(`^1[3-9]\d{9}$`),
|
||||
"@guid": regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`),
|
||||
"@id": regexp.MustCompile(`^\d{17}[\dX]$`),
|
||||
"@integer(3, 7)": regexp.MustCompile(`^[3-7]$`),
|
||||
"@float(1, 9, 2)": regexp.MustCompile(`^\d\.\d{2}$`),
|
||||
"@email": regexp.MustCompile(`^[\w.]+@[\w.]+\.\w+$`),
|
||||
"@phone": regexp.MustCompile(`^1[3-9]\d{9}$`),
|
||||
"@guid": regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`),
|
||||
"@id": regexp.MustCompile(`^\d{17}[\dX]$`),
|
||||
"@datetime(YYYY-MM-DD HH:mm:ss)": regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$`),
|
||||
"@timestamp": regexp.MustCompile(`^\d{12,}$`),
|
||||
"@boolean": regexp.MustCompile(`^(true|false)$`),
|
||||
"@cname": regexp.MustCompile(`^\p{Han}{2,3}$`),
|
||||
"@county(true)": regexp.MustCompile(`\p{Han}+(市)?\p{Han}+区$`),
|
||||
"@pick(\"男\", \"女\")": regexp.MustCompile(`^(男|女)$`),
|
||||
"@notExistThing": regexp.MustCompile(`^@notExistThing$`), // 未知表达式原样
|
||||
"普通文本": regexp.MustCompile(`^普通文本$`),
|
||||
"@timestamp": regexp.MustCompile(`^\d{12,}$`),
|
||||
"@boolean": regexp.MustCompile(`^(true|false)$`),
|
||||
"@cname": regexp.MustCompile(`^\p{Han}{2,3}$`),
|
||||
"@county(true)": regexp.MustCompile(`\p{Han}+(市)?\p{Han}+区$`),
|
||||
"@pick(\"男\", \"女\")": regexp.MustCompile(`^(男|女)$`),
|
||||
"@notExistThing": regexp.MustCompile(`^@notExistThing$`), // 未知表达式原样
|
||||
"普通文本": regexp.MustCompile(`^普通文本$`),
|
||||
}
|
||||
for expr, re := range checks {
|
||||
for i := 0; i < 30; i++ {
|
||||
@@ -135,3 +135,16 @@ func TestNormalizePath(t *testing.T) {
|
||||
}
|
||||
_ = strings.TrimSpace
|
||||
}
|
||||
|
||||
func TestResponseBodyFormats(t *testing.T) {
|
||||
for _, body := range []string{"@cname", `{"name":"@cname"}`, "plain text\n"} {
|
||||
if got := responseBody("text/plain", body); got != body {
|
||||
t.Fatalf("text changed: %q", got)
|
||||
}
|
||||
}
|
||||
var decoded map[string]int
|
||||
got := responseBody("application/json; charset=utf-8", `{"n":"@integer(1,1)"}`)
|
||||
if err := json.Unmarshal([]byte(got), &decoded); err != nil || decoded["n"] != 1 {
|
||||
t.Fatalf("JSON template not expanded: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ func newLimiter() *limiter { return &limiter{buckets: map[string]*bucket{}} }
|
||||
// allow 滑动窗口判定:最近 1 秒 ≤ 20 且最近 60 秒 ≤ 60。
|
||||
func (l *limiter) allow(key string, now time.Time) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
b, ok := l.buckets[key]
|
||||
if !ok {
|
||||
b = &bucket{}
|
||||
@@ -61,7 +62,6 @@ func (l *limiter) allow(key string, now time.Time) bool {
|
||||
l.buckets = map[string]*bucket{key: b}
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
|
||||
s := now.Unix()
|
||||
cur := &b.slots[s%60]
|
||||
@@ -100,23 +100,54 @@ func (s *Server) Register(r *gin.Engine) {
|
||||
|
||||
// handle:发布查询 → 限流 → 延迟 → 生成
|
||||
func (s *Server) handle(c *gin.Context) {
|
||||
if state, ok := s.store.(interface{ MockEnabled() (bool, error) }); ok {
|
||||
enabled, err := state.MockEnabled()
|
||||
if err != nil || !enabled {
|
||||
c.JSON(503, gin.H{"error": "Mock HTTP 响应已关闭"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if access, ok := s.store.(interface {
|
||||
MockAccess() (bool, bool, error)
|
||||
CheckMockBearer(string) (bool, error)
|
||||
}); ok {
|
||||
required, _, err := access.MockAccess()
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Mock 访问控制读取失败"})
|
||||
return
|
||||
}
|
||||
if required {
|
||||
authorization := c.GetHeader("Authorization")
|
||||
parts := strings.SplitN(authorization, " ", 2)
|
||||
valid := false
|
||||
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||
valid, err = access.CheckMockBearer(parts[1])
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Mock 访问控制读取失败"})
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
c.Header("WWW-Authenticate", `Bearer realm="OpsxAPI Mock"`)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "此 Mock 需要 Authorization: Bearer <密钥>"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
wsid := c.Param("wsid")
|
||||
path := normalizePath(c.Param("path"))
|
||||
method := strings.ToUpper(c.Query("_method"))
|
||||
if method == "" {
|
||||
method = strings.ToUpper(c.Request.Method)
|
||||
}
|
||||
if method == "HEAD" {
|
||||
method = "GET"
|
||||
}
|
||||
|
||||
key := clientIP(c) + "|" + wsid
|
||||
if !s.lim.allow(key, time.Now()) {
|
||||
c.Header("Retry-After", "60")
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "请求过于频繁,已限流(20 QPS / 60 QPM)",
|
||||
"terms": "/terms",
|
||||
"reason": "服务条款明确禁止滥用:通过技术手段规避限流属于违规行为",
|
||||
"error": "请求过于频繁,已限流(20 QPS / 60 QPM)",
|
||||
"terms": "/terms",
|
||||
"reason": "服务条款明确禁止滥用:通过技术手段规避限流属于违规行为",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -132,13 +163,16 @@ func (s *Server) handle(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
ep, found, err := s.store.FindMockEndpoint(wsID, method, path)
|
||||
if err == nil && !found && method == "HEAD" {
|
||||
ep, found, err = s.store.FindMockEndpoint(wsID, "GET", path)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "mock 存储读取失败"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Mock 接口不存在:" + method + " " + path + ",请先在客户端「Mock 链接」中发布",
|
||||
"error": "Mock 接口不存在:" + method + " " + path + ",请先在客户端「Mock」页面中发布",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -152,7 +186,7 @@ func (s *Server) handle(c *gin.Context) {
|
||||
if ct == "" {
|
||||
ct = "application/json"
|
||||
}
|
||||
c.Data(ep.statusOrOK(), ct, []byte(GenerateJSON(ep.Body)))
|
||||
c.Data(ep.statusOrOK(), ct, []byte(responseBody(ct, ep.Body)))
|
||||
}
|
||||
|
||||
func (e *Endpoint) statusOrOK() int {
|
||||
@@ -182,3 +216,11 @@ func clientIP(c *gin.Context) string {
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// Text responses remain literal; only JSON responses expand template expressions.
|
||||
func responseBody(contentType, body string) string {
|
||||
if strings.Contains(strings.ToLower(contentType), "json") {
|
||||
return GenerateJSON(body)
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMockListenerDoesNotExposeManagement(t *testing.T) {
|
||||
t.Setenv("OPSXAPI_DB", filepath.Join(t.TempDir(), "mock.db"))
|
||||
r := NewMockRouter()
|
||||
for _, tc := range []struct {
|
||||
path, peer string
|
||||
status int
|
||||
}{
|
||||
{"/api/mock/local", "192.168.1.5:3000", 404},
|
||||
{"/health", "192.168.1.5:3000", 200},
|
||||
{"/health", "8.8.8.8:3000", 403},
|
||||
{"/v1/proxy/http", "192.168.1.5:3000", 404},
|
||||
} {
|
||||
req := httptest.NewRequest("GET", tc.path, nil)
|
||||
req.RemoteAddr = tc.peer
|
||||
req.Header.Set("X-Forwarded-For", "127.0.0.1")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != tc.status {
|
||||
t.Fatalf("%s %s: %d", tc.path, tc.peer, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicMockStillIsolatesManagement(t *testing.T) {
|
||||
t.Setenv("OPSXAPI_DB", filepath.Join(t.TempDir(), "mock.db"))
|
||||
t.Setenv("OPSXAPI_MOCK_ALLOW_PUBLIC", "1")
|
||||
r := NewMockRouter()
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
code int
|
||||
}{{"/health", 200}, {"/api/mock/local", 404}, {"/v1/proxy/http", 404}} {
|
||||
req := httptest.NewRequest("GET", tc.path, nil)
|
||||
req.RemoteAddr = "8.8.8.8:3000"
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != tc.code {
|
||||
t.Fatalf("%s: %d", tc.path, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"opsxapi/server/internal/api"
|
||||
"opsxapi/server/internal/docs"
|
||||
"opsxapi/server/internal/echo"
|
||||
"opsxapi/server/internal/mocksrv"
|
||||
"opsxapi/server/internal/proxy"
|
||||
"opsxapi/server/internal/site"
|
||||
)
|
||||
|
||||
func NewRouter() *gin.Engine {
|
||||
r := gin.New()
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
r.Use(gin.Logger(), gin.Recovery(), localhostCORS())
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "service": "opsxapi"})
|
||||
})
|
||||
p := proxy.NewHandler()
|
||||
r.POST("/v1/proxy/http", p.ProxyHTTP)
|
||||
r.POST("/v1/proxy/http/stream", p.ProxyHTTPStream)
|
||||
|
||||
tunnel := proxy.NewWSTunnel()
|
||||
r.POST("/v1/proxy/ws/connect", tunnel.Connect)
|
||||
r.GET("/v1/proxy/ws/attach", tunnel.Attach)
|
||||
r.POST("/v1/proxy/ws/close", tunnel.Close)
|
||||
|
||||
// 账号 / 团队 / 云端空间(SQLite),store 同时供 Mock 服务复用
|
||||
accountAPI, err := api.New(os.Getenv("OPSXAPI_DB"))
|
||||
if err != nil {
|
||||
panic("init sqlite store: " + err.Error())
|
||||
}
|
||||
accountAPI.Register(r)
|
||||
|
||||
mocksrv.New(accountAPI.Store()).Register(r)
|
||||
|
||||
echo.Mount(r)
|
||||
if !mountHomepage(r) {
|
||||
docs.New().Register(r)
|
||||
site.New().Register(r)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// mountHomepage 本地预览 homepage/dist(落地页 + 文档)。不改 homepage/ 源。
|
||||
func mountHomepage(r *gin.Engine) bool {
|
||||
for _, dir := range []string{
|
||||
os.Getenv("OPSXAPI_HOMEPAGE"),
|
||||
"../../homepage/dist",
|
||||
"../homepage/dist",
|
||||
"homepage/dist",
|
||||
} {
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
if st, err := os.Stat(dir); err == nil && st.IsDir() {
|
||||
fs := http.FileServer(http.Dir(dir))
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
p := c.Request.URL.Path
|
||||
if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/v1/") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "接口不存在"})
|
||||
return
|
||||
}
|
||||
upath := strings.TrimPrefix(p, "/")
|
||||
if upath != "" {
|
||||
if st2, err2 := os.Stat(filepath.Join(dir, upath)); err2 == nil && st2.IsDir() {
|
||||
c.Request.URL.Path = p + "/"
|
||||
} else if !strings.Contains(filepath.Base(upath), ".") {
|
||||
if _, err3 := os.Stat(filepath.Join(dir, upath+".html")); err3 == nil {
|
||||
c.Request.URL.Path = p + ".html"
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func localhostCORS() gin.HandlerFunc {
|
||||
allowed := map[string]struct{}{
|
||||
"http://localhost:5173": {},
|
||||
"http://127.0.0.1:5173": {},
|
||||
"http://localhost:1420": {},
|
||||
"http://127.0.0.1:1420": {},
|
||||
"tauri://localhost": {},
|
||||
"http://tauri.localhost": {},
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if _, ok := allowed[strings.TrimSuffix(origin, "/")]; ok {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Vary", "Origin")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie, X-Opsx-Probe, X-Opsx-Cookie")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// NewMockRouter exposes only mock responses to private-network peers. No account,
|
||||
// publishing, workspace, or proxy routes are registered on this listener.
|
||||
func NewMockRouter() *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery(), func(c *gin.Context) {
|
||||
host, _, err := net.SplitHostPort(c.Request.RemoteAddr)
|
||||
ip := net.ParseIP(host)
|
||||
if err != nil || ip == nil || (os.Getenv("OPSXAPI_MOCK_ALLOW_PUBLIC") != "1" && !ip.IsPrivate() && !ip.IsLoopback()) {
|
||||
c.AbortWithStatus(403)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
a, err := api.New(os.Getenv("OPSXAPI_DB"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
mocksrv.New(a.Store()).Register(r)
|
||||
registerSharedDocs(r, a.Store())
|
||||
r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true, "service": "opsxapi-mock"}) })
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"opsxapi/server/internal/store"
|
||||
)
|
||||
|
||||
type sharedDocument struct {
|
||||
Name string `json:"name"`
|
||||
Endpoints []struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters []struct {
|
||||
Name string `json:"name"`
|
||||
In string `json:"in"`
|
||||
Required bool `json:"required"`
|
||||
Description string `json:"description"`
|
||||
Example string `json:"example"`
|
||||
} `json:"parameters"`
|
||||
RequestBody *struct {
|
||||
ContentType string `json:"contentType"`
|
||||
Example string `json:"example"`
|
||||
} `json:"requestBody"`
|
||||
Responses []struct {
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description"`
|
||||
ContentType string `json:"contentType"`
|
||||
Example string `json:"example"`
|
||||
} `json:"responses"`
|
||||
MockURL string `json:"mockUrl"`
|
||||
} `json:"endpoints"`
|
||||
}
|
||||
|
||||
var sharedDocsTemplate = template.Must(template.New("docs").Parse(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Name}} · API 文档</title><style>body{font:15px/1.6 system-ui,sans-serif;margin:0;background:#0d1524;color:#e6edf8}header{padding:20px 28px;border-bottom:1px solid #33445d}main{display:grid;grid-template-columns:minmax(190px,260px) minmax(0,1fr);max-width:1300px;margin:auto}nav{padding:20px;border-right:1px solid #33445d}nav a{display:block;color:#93c5fd;padding:5px 0;text-decoration:none;overflow-wrap:anywhere}article{padding:14px 28px;border-bottom:1px solid #33445d}code,pre{font:13px/1.5 ui-monospace,monospace;overflow-wrap:anywhere}pre{white-space:pre-wrap;background:#162238;border-radius:8px;padding:14px;overflow:auto}section{min-width:0}small,.muted{color:#aabbd3}h2{overflow-wrap:anywhere}table{border-collapse:collapse;width:100%}td,th{padding:8px;border-bottom:1px solid #33445d;text-align:left}a{color:#93c5fd}@media(max-width:680px){main{display:block}nav{border-right:0;border-bottom:1px solid #33445d}}</style></head><body><header><h1>{{.Name}}</h1><small>自托管 API 文档 · 只读快照</small></header><main><nav>{{range $i,$e:=.Endpoints}}<a href="#api-{{$i}}">{{$e.Method}} {{$e.Path}}</a>{{end}}</nav><section>{{range $i,$e:=.Endpoints}}<article id="api-{{$i}}"><h2>{{$e.Name}}</h2><p><code>{{$e.Method}} {{$e.Path}}</code></p><p class="muted">{{$e.Description}}</p>{{if $e.Parameters}}<h3>请求参数</h3><table><tr><th>参数</th><th>位置</th><th>必填</th><th>示例</th><th>说明</th></tr>{{range $e.Parameters}}<tr><td>{{.Name}}</td><td>{{.In}}</td><td>{{if .Required}}是{{else}}否{{end}}</td><td>{{.Example}}</td><td>{{.Description}}</td></tr>{{end}}</table>{{end}}{{with $e.RequestBody}}<h3>请求体 · {{.ContentType}}</h3><pre>{{.Example}}</pre>{{end}}{{range $e.Responses}}<h3>HTTP {{.Status}} · {{.ContentType}}</h3><p>{{.Description}}</p><pre>{{.Example}}</pre>{{end}}{{if $e.MockURL}}<h3>Mock 地址</h3><p><code>{{$e.MockURL}}</code></p>{{end}}</article>{{end}}</section></main></body></html>`))
|
||||
|
||||
func registerSharedDocs(r *gin.Engine, s *store.Store) {
|
||||
r.GET("/docs/:token", func(c *gin.Context) {
|
||||
record, err := s.PublishedDocByToken(c.Param("token"))
|
||||
if err == sql.ErrNoRows {
|
||||
c.AbortWithStatus(404)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.AbortWithStatus(500)
|
||||
return
|
||||
}
|
||||
var doc sharedDocument
|
||||
if json.Unmarshal([]byte(record.Content), &doc) != nil {
|
||||
c.AbortWithStatus(500)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Status(http.StatusOK)
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
_ = sharedDocsTemplate.Execute(c.Writer, doc)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSharedDocsAreExplicitReadOnlyAndRevocable(t *testing.T) {
|
||||
t.Setenv("OPSXAPI_DB", filepath.Join(t.TempDir(), "docs.db"))
|
||||
admin := NewRouter()
|
||||
public := NewMockRouter()
|
||||
call := func(router interface {
|
||||
ServeHTTP(http.ResponseWriter, *http.Request)
|
||||
}, method, path, body, peer string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.RemoteAddr = peer
|
||||
out := httptest.NewRecorder()
|
||||
router.ServeHTTP(out, req)
|
||||
return out
|
||||
}
|
||||
payload := `{"projectId":"one","name":"<script>alert(1)</script>","endpoints":[{"method":"GET","path":"/pets","name":"Pets","description":"<img src=x onerror=alert(1)>","responses":[{"status":"200","example":"{\"ok\":true}"}]}]}`
|
||||
if w := call(admin, "PUT", "/api/docs/local", payload, "192.168.0.2:1000"); w.Code != 403 {
|
||||
t.Fatalf("remote publish: %d", w.Code)
|
||||
}
|
||||
w := call(admin, "PUT", "/api/docs/local", payload, "127.0.0.1:1000")
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
var saved struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Token == "" {
|
||||
t.Fatal("missing token")
|
||||
}
|
||||
path := "/docs/" + saved.Token
|
||||
if w = call(public, "GET", path, "", "8.8.8.8:1000"); w.Code != 403 {
|
||||
t.Fatalf("public peer allowed: %d", w.Code)
|
||||
}
|
||||
w = call(public, "GET", path, "", "192.168.0.2:1000")
|
||||
if w.Code != 200 || strings.Contains(w.Body.String(), "<script>") || strings.Contains(w.Body.String(), "<img") || !strings.Contains(w.Body.String(), "Pets") {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if w = call(public, "POST", path, "", "192.168.0.2:1000"); w.Code != 404 {
|
||||
t.Fatalf("shared page accepts writes: %d", w.Code)
|
||||
}
|
||||
if w = call(admin, "DELETE", "/api/docs/local/one", "", "127.0.0.1:1000"); w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if w = call(public, "GET", path, "", "192.168.0.2:1000"); w.Code != 404 {
|
||||
t.Fatalf("revoked page still visible: %d", w.Code)
|
||||
}
|
||||
}
|
||||