feat(swagger): 增强 Swagger 生成逻辑与参数推断

- 新增参数推断功能,自动从测试用例中推断 API 参数的必填性、类型和示例值
- 更新 GenerateSwagger 方法,支持在生成的 API 规范中包含推断的参数信息
- 优化文档,增加对新功能的说明,包括参数推断和覆盖率字段的详细描述
- 改进调试控制台的结构,支持模块化输出和独立导航页生成
- 增强并发保护与缓存隔离机制,确保测试环境的稳定性
This commit is contained in:
2026-03-15 10:36:49 +08:00
parent 87098a9180
commit 71e415dfe1
2 changed files with 549 additions and 176 deletions
+156 -55
View File
@@ -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,生产代码路径不受任何影响。
+393 -121
View File
@@ -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] = &paramInfo{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] = &paramInfo{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] = &paramInfo{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}
</style></head><body>
<div id="side">
@@ -325,7 +482,8 @@ function render(){
}
ph+='<div class="l2h" onclick="tog(this)"><span class="ar">&#9654;</span>'+c+'<span class="cn">'+es.length+'</span></div><div class="sub">'+eh+'</div>';
}
h+='<div class="l1"><div class="l1h" onclick="tog(this)"><span class="ar o">&#9654;</span>'+p+'</div><div class="sub o">'+ph+'</div></div>';
const totalEps=Object.values(T[p]).reduce((s,es)=>s+es.length,0);
h+='<div class="l1"><div class="l1h" onclick="tog(this)"><span class="ar o">&#9654;</span>'+p+'<span class="cn">'+totalEps+'</span></div><div class="sub o">'+ph+'</div></div>';
}
document.getElementById('tree').innerHTML=h||'<div style="padding:30px;color:#555;text-align:center">无匹配</div>';
}
@@ -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='<div class="ehd"><span class="mt mt-'+m+'" style="font-size:14px;padding:4px 10px">'+e.method+'</span><span class="pa">'+e.path+'</span><span class="ds">'+e.summary+'</span>';
const e=C;if(!e)return;const m=e.method.toLowerCase();const cnt=e.cases?.length||0;
let h='<div class="ct-hd"><div class="ehd">';
h+='<span class="mt mt-'+m+'" style="font-size:13px;padding:3px 8px">'+e.method+'</span>';
h+='<span class="pa">'+esc(e.path)+'</span><span class="ds">'+esc(e.summary)+'</span>';
if(cnt)h+='<span class="case-cnt">'+cnt+' 用例</span>';
if(!e.tested)h+='<span class="nt">未测试</span>';
h+='</div><div class="tabs"><div class="tab on" onclick="stab(0,this)">测试用例'+(e.cases?'('+e.cases.length+')':'')+'</div><div class="tab" onclick="stab(1,this)">接口调试</div></div>';
h+='<div class="pn on" id="p0">'+cases(e)+'</div><div class="pn" id="p1">'+exec(e)+'</div>';
h+='</div></div>';
h+='<div class="split">';
h+='<div class="split-l">'+buildLeft(e)+'</div>';
h+='<div class="split-r">';
h+='<div class="rtabs"><div class="rtab on" id="rt0" onclick="rswitch(0)">测试用例'+(cnt?'('+cnt+')':'')+'</div><div class="rtab" id="rt1" onclick="rswitch(1)">发送结果</div></div>';
h+='<div class="rpn on" id="rp0"><div id="case-panel" style="padding:4px 0"></div></div>';
h+='<div class="rpn" id="rp1">'+respPanel()+'</div>';
h+='</div></div>';
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 '<div style="color:#555;padding:20px">暂无测试用例</div>';
const _at=document.getElementById('at')?.value||'header';
const _tk=document.getElementById('tk')?.value||'';
let h='';
for(let i=0;i<e.cases.length;i++){const c=e.cases[i];
const ct=c.json?'application/json':c.form||c.hasFile?'multipart/form-data':'';
let hdr='Content-Type: '+(ct||'none');
if(_tk&&_at!=='none'){if(_at==='header')hdr+='\nAuthorization: '+_tk;else if(_at==='cookie')hdr+='\nCookie: HOTIME='+_tk;else if(_at==='query')hdr+='\n(token via URL ?token='+_tk+')';}
h+='<div class="cs"><div class="csh" onclick="accordion(this)"><span class="ar'+(i===0?' o':'')+'">&#9654;</span><span class="dot '+(c.passed?'ok':'er')+'"></span>'+esc(c.name)+'<span class="ml">'+c.method+'</span></div>';
h+='<div class="csb'+(i===0?' o':'')+'"><div class="csr"><div class="csc">';
h+='<h5>请求头 <button class="cp-sm" onclick="event.stopPropagation();cpNext(this)">复制</button></h5>';
h+='<pre class="j">'+esc(hdr)+'</pre>';
h+='<h5>请求参数 <button class="cp-sm" onclick="event.stopPropagation();cpNext(this)">复制</button></h5>';
if(c.query)h+='<div class="pc"><div class="pc-hd">QUERY 参数</div><div class="pc-bd">'+kvList(c.query)+'</div></div>';
if(c.json)h+='<div class="pc"><div class="pc-hd">JSON BODY</div><div class="pc-bd"><pre class="j" style="margin:0">'+fj(c.json)+'</pre></div></div>';
if(c.form)h+='<div class="pc"><div class="pc-hd">FORM 表单</div><div class="pc-bd">'+kvList(c.form)+'</div></div>';
if(c.hasFile)h+='<div class="pc"><div class="pc-hd">文件上传</div><div class="pc-bd"><div class="kv-ro"><span class="kk">字段名</span><span class="vv">'+esc(c.fileField)+'</span></div></div></div>';
if(!c.query&&!c.json&&!c.form&&!c.hasFile)h+='<span style="color:#555">无参数</span>';
h+='</div><div class="csc"><h5>响应 <button class="cp-sm" onclick="event.stopPropagation();cpNext(this)">复制</button></h5>';
if(c.response)h+='<pre class="j">'+fj(c.response)+'</pre>';
else h+='<span style="color:#555">无</span>';
h+='</div></div></div></div>';
h+='<div class="csb'+(i===0?' o':'')+'"><div style="padding:8px 10px">';
const curlId='cc'+i;const respId='cr'+i;
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+curlId+'\')">复制</button></div>';
h+='<div class="curl" id="'+curlId+'">'+esc(caseCurl(e,c))+'</div>';
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+respId+'\')" style="margin-left:auto">复制</button></div>';
if(c.response)h+='<pre class="j" id="'+respId+'">'+fj(c.response)+'</pre>';
else h+='<span style="color:#555" id="'+respId+'">无响应</span>';
h+='</div></div></div>';
}
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='<option value="">-- 手动填写 --</option>';
if(e.cases)for(let i=0;i<e.cases.length;i++)opts+='<option value="'+i+'">'+esc(e.cases[i].name)+'</option>';
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+='<div style="display:flex;align-items:center;gap:6px;margin-bottom:10px">';
l+='<span style="color:var(--fg2);font-size:11px;white-space:nowrap">预填用例</span>';
l+='<select id="pf" onchange="pf()" style="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">'+opts+'</select>';
l+='<button class="sbtn" onclick="send()" id="sbtn">发送请求</button>';
l+='</div>';
let defH='<div class="kv"><input value="Content-Type"><input id="ct-val" value="'+(defBody==='json'?'application/json':'application/x-www-form-urlencoded')+'"><button onclick="this.parentElement.remove()">&#215;</button></div>';
if(tk&&at==='header')defH+='<div class="kv" data-auth="1"><input value="Authorization"><input value="'+esc(tk)+'"><button onclick="this.parentElement.remove()">&#215;</button></div>';
if(tk&&at==='cookie')defH+='<div class="kv" data-auth="1"><input value="Cookie"><input value="HOTIME='+esc(tk)+'"><button onclick="this.parentElement.remove()">&#215;</button></div>';
l+='<div class="sec" style="margin-top:0"><div class="row-hd"><span class="sec-title">请求头 (Headers)</span><button class="addbtn" onclick="addKV(\'h-rows\')">+ 添加</button></div><div id="h-rows">'+defH+'</div></div>';
let qDef='';
if(tk&&at==='query')qDef='<div class="kv" data-auth="1"><input value="token"><input value="'+esc(tk)+'"><button onclick="this.parentElement.remove()">&#215;</button></div>';
qDef+='<div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove()">&#215;</button></div>';
l+='<div class="sec"><div class="row-hd"><span class="sec-title">Query 参数</span><button class="addbtn" onclick="addKV(\'q-rows\')">+ 添加</button></div><div id="q-rows">'+qDef+'</div></div>';
l+='<div class="sec"><div class="row-hd"><span class="sec-title">文件上传</span><button class="addbtn" onclick="addFileRow()">+ 添加</button></div><div id="file-rows"><div class="kv"><input placeholder="字段名" value="file" style="max-width:120px"><input type="file"><button onclick="this.parentElement.remove()">&#215;</button></div></div></div>';
l+='<div class="sec"><div class="sec-title" style="margin-bottom:4px">请求体</div>';
l+='<div class="bd-acc">';
l+='<div class="bd-hd'+(defBody==='form'?' on':'')+'" data-t="form" onclick="togBody(\'form\')"><span class="ar'+(defBody==='form'?' o':'')+'">&#9654;</span>表单 (Form)<button class="addbtn" onclick="event.stopPropagation();setAcc(\'form\');addKV(\'f-rows\')">+ 添加</button></div>';
l+='<div id="bt-form" class="bd-bd'+(defBody==='form'?' on':'')+'"><div id="f-rows"><div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove()">&#215;</button></div></div></div>';
l+='<div class="bd-hd'+(defBody==='json'?' on':'')+'" data-t="json" onclick="togBody(\'json\')"><span class="ar'+(defBody==='json'?' o':'')+'">&#9654;</span>JSON (application/json)</div>';
l+='<div id="bt-json" class="bd-bd'+(defBody==='json'?' on':'')+'"><textarea id="xb" oninput="autoH(this)" placeholder=\'{"key":"value"}\'></textarea></div>';
l+='</div></div>';
function respPanel(){
let r='';
r+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="copyEl(\'curl-box\')">复制</button></div>';
r+='<div class="curl" id="curl-box">发送请求后生成</div>';
r+='<div class="row-hd" style="margin:10px 0 4px"><span class="sec-title">响应 <span id="resp-st"></span></span><button class="cp-sm" onclick="copyEl(\'resp-body\')">复制</button></div>';
r+='<pre class="j" id="resp-body" style="min-height:60px;max-height:calc(100vh - 280px)">发送请求后显示</pre>';
r+='<pre class="j" id="resp-body" style="min-height:60px;max-height:calc(100vh - 200px)">发送请求后显示</pre>';
return r;
}
return '<div class="dbg"><div class="dbg-l">'+l+'</div><div class="dbg-r">'+r+'</div></div>';
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+='<div style="display:flex;align-items:center;gap:6px;margin-bottom:10px">';
l+='<span style="color:var(--fg2);font-size:11px;white-space:nowrap">预填</span>';
l+='<div class="pf-wrap"><button class="pf-btn" id="pf-btn" onclick="togglePfDd(event)"><span class="dot" id="pf-dot" style="display:none;flex-shrink:0"></span><span id="pf-label" style="flex:1;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--fg2)">选择用例...</span><span style="color:var(--fg2);font-size:10px;flex-shrink:0">▾</span></button><ul class="pf-dd" id="pf-dd"></ul></div>';
l+='<button class="sbtn" onclick="send()" id="sbtn">发送</button>';
l+='</div>';
let defH='<div class="kv"><input value="Content-Type"><input id="ct-val" value="'+(defBody==='json'?'application/json':'application/x-www-form-urlencoded')+'"><button onclick="this.parentElement.remove()">&#215;</button></div>';
if(tk&&at==='header')defH+='<div class="kv" data-auth="1"><input value="Authorization"><input value="'+esc(tk)+'"><button onclick="this.parentElement.remove()">&#215;</button></div>';
if(tk&&at==='cookie')defH+='<div class="kv" data-auth="1"><input value="Cookie"><input value="HOTIME='+esc(tk)+'"><button onclick="this.parentElement.remove()">&#215;</button></div>';
l+='<div class="sec" style="margin-top:0"><div class="row-hd"><span class="sec-title">请求头</span><button class="addbtn" onclick="addKV(\'h-rows\')">+ 添加</button></div><div id="h-rows">'+defH+'</div></div>';
let qDef='';
if(tk&&at==='query')qDef='<div class="kv" data-auth="1"><input value="token"><input value="'+esc(tk)+'"><button onclick="this.parentElement.remove()">&#215;</button></div>';
qDef+='<div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove()">&#215;</button></div>';
l+='<div class="sec"><div class="row-hd"><span class="sec-title">Query 参数</span><button class="addbtn" onclick="addKV(\'q-rows\')">+ 添加</button></div><div id="q-rows">'+qDef+'</div></div>';
l+='<div class="sec"><div class="row-hd"><span class="sec-title">文件上传</span><button class="addbtn" onclick="addFileRow()">+ 添加</button></div><div id="file-rows"><div class="kv"><input placeholder="字段名" value="file" style="max-width:120px"><input type="file"><button onclick="this.parentElement.remove()">&#215;</button></div></div></div>';
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+='<div class="sec"><div class="sec-title" style="margin-bottom:4px">请求体</div><div class="bd-acc">';
l+='<div class="bd-hd'+(defBody==='form'?' on':'')+'" data-t="form" onclick="togBody(\'form\')"><span class="ar'+(defBody==='form'?' o':'')+'">&#9654;</span>表单 (Form)<button class="addbtn" onclick="event.stopPropagation();setAcc(\'form\');addKV(\'f-rows\')">+ 添加</button></div>';
l+='<div id="bt-form" class="bd-bd'+(defBody==='form'?' on':'')+'"><div id="f-rows"><div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove()">&#215;</button></div></div></div>';
l+='<div class="bd-hd'+(defBody==='json'?' on':'')+'" data-t="json" onclick="togBody(\'json\')"><span class="ar'+(defBody==='json'?' o':'')+'">&#9654;</span>JSON</div>';
l+='<div id="bt-json" class="bd-bd'+(defBody==='json'?' on':'')+'"><textarea id="xb" oninput="autoH(this)" placeholder="'+(jsonPH?esc(jsonPH):'{"key":"value"}')+'"></textarea></div>';
l+='</div></div>';
return l;
}
function addKV(id,k,v){
@@ -425,6 +599,12 @@ function addKV(id,k,v){
row.innerHTML='<input placeholder="key" value="'+esc(k||'')+'"><input placeholder="value" value="'+esc(v||'')+'"><button onclick="this.parentElement.remove()">&#215;</button>';
document.getElementById(id).appendChild(row);
}
function addKVReq(id,k,v,req){
const row=document.createElement('div');row.className='kv';
const star=req?'<span class="req-star">*</span>':'';
row.innerHTML=star+'<input placeholder="key" value="'+esc(k||'')+'"><input placeholder="value" value="'+esc(v||'')+'"><button onclick="this.parentElement.remove()">&#215;</button>';
document.getElementById(id).appendChild(row);
}
function addFileRow(field){
const row=document.createElement('div');row.className='kv';
row.innerHTML='<input placeholder="字段名" value="'+esc(field||'file')+'" style="max-width:120px"><input type="file"><button onclick="this.parentElement.remove()">&#215;</button>';
@@ -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='<span class="dot '+(c.passed?'ok':'er')+'"></span><span>'+esc(c.name)+'</span>';
li.onclick=ev=>{ev.stopPropagation();pfSelect(i);};
dd.appendChild(li);
}
}
const manual=document.createElement('li');manual.className='pf-opt';manual.dataset.idx='';
manual.innerHTML='<span style="color:var(--fg2);font-style:italic">-- 手动填写 --</span>';
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='<div style="color:#555;padding:16px 10px">选择一个测试用例查看响应</div>';return;}
const c=e.cases[i];
let h='<div style="padding:8px 10px">';
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="copyEl(\'c-curl\')" style="margin-left:auto">复制</button></div>';
h+='<div class="curl" id="c-curl">'+esc(caseCurl(e,c))+'</div>';
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span><button class="cp-sm" onclick="copyEl(\'c-resp\')" style="margin-left:auto">复制</button></div>';
if(c.response)h+='<pre class="j" id="c-resp">'+fj(c.response)+'</pre>';
else h+='<span style="color:#555" id="c-resp">无响应</span>';
h+='</div>';
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='<span style="color:'+(r.ok?'var(--green)':'var(--red)')+'">HTTP '+r.status+'</span> <span style="color:#555">'+ms+'ms</span>';
document.getElementById('resp-body').textContent=pretty;
const st=document.getElementById('resp-st');if(st)st.innerHTML='<span style="color:'+(r.ok?'var(--green)':'var(--red)')+'">HTTP '+r.status+'</span> <span style="color:#555">'+ms+'ms</span>';
const rb=document.getElementById('resp-body');if(rb)rb.textContent=pretty;
}catch(e){
document.getElementById('resp-st').innerHTML='<span style="color:var(--red)">失败</span>';
document.getElementById('resp-body').textContent=e.message;
const st=document.getElementById('resp-st');if(st)st.innerHTML='<span style="color:var(--red)">失败</span>';
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+='<div class="kv-ro"><span class="kk">'+esc(k)+'</span><span class="vv">'+esc(String(v))+'</span></div>';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)?'<span class="req-star">*</span>':'';
h+='<div class="kv-ro">'+star+'<span class="kk">'+esc(k)+'</span><span class="vv">'+esc(String(v))+'</span></div>';
}
return h;
}
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
</script></body></html>`
}