桌面 GUI 外链修复 + 主题顺序调整(含结构扁平化进行中的工作区基线)

- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化)
- 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转
- 设置→外观 主题顺序调整为 深色/浅色/跟随系统
- release 二进制重编并验证(浏览器实测关于页链接可开文档页)
- 附带工作区中未提交的扁平化重构现状(另一会话进行中)
- homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
This commit is contained in:
logan committed 2026-10-16 03:03:36 +08:00
1 parent 6452026fd6
commit 2bb4ef7a95
299 files changed
+6059 -3322

No files matched your search

+6 -5
View File
@@ -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 -3
View File
@@ -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
+61 -18
View File
@@ -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 响应端口,不开放发布、账号、工作区或代理管理接口,也不改变本地数据存储方式。
-220
View File
@@ -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"
}
}
}
}
-11
View File
@@ -1,11 +0,0 @@
{
"name": "@opsxapi/desktop",
"version": "0.1.0",
"private": true,
"scripts": {
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2.4.0"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

-293
View File
@@ -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");
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
-73
View File
@@ -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()
}
}
-30
View File
@@ -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"
}
}
-10
View File
@@ -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>,
);
-11
View File
@@ -1,11 +0,0 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}
+18
View File
@@ -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 的自动查看被运行环境拒绝,窄窗的实际视觉效果仍待桌面窗口验收。
+35
View File
@@ -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 保持不变,恢复内容未自动提交。
+9
View File
@@ -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>
+259 -115
View File
@@ -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"
}
}
}
}
+32 -12
View File
@@ -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"
}
}
-23
View File
@@ -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"
}
}
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": [
"src"
]
}
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});
-27
View File
@@ -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
}
}
}
-102
View File
@@ -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"));
},
};
},
};
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": [
"src"
]
}
-25
View File
@@ -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"
}
}
-178
View File
@@ -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>
);
}
-756
View File
@@ -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("[");
}
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": [
"src"
]
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
+92 -47
View File
@@ -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)
+7 -39
View File
@@ -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
}
Binary file not shown.
@@ -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)
File renamed without changes.
Binary file not shown.
Binary file not shown.
File renamed without changes.
File renamed without changes.
@@ -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})
}
+69
View File
@@ -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})
}
+120
View File
@@ -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)
}
}
File renamed without changes.
@@ -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`),不上传任何第三方。
## 注册 / 登录
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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)
}
}
}
+137
View File
@@ -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
}
+65
View File
@@ -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)
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 299 files, more files were not shown because too many files have changed in this diff. Show more