From 71e415dfe16a664623877383be8ac8721af6749f Mon Sep 17 00:00:00 2001 From: hoteas <925970985@qq.com> Date: Sun, 15 Mar 2026 10:36:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(swagger):=20=E5=A2=9E=E5=BC=BA=20Swagger?= =?UTF-8?q?=20=E7=94=9F=E6=88=90=E9=80=BB=E8=BE=91=E4=B8=8E=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E6=8E=A8=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增参数推断功能,自动从测试用例中推断 API 参数的必填性、类型和示例值 - 更新 GenerateSwagger 方法,支持在生成的 API 规范中包含推断的参数信息 - 优化文档,增加对新功能的说明,包括参数推断和覆盖率字段的详细描述 - 改进调试控制台的结构,支持模块化输出和独立导航页生成 - 增强并发保护与缓存隔离机制,确保测试环境的稳定性 --- docs/Testing_API测试框架.md | 211 +++++++++++---- testing_swagger.go | 514 +++++++++++++++++++++++++++--------- 2 files changed, 549 insertions(+), 176 deletions(-) diff --git a/docs/Testing_API测试框架.md b/docs/Testing_API测试框架.md index 3b793c3..31e93ff 100644 --- a/docs/Testing_API测试框架.md +++ b/docs/Testing_API测试框架.md @@ -11,8 +11,11 @@ - [完整场景对照](#完整场景对照) - [事务隔离机制](#事务隔离机制) - [覆盖率报告](#覆盖率报告) + - [api-spec.json 覆盖率字段说明](#api-specjson-覆盖率字段说明) + - [params 参数推断说明](#api-specjson-覆盖率字段说明) - [API 调试控制台](#api-调试控制台) - [指定运行范围](#指定运行范围) +- [并发保护与缓存隔离](#并发保护与缓存隔离) - [框架改动说明](#框架改动说明) --- @@ -26,9 +29,11 @@ HoTime 内置 API 测试框架,核心能力: | **零启动** | 基于 `net/http/httptest`,不需要启动 HTTP 服务器 | | **事务回滚** | 每个接口方法独立开启数据库事务,测试结束自动回滚,数据库不留任何痕迹 | | **SAVEPOINT** | 业务代码中的 `that.Db.Action()` 在测试模式下自动用 SAVEPOINT 替代真事务,嵌套事务完整可用 | +| **并发保护** | `testMu` 互斥锁自动保护 `testTx` 单连接,业务代码中的 `go func()` 协程不会导致 `busy buffer` | +| **缓存隔离** | 测试启动时自动禁用 DB/Redis 缓存,避免缓存操作与测试事务锁冲突 | | **链式 API** | `a.JSON(...).Post("描述", 期望status)` 一行完成:数据组装 + 请求 + 断言 | | **覆盖率** | 自动对比路由注册表与测试定义,输出哪些接口已覆盖、哪些未覆盖 | -| **调试控制台** | 根据测试用例自动生成交互式 API 调试控制台,支持在线测试、认证管理、参数编辑 | +| **调试控制台** | 按模块生成独立的交互式 API 调试控制台,支持在线测试、认证管理、参数编辑 | --- @@ -345,20 +350,6 @@ that.Db.Action(func(db HoTimeDB) bool { | 数据持久化 | 持久化到数据库 | 测试结束全部回滚 | | 对现有代码影响 | 无 | 无(`testTx` 默认 nil) | -### 一次性数据的处理 - -对于需要随机数据的测试(如注册),直接用 `RandX` 生成随机值即可,**无需手动清理**: - -```go -// app/user_test.go 中的 UserTest 片段 -"create": {Desc: "用户注册", Func: func(a *Api) { - // 生成随机手机号,测试完成后事务自动回滚,数据库中不留任何记录 - phone := "138" + ObjToStr(RandX(10000000, 99999999)) - a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0) - a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册") -}}, -``` - --- ## 覆盖率报告 @@ -381,26 +372,86 @@ func TestMain(m *testing.M) { 总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3% 已覆盖的接口: - + /app/user/login 4 用例 (4 通过, 0 失败) 12ms - + /app/user/info 2 用例 (2 通过, 0 失败) 5ms - + /app/user/token 2 用例 (2 通过, 0 失败) 4ms - + /app/user/create 4 用例 (4 通过, 0 失败) 18ms - + /app/user/forget 2 用例 (2 通过, 0 失败) 6ms - + /app/user/file 2 用例 (2 通过, 0 失败) 9ms + + /api/user/login 4 用例 (4 通过, 0 失败) 12ms + + /api/user/info 2 用例 (2 通过, 0 失败) 5ms + + /api/user/token 2 用例 (2 通过, 0 失败) 4ms + + /api/user/create 4 用例 (4 通过, 0 失败) 18ms + + /api/user/forget 2 用例 (2 通过, 0 失败) 6ms + + /api/user/file 2 用例 (2 通过, 0 失败) 9ms 未覆盖的接口: - - app/goods/list - - app/goods/create - - app/order/list + - api/goods/list + - api/goods/create + - api/order/list - ... (共36个未覆盖) ======================================== ``` +### api-spec.json 覆盖率字段说明 + +`GenerateSwagger` 生成的每个 `api-spec.json` 文件中,每条 `endpoint` 记录包含以下与测试状态相关的字段: + +```json +{ + "path": "/api/user/login", + "project": "api", + "ctr": "user", + "method": "POST", + "summary": "用户登录", + "tested": true, + "params": [ + { "name": "name", "required": true, "type": "string", "example": "13800138000", "in": "json" }, + { "name": "password", "required": false, "type": "string", "example": "123456", "in": "json" } + ], + "cases": [ + { + "name": "正常登录", + "method": "POST", + "passed": true, + "query": {}, + "json": { "name": "13800138000", "password": "123456" }, + "response": { "status": 0, "msg": "ok", "data": {} } + }, + { + "name": "密码为空", + "method": "POST", + "passed": true, + "json": { "name": "13800138000", "password": "" }, + "response": { "status": 4, "msg": "用户名或密码不能为空" } + } + ] +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `tested` | bool | 是否在 `CtrTest` 中定义了测试,`false` = 未测试 | +| `params` | array | 从测试用例自动推断的参数规格列表 | +| `params[].name` | string | 参数名称 | +| `params[].required` | bool | 是否必填(判断依据:该参数出现且值为空、同时 response.status==3 的用例存在) | +| `params[].type` | string | 推断的参数类型(`string` / `number` / `boolean` / `array` / `object`) | +| `params[].example` | any | 取第一个 status==0 成功用例中该参数的值作为示例 | +| `params[].in` | string | 参数位置:`query` / `form` / `json` | +| `cases` | array | 实际运行的测试用例列表;若 `tested=false` 则为空数组 | +| `cases[].passed` | bool | 该用例是否通过(断言的 status/message 均匹配) | +| `cases[].query` | object | 该用例的 Query 参数(GET 场景) | +| `cases[].json` | object | 该用例的 JSON Body(`a.JSON(...)` 场景) | +| `cases[].form` | object | 该用例的 Form 参数(`a.Form(...)` 场景) | +| `cases[].response` | object | 该用例的实际响应体 | + +**侧边栏徽章规则**(调试控制台): + +| 显示 | 含义 | +|------|------| +| `未测试`(灰色) | `tested=false`,该接口没有任何测试用例 | +| `N/N`(绿色) | 所有用例通过,如 `4/4` | +| `N/N`(红色) | 存在失败用例,如 `2/4` | + --- ## API 调试控制台 -`GenerateSwagger` 遍历 `TestProj` 中所有项目的路由和测试用例,生成一个**交互式 API 调试控制台**(暗色高对比度主题),支持在线调试、参数编辑、认证管理。 +`GenerateSwagger` 按模块生成独立的交互式 API 调试控制台(暗色高对比度主题),每个模块一个子目录,支持独立分发。 ```go testApp.GenerateSwagger( @@ -410,47 +461,56 @@ testApp.GenerateSwagger( ) ``` -生成文件: +### 生成目录结构 + +每个模块(`TestProj` 中的项目名)生成独立子目录,同时自动生成根导航页: ``` {outputDir}/swagger/ -├── api-spec.json ← 自定义 API 规范文件(包含路由、测试用例、请求/响应数据) -└── index.html ← 交互式调试控制台 +├── index.html ← 根导航页(API 文档中心,卡片式链接到各模块) +├── api/ +│ ├── api-spec.json ← api 模块的 API 规范(路由、测试用例、请求/响应数据) +│ └── index.html ← api 模块的调试控制台 +├── admin/ +│ ├── api-spec.json +│ └── index.html +└── portal/ + ├── api-spec.json + └── index.html ``` -访问地址:`http://localhost:8081/swagger/` +访问地址:`http://localhost:8081/swagger/`(根导航页),`http://localhost:8081/swagger/api/`(api 模块) + +> **独立分发**:每个模块目录可以单独拷贝和部署,不依赖其他模块。根导航页自动扫描子目录生成链接。 ### 控制台功能 | 功能 | 说明 | |------|------| -| **三级导航** | 左侧侧边栏按 项目 > 控制器 > 方法 三级树形展开 | -| **搜索和筛选** | 支持关键字搜索,按 全部/已测试/未测试 筛选 | -| **测试用例查看** | 每个接口的测试用例以手风琴形式展示(第一个默认展开),包含请求头、请求参数(Query/JSON/Form/文件)、响应结果 | -| **接口调试** | 可编辑的请求头、Query 参数、文件上传、请求体(表单/JSON 互斥切换),直接发送请求 | -| **预填用例** | 下拉选择测试用例一键填充请求参数,方便快速调试 | +| **三级导航** | 左侧侧边栏按 项目 > 控制器 > 方法 三级树形展示(一级默认展开,二级默认收起) | +| **搜索和筛选** | 支持关键字搜索,按 全部/已测试/未测试/通过/未通过 五种模式筛选 | +| **左右分栏** | 接口信息栏右侧显示用例数量(`用例: N`);左侧为请求构建器,右侧为结果面板(两 tab 切换) | +| **测试用例 tab** | 右侧默认展示"测试用例"tab,手风琴列表(第一个默认展开);必填参数前显示红色 `*` 标记 | +| **发送结果 tab** | 点击"发送"后自动切换到"发送结果"tab 展示 cURL 命令和响应内容;可随时手动切回 | +| **必填参数推断** | 从测试用例自动推断必填参数(有 status==3 且值为空的用例),调试面板和用例展示均标记 `*` | +| **预填用例** | 默认选中第一个用例自动填充左侧所有参数;"手动填写"置于列表末尾;必填字段有 `*` 标记 | | **全局认证** | 顶部认证栏支持四种模式:无认证、Header (Authorization)、URL (?token=)、Cookie,修改后实时同步到各参数区域 | | **Cookie 隔离** | Header/URL 认证模式下自动阻止浏览器发送 Cookie(`credentials: 'omit'`),避免意外自动授权 | | **cURL 生成** | 每次请求自动生成对应的 cURL 命令,支持一键复制 | | **响应格式化** | JSON 响应自动格式化显示,支持复制 | | **覆盖率可视化** | 已测试接口显示通过/失败计数徽章,未测试接口标记"未测试" | -### 多项目支持 - -所有注册项目的路由和测试用例合并到同一份 `api-spec.json`,在侧边栏按项目名分组,不会互相覆盖: +### 侧边栏结构 ``` -侧边栏结构: -├── app(项目) -│ ├── user(控制器) -│ │ ├── POST login 用户登录 ✅ 4/4 -│ │ ├── POST info 未测试 -│ │ └── POST create 用户注册 ✅ 4/4 -│ └── goods(控制器) -│ └── ... -└── customer(项目) - └── pay(控制器) - └── ... +├── user(控制器,默认展开) ← 一级目录 +│ ├── POST login 用户登录 ✅ 4/4 +│ ├── POST info 未测试 +│ └── POST create 用户注册 ✅ 4/4 +├── goods(控制器,点击展开) ← 二级目录 +│ └── ... +└── order + └── ... ``` --- @@ -492,24 +552,65 @@ TestApi --- +## 并发保护与缓存隔离 + +### testMu 互斥锁 + +`testTx` 是单个 `*sql.Tx` 连接,MySQL 驱动不允许在同一连接上并发执行查询。业务代码中的 `go func()` 协程(如异步同步、统计任务)会尝试并发访问 `testTx`,导致 `busy buffer` 错误。 + +框架通过 `testMu *sync.Mutex` 自动序列化所有 `testTx` 上的操作: + +| 操作 | 保护范围 | +|------|----------| +| `testTx.Query()` | 加锁 → 执行查询 → 消费结果集 → 解锁 | +| `testTx.Exec()` | 加锁 → 执行 → 解锁 | +| SAVEPOINT 操作 | 加锁 → 执行 → 解锁 | + +生产环境不受影响(`testMu` 默认 nil,所有锁检查都有 nil 保护)。 + +### 缓存隔离 + +`NewTestApp` 启动时自动调用 `HoTimeCache.DisableDbCache()`,禁用 DB 和 Redis 缓存层,仅保留 Memory 缓存: + +- 避免缓存的 `DELETE FROM cached` 操作与测试事务产生锁冲突(`Lock wait timeout`) +- Session 通过 `WithSession()` 直接注入 Memory 缓存,不依赖 DB/Redis +- 生产环境不受影响(仅在 `NewTestApp` 中调用) + +### 一次性数据的处理 + +对于需要随机数据的测试(如注册),直接用 `RandX` 生成随机值即可,**无需手动清理**: + +```go +"create": {Desc: "用户注册", Func: func(a *Api) { + phone := "138" + ObjToStr(RandX(10000000, 99999999)) + a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0) + a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册") +}}, +``` + +--- + ## 框架改动说明 -测试框架对 `hotimev1.5` 共改动 6 个文件,其中 3 个为修改、3 个为新增: +测试框架对 `hotimev1.5` 共改动 9 个文件,其中 6 个为修改、3 个为新增: ### 修改的文件 | 文件 | 改动 | 生产影响 | |------|------|----------| -| `db/db.go` | 新增 `testTx *sql.Tx` 字段 + `BeginTestTx()`/`RollbackTestTx()` 方法 | 无(`testTx` 默认 nil) | -| `db/query.go` | `Query` 和 `Exec` 各增加一个 `if testTx != nil` 分支 | 无(分支默认跳过) | -| `db/transaction.go` | `Action()` 增加 SAVEPOINT 分支 | 无(`testTx` 为 nil 时走原有逻辑) | +| `db/db.go` | 新增 `testTx *sql.Tx` + `testMu *sync.Mutex` 字段,`BeginTestTx()`(初始化互斥锁)/ `RollbackTestTx()` 方法 | 无(`testTx` 默认 nil) | +| `db/query.go` | `Query` 和 `Exec` 各增加 `if testTx != nil` 分支 + `testMu` 互斥锁保护 + 跳过重试逻辑 | 无(分支默认跳过) | +| `db/transaction.go` | `Action()` 增加 SAVEPOINT 分支 + `testTx`/`testMu` 传递 + SAVEPOINT 操作加锁 | 无(`testTx` 为 nil 时走原有逻辑) | +| `db/crud.go` | `Select` 中 `testTx` 激活时跳过缓存逻辑 | 无(分支默认跳过) | +| `cache/cache.go` | 新增 `DisableDbCache()` 方法 + 新增 `SessionsGet`/`SessionsSet`/`SessionsDelete` 批量操作 | 无(方法不主动调用) | +| `session.go` | 对接批量 Session 缓存操作 | 无 | ### 新增的文件 | 文件 | 内容 | |------|------| -| `testing_helper.go` | `TestApp`、`NewTestApp`、`SetupForTest`、`RunTests`(事务隔离)、`PrintCoverage`(覆盖率) | +| `testing_helper.go` | `TestApp`、`NewTestApp`(含 `DisableDbCache` 调用)、`SetupForTest`、`RunTests`(事务隔离)、`PrintCoverage`(覆盖率) | | `testing_api.go` | 注册类型 + `Api`/`ApiCase`/`ApiResponse` 链式 API + `TestCollector` | -| `testing_swagger.go` | `GenerateSwagger`:合并所有项目生成 API 调试控制台(`api-spec.json` + `index.html`) | +| `testing_swagger.go` | `GenerateSwagger`:按模块生成子目录 + 根导航页 + 交互式调试控制台 | -> 所有新增文件仅在 `go test` 环境下被使用,生产构建不引入 `testing` 包依赖,完全不影响线上服务。 +> 所有新增文件仅在 `go test` 环境下被使用,生产构建不引入 `testing` 包依赖,完全不影响线上服务。所有修改的文件都通过 nil 检查保护,`testTx`/`testMu` 默认为 nil,生产代码路径不受任何影响。 diff --git a/testing_swagger.go b/testing_swagger.go index 9c816f3..0410920 100644 --- a/testing_swagger.go +++ b/testing_swagger.go @@ -12,15 +12,170 @@ import ( ) type swaggerTestCase struct { - Name string `json:"name"` - Method string `json:"method"` - Passed bool `json:"passed"` - Query map[string]interface{} `json:"query,omitempty"` - Json interface{} `json:"json,omitempty"` - Form map[string]interface{} `json:"form,omitempty"` - HasFile bool `json:"hasFile,omitempty"` - FileField string `json:"fileField,omitempty"` - Response map[string]interface{} `json:"response,omitempty"` + Name string `json:"name"` + Method string `json:"method"` + Passed bool `json:"passed"` + Query map[string]interface{} `json:"query,omitempty"` + Json interface{} `json:"json,omitempty"` + Form map[string]interface{} `json:"form,omitempty"` + HasFile bool `json:"hasFile,omitempty"` + FileField string `json:"fileField,omitempty"` + Response map[string]interface{} `json:"response,omitempty"` +} + +type paramSpec struct { + Name string `json:"name"` + Required bool `json:"required"` + Type string `json:"type,omitempty"` + Example interface{} `json:"example,omitempty"` + In string `json:"in"` +} + +// inferParamsFromCases 从测试用例自动推断参数必填性、类型和示例值。 +// 必填判定:参数显式存在但值为空字符串,且该用例 response.status==3。 +func inferParamsFromCases(cases []swaggerTestCase) []paramSpec { + type paramInfo struct { + in string + required bool + typ string + example interface{} + } + + params := map[string]*paramInfo{} + + getStatus := func(resp map[string]interface{}) int { + if resp == nil { + return -1 + } + if s, ok := resp["status"]; ok { + if f, ok := s.(float64); ok { + return int(f) + } + } + return -1 + } + + inferType := func(v interface{}) string { + switch v.(type) { + case float64: + return "number" + case bool: + return "boolean" + case []interface{}: + return "array" + case map[string]interface{}: + return "object" + default: + return "string" + } + } + + sv := func(v interface{}) string { + if v == nil { + return "" + } + return fmt.Sprintf("%v", v) + } + + // toStrMap 兼容 Map 类型别名和 map[string]interface{} 两种情况 + toStrMap := func(v interface{}) map[string]interface{} { + if v == nil { + return nil + } + switch m := v.(type) { + case map[string]interface{}: + return m + case Map: + return map[string]interface{}(m) + } + return nil + } + + // 第一轮:收集所有参数 key、类型和示例值 + for _, c := range cases { + st := getStatus(c.Response) + for k, v := range c.Query { + if _, exists := params[k]; !exists { + params[k] = ¶mInfo{in: "query"} + } + p := params[k] + if p.typ == "" && sv(v) != "" { + p.typ = inferType(v) + } + if p.example == nil && st == 0 && sv(v) != "" { + p.example = v + } + } + for k, v := range c.Form { + if _, exists := params[k]; !exists { + params[k] = ¶mInfo{in: "form"} + } + p := params[k] + if p.typ == "" && sv(v) != "" { + p.typ = inferType(v) + } + if p.example == nil && st == 0 && sv(v) != "" { + p.example = v + } + } + if jmap := toStrMap(c.Json); jmap != nil { + for k, v := range jmap { + if _, exists := params[k]; !exists { + params[k] = ¶mInfo{in: "json"} + } + p := params[k] + if p.typ == "" && sv(v) != "" { + p.typ = inferType(v) + } + if p.example == nil && st == 0 && sv(v) != "" { + p.example = v + } + } + } + } + + // 第二轮:标记必填 — 参数存在但值为空 且 status==3 + for _, c := range cases { + if getStatus(c.Response) != 3 { + continue + } + for k, v := range c.Query { + if p, ok := params[k]; ok && p.in == "query" && sv(v) == "" { + p.required = true + } + } + for k, v := range c.Form { + if p, ok := params[k]; ok && p.in == "form" && sv(v) == "" { + p.required = true + } + } + if jmap := toStrMap(c.Json); jmap != nil { + for k, v := range jmap { + if p, ok := params[k]; ok && p.in == "json" && sv(v) == "" { + p.required = true + } + } + } + } + + result := make([]paramSpec, 0, len(params)) + for name, p := range params { + result = append(result, paramSpec{ + Name: name, + Required: p.required, + Type: p.typ, + Example: p.example, + In: p.in, + }) + } + + sort.Slice(result, func(i, j int) bool { + if result[i].Required != result[j].Required { + return result[i].Required + } + return result[i].Name < result[j].Name + }) + return result } func (that *TestApp) GenerateSwagger(title, version, outputDir string) error { @@ -100,6 +255,7 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error { "summary": summary, "tested": hasTest, "cases": cases, + "params": inferParamsFromCases(cases), }) } } @@ -195,19 +351,18 @@ func apiConsoleHTML() string { :root{--bg:#1a1a2e;--bg2:#16213e;--bg3:#0f3460;--fg:#e0e0e0;--fg2:#888;--accent:#4fc3f7;--green:#66bb6a;--red:#ef5350;--border:#2a2a4a;--r:4px} *{box-sizing:border-box;margin:0;padding:0}body{display:flex;height:100vh;background:var(--bg);color:var(--fg);font:13px/1.5 -apple-system,sans-serif} input,select,textarea,button{font:inherit}pre{margin:0} - -#side{width:260px;min-width:260px;display:flex;flex-direction:column;border-right:1px solid var(--border)} +#side{width:260px;min-width:260px;display:flex;flex-direction:column;border-right:1px solid var(--border);overflow:hidden} .shd{padding:10px;border-bottom:1px solid var(--border)}.shd h3{font-size:13px;color:var(--accent);margin-bottom:6px} .shd input{width:100%;padding:5px 8px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none} .shd input:focus{border-color:var(--accent)}.sft{display:flex;gap:2px;margin-top:5px} .sft button{flex:1;padding:3px;border:1px solid var(--border);border-radius:var(--r);background:transparent;color:var(--fg2);font-size:11px;cursor:pointer} .sft button.on{background:var(--bg3);color:var(--accent);border-color:var(--accent)} -#tree{flex:1;overflow-y:auto}#tree::-webkit-scrollbar{width:4px}#tree::-webkit-scrollbar-thumb{background:#444;border-radius:2px} +#tree{flex:1;overflow-y:auto;min-height:0}#tree::-webkit-scrollbar{width:4px}#tree::-webkit-scrollbar-thumb{background:#444;border-radius:2px} .l1{border-bottom:1px solid var(--border)}.l1h,.l2h{display:flex;align-items:center;cursor:pointer} .l1h{padding:5px 8px;font-weight:700;color:var(--accent);font-size:13px}.l1h:hover,.l2h:hover{background:var(--bg2)} .l2h{padding:3px 8px 3px 20px;color:#81d4fa;font-size:12px} .ar{margin-right:4px;font-size:8px;transition:transform .1s;color:#555}.ar.o{transform:rotate(90deg);color:var(--accent)} -.cn{margin-left:auto;font-size:10px;color:#444}.sub{display:none}.sub.o{display:block} +.cn{margin-left:auto;font-size:10px;color:#888;background:rgba(255,255,255,.06);padding:0 5px;border-radius:8px;min-width:18px;text-align:center}.sub{display:none}.sub.o{display:block} .ep{display:flex;align-items:center;padding:3px 6px 3px 32px;cursor:pointer;gap:4px;color:var(--fg2)} .ep:hover{background:var(--bg2);color:var(--fg)}.ep.act{background:var(--bg3);color:var(--accent)} .ep .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} @@ -215,50 +370,51 @@ input,select,textarea,button{font:inherit}pre{margin:0} .mt-get{background:#1b5e20;color:#a5d6a7}.mt-post{background:#0d47a1;color:#90caf9}.mt-put{background:#e65100;color:#ffcc80}.mt-delete{background:#b71c1c;color:#ef9a9a} .tg{font-size:9px;padding:1px 4px;border-radius:2px;flex-shrink:0} .tg-ok{background:#1b5e20;color:#a5d6a7}.tg-err{background:#b71c1c;color:#ef9a9a}.tg-no{background:#333;color:#555} - #main{flex:1;display:flex;flex-direction:column;overflow:hidden} #bar{padding:5px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:6px;flex-shrink:0} #bar label{color:var(--fg2);font-size:11px} #bar select,#bar input{padding:3px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none} #bar input{flex:1;max-width:280px}#bar select{width:auto} -#ct{flex:1;overflow-y:auto;padding:12px}#ct::-webkit-scrollbar{width:5px}#ct::-webkit-scrollbar-thumb{background:#444;border-radius:3px} - -.ehd{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap} +#ct{flex:1;display:flex;flex-direction:column;overflow:hidden;padding:0} +.ct-hd{padding:6px 12px;border-bottom:1px solid var(--border);flex-shrink:0} +.ehd{display:flex;align-items:center;gap:8px;flex-wrap:wrap} .ehd .mt{font-size:13px;padding:3px 10px}.ehd .pa{font-size:15px;font-weight:600}.ehd .ds{color:var(--fg2);font-size:13px} .ehd .nt{color:var(--red);font-size:11px;padding:2px 6px;border:1px solid var(--red);border-radius:var(--r)} -.tabs{display:flex;border-bottom:1px solid var(--border);margin-bottom:10px} -.tab{padding:6px 16px;cursor:pointer;color:var(--fg2);border-bottom:2px solid transparent;font-size:13px} -.tab:hover{color:var(--fg)}.tab.on{color:var(--accent);border-color:var(--accent)}.pn{display:none}.pn.on{display:block} - +.case-cnt{font-size:11px;color:var(--fg2);padding:1px 8px;border:1px solid var(--border);border-radius:10px;white-space:nowrap;flex-shrink:0} +.pf-wrap{position:relative;flex:1;min-width:0}.pf-btn{width:100%;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);cursor:pointer;font-size:12px;color:var(--fg);outline:none;box-sizing:border-box}.pf-btn:hover{border-color:#555}.pf-dd{position:absolute;top:calc(100% + 2px);left:0;right:0;background:#161625;border:1px solid var(--border);border-radius:var(--r);z-index:200;list-style:none;padding:3px 0;margin:0;max-height:200px;overflow-y:auto;display:none;box-shadow:0 4px 12px rgba(0,0,0,.5)}.pf-dd.on{display:block}.pf-opt{display:flex;align-items:center;gap:6px;padding:5px 10px;cursor:pointer;font-size:12px}.pf-opt:hover{background:var(--bg3)}.pf-opt.sel{color:var(--accent)} +.split{display:flex;flex:1;min-height:0;overflow:hidden} +.split-l{width:560px;min-width:220px;overflow-y:auto;padding:10px 12px;border-right:1px solid var(--border);flex-shrink:0} +.split-l::-webkit-scrollbar{width:4px}.split-l::-webkit-scrollbar-thumb{background:#444;border-radius:2px} +.split-r{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden} +.rtabs{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;padding:0 4px} +.rtab{padding:6px 14px;cursor:pointer;color:var(--fg2);border-bottom:2px solid transparent;font-size:12px} +.rtab:hover{color:var(--fg)}.rtab.on{color:var(--accent);border-color:var(--accent)} +.rpn{display:none;overflow-y:auto;padding:12px;flex:1}.rpn.on{display:block} +.rpn::-webkit-scrollbar{width:4px}.rpn::-webkit-scrollbar-thumb{background:#444;border-radius:2px} .cs{border:1px solid var(--border);border-radius:var(--r);margin-bottom:6px;overflow:hidden} .csh{padding:6px 10px;display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;background:var(--bg2)} -.csh:hover{background:var(--bg3)}.csh .dot{width:8px;height:8px;border-radius:50%;flex-shrink:0} -.csh .dot.ok{background:var(--green)}.csh .dot.er{background:var(--red)}.csh .ml{margin-left:auto;color:#555;font-size:11px} +.csh:hover{background:var(--bg3)}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0} +.dot.ok{background:var(--green)}.dot.er{background:var(--red)}.csh .ml{margin-left:auto;color:#555;font-size:11px} .csb{display:none;border-top:1px solid var(--border);font-size:12px}.csb.o{display:block} .csr{display:flex}.csr>.csc{flex:1;padding:8px 10px;min-width:0}.csr>.csc+.csc{border-left:1px solid var(--border)} .csc h5{color:var(--accent);margin:0 0 4px;font-size:11px;letter-spacing:.5px;display:flex;align-items:center} .csc h5 .cp-sm{margin-left:auto}.csc h5:not(:first-child){margin-top:8px} pre.j{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:12px;line-height:1.4;color:#aaa;white-space:pre-wrap;word-break:break-all;max-height:260px;overflow:auto;font-family:Consolas,Monaco,monospace} - -.dbg{display:flex;gap:12px} -.dbg-l{flex:1;min-width:0;max-height:calc(100vh - 140px);overflow-y:auto;padding-right:4px} -.dbg-r{flex:1;min-width:0;max-height:calc(100vh - 140px);overflow-y:auto} -.dbg-l::-webkit-scrollbar,.dbg-r::-webkit-scrollbar{width:4px}.dbg-l::-webkit-scrollbar-thumb,.dbg-r::-webkit-scrollbar-thumb{background:#444;border-radius:2px} .sec{margin-top:10px;padding-top:10px;border-top:1px solid var(--border)}.sec-title{color:var(--fg2);font-size:11px;letter-spacing:.5px;margin-bottom:4px} .row-hd{display:flex;align-items:center;margin-bottom:4px;padding:4px 8px;background:var(--bg2);border-radius:var(--r)}.row-hd .sec-title{flex:1;margin:0} -.kv{display:flex;gap:4px;margin-bottom:3px}.kv input{flex:1;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none} +.kv{display:flex;gap:4px;margin-bottom:3px;align-items:center}.kv input{flex:1;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none} .kv input[type="file"]{padding:3px 4px} .kv input:focus{border-color:var(--accent)}.kv button{padding:2px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--red);border-radius:var(--r);cursor:pointer} .addbtn{padding:2px 10px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);color:var(--fg);cursor:pointer;font-size:10px;white-space:nowrap} .addbtn:hover{border-color:var(--accent);color:var(--accent)} -.sbtn{padding:6px 20px;background:var(--accent);color:var(--bg);border:none;border-radius:var(--r);cursor:pointer;font-size:12px;font-weight:600;white-space:nowrap} +.sbtn{padding:6px 18px;background:var(--accent);color:var(--bg);border:none;border-radius:var(--r);cursor:pointer;font-size:12px;font-weight:600;white-space:nowrap} .sbtn:hover{opacity:.9}.sbtn:disabled{opacity:.4;cursor:not-allowed} .curl{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:11px;color:#888;white-space:pre-wrap;word-break:break-all;font-family:Consolas,Monaco,monospace} .cp-sm{padding:1px 6px;background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);color:var(--fg2);cursor:pointer;font-size:10px} .cp-sm:hover{color:var(--accent);border-color:var(--accent)} .pc{border:1px solid var(--border);border-radius:var(--r);margin-bottom:6px;overflow:hidden} .pc-hd{padding:3px 8px;background:var(--bg2);color:var(--fg2);font-size:10px;letter-spacing:.5px;border-bottom:1px solid var(--border)}.pc-bd{padding:6px 8px} -.kv-ro{display:flex;gap:4px;margin-bottom:3px} +.kv-ro{display:flex;gap:4px;margin-bottom:3px;align-items:center} .kv-ro .kk{min-width:80px;max-width:140px;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg3);color:var(--accent);font-size:12px;word-break:break-all} .kv-ro .vv{flex:1;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;word-break:break-all} .bd-acc{border:1px solid var(--border);border-radius:var(--r);overflow:hidden} @@ -269,6 +425,7 @@ pre.j{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:12px;l .bd-bd{display:none;padding:8px;border-top:1px solid var(--border)}.bd-bd.on{display:block} .bd-bd textarea{width:100%;min-height:36px;max-height:40vh;padding:5px 8px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;font-family:Consolas,Monaco,monospace;resize:vertical;outline:none;overflow-y:auto} .bd-bd textarea:focus{border-color:var(--accent)} +.req-star{color:var(--red);font-size:10px;font-weight:700;margin-right:2px;flex-shrink:0;line-height:1.2;align-self:center}
'+esc(hdr)+''; - h+='
'+fj(c.json)+'
'+fj(c.response)+''; - else h+='无'; - h+='
'+fj(c.response)+''; + else h+='无响应'; + h+='
发送请求后显示'; + r+='
发送请求后显示'; + return r; +} - return '
'+fj(c.response)+''; + else h+='无响应'; + h+='