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}
@@ -325,7 +482,8 @@ function render(){ } ph+='
'+c+''+es.length+'
'+eh+'
'; } - h+='
'+p+'
'+ph+'
'; + const totalEps=Object.values(T[p]).reduce((s,es)=>s+es.length,0); + h+='
'+p+''+totalEps+'
'+ph+'
'; } document.getElementById('tree').innerHTML=h||'
无匹配
'; } @@ -334,90 +492,106 @@ function tog(e){e.querySelector('.ar').classList.toggle('o');e.nextElementSiblin function go(el){C=D.endpoints.find(e=>e.path===el.dataset.p);if(!C)return;document.querySelectorAll('.ep').forEach(e=>e.classList.remove('act'));el.classList.add('act');show();} function show(){ - const e=C;if(!e)return;const m=e.method.toLowerCase(); - let h='
'+e.method+''+e.path+''+e.summary+''; + const e=C;if(!e)return;const m=e.method.toLowerCase();const cnt=e.cases?.length||0; + let h='
'; + h+=''+e.method+''; + h+=''+esc(e.path)+''+esc(e.summary)+''; + if(cnt)h+=''+cnt+' 用例'; if(!e.tested)h+='未测试'; - h+='
测试用例'+(e.cases?'('+e.cases.length+')':'')+'
接口调试
'; - h+='
'+cases(e)+'
'+exec(e)+'
'; + h+='
'; + h+='
'; + h+='
'+buildLeft(e)+'
'; + h+='
'; + h+='
测试用例'+(cnt?'('+cnt+')':'')+'
发送结果
'; + h+='
'; + h+='
'+respPanel()+'
'; + h+='
'; document.getElementById('ct').innerHTML=h; + buildPfList(e); + let defIdx=-1; + if(cnt>0){for(let i=e.cases.length-1;i>=0;i--){if(e.cases[i].passed){defIdx=i;break;}}if(defIdx<0)defIdx=e.cases.length-1;} + pfSelect(defIdx>=0?defIdx:''); +} +function rswitch(i){ + document.querySelectorAll('.rtab').forEach((t,j)=>t.classList.toggle('on',j===i)); + document.querySelectorAll('.rpn').forEach((p,j)=>p.classList.toggle('on',j===i)); } -function stab(i,el){document.querySelectorAll('.tab').forEach(t=>t.classList.remove('on'));document.querySelectorAll('.pn').forEach(p=>p.classList.remove('on'));el.classList.add('on');document.getElementById('p'+i).classList.add('on');} +function caseCurl(e,c){ + const base=window.location.origin; + let url=base+e.path; + const m=c.method||'POST'; + if(c.query){const qs=Object.keys(c.query).filter(k=>c.query[k]!==undefined&&c.query[k]!=='').map(k=>encodeURIComponent(k)+'='+encodeURIComponent(c.query[k])).join('&');if(qs)url+='?'+qs;} + let cmd="curl -X "+m+" '"+url+"'"; + if(c.json){cmd+="\\\n -H 'Content-Type: application/json'";cmd+="\\\n -d '"+JSON.stringify(c.json).replace(/'/g,"'\\''")+"'";} + else if(c.form){const fd=Object.keys(c.form).map(k=>"-F '"+k+"="+c.form[k]+"'").join("\\\n ");if(fd)cmd+="\\\n "+fd;} + return cmd; +} function cases(e){ if(!e.cases?.length)return '
暂无测试用例
'; - const _at=document.getElementById('at')?.value||'header'; - const _tk=document.getElementById('tk')?.value||''; let h=''; for(let i=0;i'+esc(c.name)+''+c.method+'
'; - h+='
'; - h+='
请求头
'; - h+='
'+esc(hdr)+'
'; - h+='
请求参数
'; - if(c.query)h+='
QUERY 参数
'+kvList(c.query)+'
'; - if(c.json)h+='
JSON BODY
'+fj(c.json)+'
'; - if(c.form)h+='
FORM 表单
'+kvList(c.form)+'
'; - if(c.hasFile)h+='
文件上传
字段名'+esc(c.fileField)+'
'; - if(!c.query&&!c.json&&!c.form&&!c.hasFile)h+='无参数'; - h+='
响应
'; - if(c.response)h+='
'+fj(c.response)+'
'; - else h+=''; - h+='
'; + h+='
'; + const curlId='cc'+i;const respId='cr'+i; + h+='
cURL
'; + h+='
'+esc(caseCurl(e,c))+'
'; + h+='
响应'+(c.passed?'通过':'失败')+'
'; + if(c.response)h+='
'+fj(c.response)+'
'; + else h+='无响应'; + h+='
'; } return h; } function accordion(el){ const bd=el.nextElementSibling;const wasOpen=bd.classList.contains('o'); - el.closest('.pn').querySelectorAll('.csb').forEach(b=>b.classList.remove('o')); - el.closest('.pn').querySelectorAll('.csh .ar').forEach(a=>a.classList.remove('o')); + const panel=el.closest('.rpn'); + panel.querySelectorAll('.csb').forEach(b=>b.classList.remove('o')); + panel.querySelectorAll('.csh .ar').forEach(a=>a.classList.remove('o')); if(!wasOpen){bd.classList.add('o');el.querySelector('.ar').classList.add('o');} } -function exec(e){ - let opts=''; - if(e.cases)for(let i=0;i'+esc(e.cases[i].name)+''; - const hasForm=e.cases?.some(c=>c.form); - const defBody=hasForm?'form':'json'; - const tk=document.getElementById('tk').value||''; - const at=document.getElementById('at').value; - - let l=''; - l+='
'; - l+='预填用例'; - l+=''; - l+=''; - l+='
'; - - let defH='
'; - if(tk&&at==='header')defH+='
'; - if(tk&&at==='cookie')defH+='
'; - l+='
请求头 (Headers)
'+defH+'
'; - - let qDef=''; - if(tk&&at==='query')qDef='
'; - qDef+='
'; - l+='
Query 参数
'+qDef+'
'; - - l+='
文件上传
'; - - l+='
请求体
'; - l+='
'; - l+='
表单 (Form)
'; - l+='
'; - l+='
JSON (application/json)
'; - l+='
'; - l+='
'; - +function respPanel(){ let r=''; r+='
cURL
'; r+='
发送请求后生成
'; r+='
响应
'; - r+='
发送请求后显示
'; + r+='
发送请求后显示
'; + return r; +} - return '
'+l+'
'+r+'
'; +function buildLeft(e){ + const hasForm=e.cases?.some(c=>c.form)||e.params?.some(p=>p.in==='form'); + const defBody=hasForm?'form':'json'; + const tk=document.getElementById('tk').value||''; + const at=document.getElementById('at').value; + let l=''; + l+='
'; + l+='预填'; + l+='
    '; + l+=''; + l+='
    '; + let defH='
    '; + if(tk&&at==='header')defH+='
    '; + if(tk&&at==='cookie')defH+='
    '; + l+='
    请求头
    '+defH+'
    '; + let qDef=''; + if(tk&&at==='query')qDef='
    '; + qDef+='
    '; + l+='
    Query 参数
    '+qDef+'
    '; + l+='
    文件上传
    '; + let jsonPH=''; + if(e.params){ + const jp=e.params.filter(p=>p.in==='json'); + if(jp.length){const obj={};for(const p of jp)obj[(p.required?'*':'')+p.name]=p.example!==undefined?p.example:'';jsonPH=JSON.stringify(obj,null,2);} + } + l+='
    请求体
    '; + l+='
    表单 (Form)
    '; + l+='
    '; + l+='
    JSON
    '; + l+='
    '; + l+='
    '; + return l; } function addKV(id,k,v){ @@ -425,6 +599,12 @@ function addKV(id,k,v){ row.innerHTML=''; document.getElementById(id).appendChild(row); } +function addKVReq(id,k,v,req){ + const row=document.createElement('div');row.className='kv'; + const star=req?'*':''; + row.innerHTML=star+''; + document.getElementById(id).appendChild(row); +} function addFileRow(field){ const row=document.createElement('div');row.className='kv'; row.innerHTML=''; @@ -455,30 +635,114 @@ function syncAuth(){ if(at==='query')mkAuth('token',tk,qr); } -function pf(){ - const i=parseInt(document.getElementById('pf').value); - if(isNaN(i)||i<0||!C?.cases?.[i])return;const c=C.cases[i]; - document.getElementById('q-rows').innerHTML=''; - if(c.query)for(const[k,v]of Object.entries(c.query))addKV('q-rows',k,String(v)); +function buildPfList(e){ + const dd=document.getElementById('pf-dd');if(!dd)return; + dd.innerHTML=''; + if(e.cases){ + for(let i=e.cases.length-1;i>=0;i--){ + const c=e.cases[i]; + const li=document.createElement('li');li.className='pf-opt';li.dataset.idx=String(i); + li.innerHTML=''+esc(c.name)+''; + li.onclick=ev=>{ev.stopPropagation();pfSelect(i);}; + dd.appendChild(li); + } + } + const manual=document.createElement('li');manual.className='pf-opt';manual.dataset.idx=''; + manual.innerHTML='-- 手动填写 --'; + manual.onclick=ev=>{ev.stopPropagation();pfSelect('');}; + dd.appendChild(manual); +} +function togglePfDd(ev){ev.stopPropagation();const dd=document.getElementById('pf-dd');if(dd)dd.classList.toggle('on');} +document.addEventListener('click',()=>{document.getElementById('pf-dd')?.classList.remove('on');}); +function pfSelect(i){ + const e=C;if(!e)return; + const idxStr=String(i); + document.querySelectorAll('.pf-opt').forEach(el=>el.classList.toggle('sel',el.dataset.idx===idxStr)); + const c=(i!==''&&e.cases?.[i])?e.cases[i]:null; + const dot=document.getElementById('pf-dot'); + const lbl=document.getElementById('pf-label'); + if(dot&&lbl){ + if(c){dot.style.display='';dot.className='dot '+(c.passed?'ok':'er');lbl.style.color='var(--fg)';lbl.textContent=c.name;} + else{dot.style.display='none';lbl.style.color='var(--fg2)';lbl.textContent='-- 手动填写 --';} + } + document.getElementById('pf-dd')?.classList.remove('on'); + updateCasePanel(e,i); + pfFill(i); +} +function updateCasePanel(e,i){ + const panel=document.getElementById('case-panel');if(!panel)return; + if(i===''||!e?.cases?.[i]){panel.innerHTML='
    选择一个测试用例查看响应
    ';return;} + const c=e.cases[i]; + let h='
    '; + h+='
    cURL'+(c.passed?'通过':'失败')+'
    '; + h+='
    '+esc(caseCurl(e,c))+'
    '; + h+='
    响应
    '; + if(c.response)h+='
    '+fj(c.response)+'
    '; + else h+='无响应'; + h+='
    '; + panel.innerHTML=h; +} +function pfFill(v){ + const qr=document.getElementById('q-rows'); + const fr=document.getElementById('f-rows'); + if(qr)qr.innerHTML=''; + if(v===''){ + if(C?.params){ + for(const p of C.params.filter(p=>p.in==='query'))addKVReq('q-rows',p.name,p.example!==undefined?String(p.example):'',p.required); + } + addKV('q-rows'); + const el=document.getElementById('xb');if(el)el.value=''; + if(fr){ + fr.innerHTML=''; + if(C?.params){for(const p of C.params.filter(p=>p.in==='form'))addKVReq('f-rows',p.name,p.example!==undefined?String(p.example):'',p.required);} + addKV('f-rows'); + } + document.getElementById('file-rows').innerHTML='';addFileRow();syncAuth();return; + } + const i=parseInt(v); + if(isNaN(i)||i<0||!C?.cases?.[i])return; + const c=C.cases[i]; + if(c.query){ + for(const[k,val]of Object.entries(c.query)){ + const req=C?.params?.find(p=>p.name===k&&p.in==='query')?.required||false; + addKVReq('q-rows',k,String(val),req); + } + } addKV('q-rows'); if(c.json){ - setAcc('json');const el=document.getElementById('xb');if(el){el.value=JSON.stringify(c.json,null,2);autoH(el);} + setAcc('json');const el=document.getElementById('xb'); + if(el){ + const rq=new Set((C?.params||[]).filter(p=>p.required&&p.in==='json').map(p=>p.name)); + let obj=c.json; + if(rq.size&&typeof obj==='object'&&obj!==null&&!Array.isArray(obj)){ + const sorted={}; + for(const k of Object.keys(obj).sort((a,b)=>(rq.has(b)?1:0)-(rq.has(a)?1:0)))sorted[k]=obj[k]; + obj=sorted; + } + el.value=JSON.stringify(obj,null,2);autoH(el); + } }else if(c.form){ - setAcc('form');const fr=document.getElementById('f-rows'); - if(fr){fr.innerHTML='';for(const[k,v]of Object.entries(c.form))addKV('f-rows',k,String(v));addKV('f-rows');} + setAcc('form'); + if(fr){ + fr.innerHTML=''; + for(const[k,val]of Object.entries(c.form)){ + const req=C?.params?.find(p=>p.name===k&&p.in==='form')?.required||false; + addKVReq('f-rows',k,String(val),req); + } + addKV('f-rows'); + } }else{ const el=document.getElementById('xb');if(el)el.value=''; - const fr=document.getElementById('f-rows');if(fr){fr.innerHTML='';addKV('f-rows');} + if(fr){fr.innerHTML='';addKV('f-rows');} } document.getElementById('file-rows').innerHTML=''; if(c.hasFile&&c.fileField)addFileRow(c.fileField); - addFileRow(); - syncAuth(); + addFileRow();syncAuth(); } function getKV(id){ const obj={}; - document.querySelectorAll('#'+id+' .kv').forEach(r=>{const ins=r.querySelectorAll('input');const k=ins[0].value.trim(),v=ins[1].value;if(k)obj[k]=v;}); + document.querySelectorAll('#'+id+' .kv').forEach(r=>{const ins=r.querySelectorAll('input');const k=ins[0].value.trim(),v=ins[1]?.value;if(k)obj[k]=v||'';}); return Object.keys(obj).length?obj:null; } @@ -487,27 +751,21 @@ async function send(){ let url=C.path; const qp=getKV('q-rows'); if(qp){const ps=new URLSearchParams();for(const[k,v]of Object.entries(qp))ps.set(k,v);url+='?'+ps.toString();} - const token=document.getElementById('tk').value; const authType=document.getElementById('at').value; - const opts={method:C.method,headers:{}}; if(authType==='cookie'){opts.credentials='same-origin';if(token)document.cookie='HOTIME='+token+';path=/';} else{opts.credentials='omit';} - const ch=getKV('h-rows');if(ch)for(const[k,v]of Object.entries(ch)){ if(k.toLowerCase()==='authorization'&&token&&authType==='header')continue; if(k.toLowerCase()==='cookie'&&token&&authType==='cookie')continue; opts.headers[k]=v; } if(token&&authType==='header')opts.headers['Authorization']=token; - const fileEls=document.querySelectorAll('#file-rows .kv'); const files=[];fileEls.forEach(r=>{const fnEl=r.querySelector('input:not([type="file"])');const fi=r.querySelector('input[type="file"]');if(!fnEl||!fi)return;const fn=fnEl.value.trim();if(fn&&fi.files.length)files.push({field:fn,file:fi.files[0]});}); - const isJson=document.getElementById('bt-json')?.classList.contains('on'); const bodyEl=document.getElementById('xb');const formKV=getKV('f-rows');let curlParts=''; - if(files.length){ const fd=new FormData();for(const f of files)fd.append(f.field,f.file); if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))fd.append(k,v); @@ -521,30 +779,44 @@ async function send(){ const fd=new FormData();for(const[k,v]of Object.entries(formKV))fd.append(k,v);opts.body=fd;delete opts.headers['Content-Type']; for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"'; } - let curl='curl -X '+C.method; for(const[k,v]of Object.entries(opts.headers)){if(k&&v)curl+=" -H '"+k+": "+v+"'";} if(token&&authType==='cookie')curl+=" --cookie 'HOTIME="+token+"'"; curl+=curlParts+' '+location.origin+url; - document.getElementById('curl-box').textContent=curl; - + const curlBox=document.getElementById('curl-box');if(curlBox)curlBox.textContent=curl; + rswitch(1); const t0=Date.now(); try{ const r=await fetch(url,opts);const ms=Date.now()-t0;const txt=await r.text();let pretty=txt; try{pretty=JSON.stringify(JSON.parse(txt),null,2);}catch(e){} - document.getElementById('resp-st').innerHTML='HTTP '+r.status+' '+ms+'ms'; - document.getElementById('resp-body').textContent=pretty; + const st=document.getElementById('resp-st');if(st)st.innerHTML='HTTP '+r.status+' '+ms+'ms'; + const rb=document.getElementById('resp-body');if(rb)rb.textContent=pretty; }catch(e){ - document.getElementById('resp-st').innerHTML='失败'; - document.getElementById('resp-body').textContent=e.message; + const st=document.getElementById('resp-st');if(st)st.innerHTML='失败'; + const rb=document.getElementById('resp-body');if(rb)rb.textContent=e.message; } - btn.disabled=false;btn.textContent='发送请求'; + btn.disabled=false;btn.textContent='发送'; } function copyEl(id){navigator.clipboard.writeText(document.getElementById(id).textContent).then(()=>{const b=event.target;b.textContent='已复制';setTimeout(()=>{b.textContent='复制'},1200);});} function cpNext(btn){let el=btn.closest('h5').nextElementSibling;let t='';while(el&&el.tagName!=='H5'){if(el.tagName==='PRE')t+=(t?'\n':'')+el.textContent;el=el.nextElementSibling;}navigator.clipboard.writeText(t).then(()=>{btn.textContent='已复制';setTimeout(()=>{btn.textContent='复制'},1200);});} function fj(o){return esc(JSON.stringify(o,null,2));} -function kvList(o){let h='';for(const[k,v]of Object.entries(o))h+='
    '+esc(k)+''+esc(String(v))+'
    ';return h;} +function fjp(o,params){ + if(!params||!params.length||typeof o!=='object'||o===null||Array.isArray(o))return fj(o); + const rq=new Set(params.filter(p=>p.required).map(p=>p.name)); + const keys=Object.keys(o).sort((a,b)=>(rq.has(b)?1:0)-(rq.has(a)?1:0)); + const lines=keys.map(k=>' "'+(rq.has(k)?'*':'')+k+'": '+JSON.stringify(o[k])); + return esc('{\n'+lines.join(',\n')+'\n}'); +} +function kvList(o,params){ + const rq=new Set((params||[]).filter(p=>p.required).map(p=>p.name)); + let h=''; + for(const[k,v]of Object.entries(o)){ + const star=rq.has(k)?'*':''; + h+='
    '+star+''+esc(k)+''+esc(String(v))+'
    '; + } + return h; +} function esc(s){return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');} ` }