87098a9180
- 在 HoTimeDB 中新增 testMu 互斥锁,确保 testTx 的串行访问,解决并发冲突问题 - 在 BeginTestTx 中初始化 testMu,确保测试事务的安全性 - 在 HoTimeCache 中新增 DisableDbCache 方法,测试模式下禁用 DB 和 Redis 缓存,避免锁等待超时 - 更新 Select 方法,确保在 testTx 激活时跳过缓存逻辑,提升测试稳定性 - 优化 Swagger 生成逻辑,支持模块化输出和导航页生成 - 移除冗余的调试日志代码,提升代码整洁性
578 lines
33 KiB
Go
578 lines
33 KiB
Go
package hotime
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
. "code.hoteas.com/golang/hotime/common"
|
|
)
|
|
|
|
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"`
|
|
}
|
|
|
|
func (that *TestApp) GenerateSwagger(title, version, outputDir string) error {
|
|
if outputDir == "" {
|
|
outputDir = "tpt"
|
|
}
|
|
swaggerRoot := filepath.Join(outputDir, "swagger")
|
|
if err := os.MkdirAll(swaggerRoot, os.ModePerm); err != nil {
|
|
return fmt.Errorf("创建 swagger 目录失败: %w", err)
|
|
}
|
|
|
|
that.collector.mu.Lock()
|
|
records := make([]TestRecord, len(that.collector.Records))
|
|
copy(records, that.collector.Records)
|
|
that.collector.mu.Unlock()
|
|
|
|
pathRecords := map[string][]TestRecord{}
|
|
for _, r := range records {
|
|
pathRecords[r.Path] = append(pathRecords[r.Path], r)
|
|
}
|
|
|
|
for projName, projDef := range that.projs {
|
|
moduleDir := filepath.Join(swaggerRoot, projName)
|
|
if err := os.MkdirAll(moduleDir, os.ModePerm); err != nil {
|
|
return fmt.Errorf("创建模块目录 %s 失败: %w", projName, err)
|
|
}
|
|
|
|
endpoints := []map[string]interface{}{}
|
|
for ctrName, ctr := range projDef.Proj {
|
|
for methodName := range ctr {
|
|
apiPath := "/" + projName + "/" + ctrName + "/" + methodName
|
|
ctrTest, hasCtr := projDef.Tests[ctrName]
|
|
hasTest := false
|
|
var apiTest ApiTestDef
|
|
if hasCtr {
|
|
apiTest, hasTest = ctrTest[methodName]
|
|
}
|
|
|
|
summary := methodName
|
|
if hasTest {
|
|
summary = apiTest.Desc
|
|
}
|
|
|
|
recs := pathRecords[apiPath]
|
|
httpMethod := "POST"
|
|
if len(recs) > 0 && recs[0].Method != "" {
|
|
httpMethod = strings.ToUpper(recs[0].Method)
|
|
}
|
|
|
|
var cases []swaggerTestCase
|
|
for _, r := range recs {
|
|
tc := swaggerTestCase{
|
|
Name: r.CaseName, Method: r.Method, Passed: r.Passed,
|
|
Response: r.ResponseBody,
|
|
}
|
|
if r.Query != nil {
|
|
tc.Query = r.Query
|
|
}
|
|
if r.JsonBody != nil {
|
|
tc.Json = r.JsonBody
|
|
}
|
|
if r.FormBody != nil {
|
|
tc.Form = r.FormBody
|
|
}
|
|
if r.HasFile {
|
|
tc.HasFile = true
|
|
tc.FileField = r.FileField
|
|
}
|
|
cases = append(cases, tc)
|
|
}
|
|
|
|
endpoints = append(endpoints, map[string]interface{}{
|
|
"path": apiPath,
|
|
"project": projName,
|
|
"ctr": ctrName,
|
|
"method": httpMethod,
|
|
"summary": summary,
|
|
"tested": hasTest,
|
|
"cases": cases,
|
|
})
|
|
}
|
|
}
|
|
|
|
sort.Slice(endpoints, func(i, j int) bool {
|
|
return endpoints[i]["path"].(string) < endpoints[j]["path"].(string)
|
|
})
|
|
|
|
spec := map[string]interface{}{
|
|
"title": title,
|
|
"version": version,
|
|
"endpoints": endpoints,
|
|
}
|
|
|
|
specJSON, err := json.MarshalIndent(spec, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("序列化 JSON 失败: %w", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(moduleDir, "api-spec.json"), specJSON, os.ModePerm); err != nil {
|
|
return fmt.Errorf("写入 %s/api-spec.json 失败: %w", projName, err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(moduleDir, "index.html"), []byte(apiConsoleHTML()), os.ModePerm); err != nil {
|
|
return fmt.Errorf("写入 %s/index.html 失败: %w", projName, err)
|
|
}
|
|
fmt.Printf("Swagger 文档已生成: %s\n", moduleDir)
|
|
}
|
|
|
|
if err := generateSwaggerPortal(swaggerRoot); err != nil {
|
|
return fmt.Errorf("生成导航页失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func generateSwaggerPortal(swaggerRoot string) error {
|
|
entries, err := os.ReadDir(swaggerRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var modules []struct{ Name, Title string }
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
specPath := filepath.Join(swaggerRoot, e.Name(), "api-spec.json")
|
|
title := e.Name()
|
|
if data, err := os.ReadFile(specPath); err == nil {
|
|
var spec map[string]interface{}
|
|
if json.Unmarshal(data, &spec) == nil {
|
|
if t, ok := spec["title"].(string); ok && t != "" {
|
|
title = t
|
|
}
|
|
}
|
|
}
|
|
modules = append(modules, struct{ Name, Title string }{e.Name(), title})
|
|
}
|
|
sort.Slice(modules, func(i, j int) bool { return modules[i].Name < modules[j].Name })
|
|
|
|
html := swaggerPortalHTML(modules)
|
|
return os.WriteFile(filepath.Join(swaggerRoot, "index.html"), []byte(html), os.ModePerm)
|
|
}
|
|
|
|
func swaggerPortalHTML(modules []struct{ Name, Title string }) string {
|
|
var cards string
|
|
for _, m := range modules {
|
|
cards += `<a class="card" href="` + m.Name + `/index.html"><div class="icon">` + strings.ToUpper(m.Name[:1]) + m.Name[1:] + `</div><div class="title">` + m.Title + `</div><div class="sub">` + m.Name + `/</div></a>`
|
|
}
|
|
return `<!DOCTYPE html>
|
|
<html lang="zh-CN"><head><meta charset="utf-8"><title>API 文档中心</title>
|
|
<style>
|
|
:root{--bg:#1a1a2e;--bg2:#16213e;--bg3:#0f3460;--fg:#e0e0e0;--fg2:#888;--accent:#4fc3f7;--border:#2a2a4a}
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{min-height:100vh;background:var(--bg);color:var(--fg);font:14px/1.6 -apple-system,sans-serif;display:flex;flex-direction:column;align-items:center;padding:60px 20px}
|
|
h1{font-size:24px;color:var(--accent);margin-bottom:8px}
|
|
.desc{color:var(--fg2);margin-bottom:40px;font-size:13px}
|
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px;width:100%;max-width:900px}
|
|
.card{display:block;background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:24px 20px;text-decoration:none;color:var(--fg);transition:all .2s}
|
|
.card:hover{border-color:var(--accent);transform:translateY(-2px);box-shadow:0 4px 20px rgba(79,195,247,.15)}
|
|
.icon{font-size:20px;font-weight:700;color:var(--accent);margin-bottom:8px}
|
|
.title{font-size:15px;font-weight:600;margin-bottom:4px}
|
|
.sub{font-size:12px;color:var(--fg2)}
|
|
</style></head><body>
|
|
<h1>API 文档中心</h1>
|
|
<div class="desc">选择一个模块查看接口文档与调试控制台</div>
|
|
<div class="grid">` + cards + `</div>
|
|
</body></html>`
|
|
}
|
|
|
|
func apiConsoleHTML() string {
|
|
return `<!DOCTYPE html>
|
|
<html lang="zh-CN"><head><meta charset="utf-8"><title>API 调试控制台</title>
|
|
<style>
|
|
: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)}
|
|
.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}
|
|
.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}
|
|
.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}
|
|
.mt{font-size:9px;padding:1px 4px;border-radius:2px;font-weight:700;flex-shrink: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}
|
|
.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}
|
|
|
|
.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}
|
|
.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 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: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 .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}
|
|
.bd-hd{padding:6px 8px;cursor:pointer;display:flex;align-items:center;gap:5px;color:var(--fg2);font-size:11px;background:var(--bg2);user-select:none}
|
|
.bd-hd+.bd-hd,.bd-bd+.bd-hd{border-top:1px solid var(--border)}
|
|
.bd-hd.on{color:var(--accent)}.bd-hd .ar{font-size:8px;transition:transform .15s;color:#555;margin-right:2px}.bd-hd.on .ar{transform:rotate(90deg);color:var(--accent)}
|
|
.bd-hd .addbtn{margin-left:auto}
|
|
.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)}
|
|
</style></head><body>
|
|
|
|
<div id="side">
|
|
<div class="shd">
|
|
<h3 id="dt"></h3>
|
|
<input id="q" placeholder="搜索接口..." oninput="render()">
|
|
<div class="sft">
|
|
<button class="on" onclick="sf('all',this)">全部</button>
|
|
<button onclick="sf('yes',this)">已测试</button>
|
|
<button onclick="sf('no',this)">未测试</button>
|
|
<button onclick="sf('pass',this)">通过</button>
|
|
<button onclick="sf('fail',this)">未通过</button>
|
|
</div>
|
|
</div>
|
|
<div id="tree"></div>
|
|
</div>
|
|
|
|
<div id="main">
|
|
<div id="bar">
|
|
<label>认证</label>
|
|
<select id="at" onchange="syncAuth()"><option value="none">无认证</option><option value="header">Header (Authorization)</option><option value="query">URL (?token=)</option><option value="cookie">Cookie</option></select>
|
|
<input id="tk" placeholder="Token(登录后返回的 32 位字符串)" oninput="syncAuth()">
|
|
</div>
|
|
<div id="ct"><div style="text-align:center;color:var(--fg2);padding:80px 20px">← 选择一个接口开始</div></div>
|
|
</div>
|
|
|
|
<script>
|
|
let D=null,F='all',C=null;
|
|
|
|
fetch('./api-spec.json').then(r=>r.json()).then(d=>{
|
|
D=d;document.getElementById('dt').textContent=d.title+' v'+d.version;document.title=d.title;render();
|
|
});
|
|
|
|
function epPass(e){if(!e.cases?.length)return false;return e.cases.every(x=>x.passed);}
|
|
function epFail(e){if(!e.cases?.length)return e.tested;return e.cases.some(x=>!x.passed);}
|
|
function render(){
|
|
if(!D)return;const q=document.getElementById('q').value.toLowerCase();const T={};
|
|
for(const e of D.endpoints){
|
|
if(F==='yes'&&!e.tested)continue;
|
|
if(F==='no'&&e.tested)continue;
|
|
if(F==='pass'&&!epPass(e))continue;
|
|
if(F==='fail'&&!epFail(e))continue;
|
|
if(q&&!e.summary.toLowerCase().includes(q)&&!e.path.toLowerCase().includes(q)&&!e.ctr.toLowerCase().includes(q))continue;
|
|
if(!T[e.project])T[e.project]={};if(!T[e.project][e.ctr])T[e.project][e.ctr]=[];T[e.project][e.ctr].push(e);
|
|
}
|
|
let h='';
|
|
for(const p of Object.keys(T).sort()){let ph='';
|
|
for(const c of Object.keys(T[p]).sort()){const es=T[p][c];let eh='';
|
|
for(const e of es){const m=e.method.toLowerCase();
|
|
let tg='';if(!e.tested)tg='<span class="tg tg-no">未测试</span>';
|
|
else if(e.cases?.length){const pc=e.cases.filter(x=>x.passed).length;tg=pc===e.cases.length?'<span class="tg tg-ok">'+pc+'/'+e.cases.length+'</span>':'<span class="tg tg-err">'+pc+'/'+e.cases.length+'</span>';}
|
|
const mn=e.path.split('/').pop();const lbl=e.tested&&e.summary!==mn?mn+' '+e.summary:e.summary;
|
|
eh+='<div class="ep'+(C?.path===e.path?' act':'')+'" data-p="'+e.path+'" onclick="go(this)"><span class="mt mt-'+m+'">'+e.method+'</span><span class="nm" title="'+e.path+'">'+lbl+'</span>'+tg+'</div>';
|
|
}
|
|
ph+='<div class="l2h" onclick="tog(this)"><span class="ar">▶</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">▶</span>'+p+'</div><div class="sub o">'+ph+'</div></div>';
|
|
}
|
|
document.getElementById('tree').innerHTML=h||'<div style="padding:30px;color:#555;text-align:center">无匹配</div>';
|
|
}
|
|
function sf(f,b){F=f;document.querySelectorAll('.sft button').forEach(x=>x.classList.remove('on'));b.classList.add('on');render();}
|
|
function tog(e){e.querySelector('.ar').classList.toggle('o');e.nextElementSibling.classList.toggle('o');}
|
|
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>';
|
|
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>';
|
|
document.getElementById('ct').innerHTML=h;
|
|
}
|
|
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 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':'')+'">▶</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>';
|
|
}
|
|
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'));
|
|
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()">×</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()">×</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()">×</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()">×</button></div>';
|
|
qDef+='<div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove()">×</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()">×</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':'')+'">▶</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()">×</button></div></div></div>';
|
|
l+='<div class="bd-hd'+(defBody==='json'?' on':'')+'" data-t="json" onclick="togBody(\'json\')"><span class="ar'+(defBody==='json'?' o':'')+'">▶</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>';
|
|
|
|
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>';
|
|
|
|
return '<div class="dbg"><div class="dbg-l">'+l+'</div><div class="dbg-r">'+r+'</div></div>';
|
|
}
|
|
|
|
function addKV(id,k,v){
|
|
const row=document.createElement('div');row.className='kv';
|
|
row.innerHTML='<input placeholder="key" value="'+esc(k||'')+'"><input placeholder="value" value="'+esc(v||'')+'"><button onclick="this.parentElement.remove()">×</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()">×</button>';
|
|
document.getElementById('file-rows').appendChild(row);
|
|
}
|
|
function setAcc(t){
|
|
document.querySelectorAll('.bd-hd').forEach(h=>{h.classList.toggle('on',h.dataset.t===t);const a=h.querySelector('.ar');if(a)a.classList.toggle('o',h.dataset.t===t);});
|
|
document.getElementById('bt-json').classList.toggle('on',t==='json');
|
|
document.getElementById('bt-form').classList.toggle('on',t==='form');
|
|
const ctVal=document.getElementById('ct-val');
|
|
if(ctVal)ctVal.value=t==='json'?'application/json':'application/x-www-form-urlencoded';
|
|
}
|
|
function togBody(t){
|
|
const cur=document.getElementById(t==='json'?'bt-json':'bt-form').classList.contains('on');
|
|
setAcc(cur?(t==='json'?'form':'json'):t);
|
|
}
|
|
function autoH(el){el.style.height='36px';el.style.height=Math.min(el.scrollHeight,window.innerHeight*0.4)+'px';}
|
|
function syncAuth(){
|
|
const tk=document.getElementById('tk').value;const at=document.getElementById('at').value;
|
|
const hr=document.getElementById('h-rows');const qr=document.getElementById('q-rows');
|
|
if(!hr||!qr)return;
|
|
hr.querySelectorAll('[data-auth]').forEach(e=>e.remove());
|
|
qr.querySelectorAll('[data-auth]').forEach(e=>e.remove());
|
|
if(!tk)return;
|
|
function mkAuth(k,v,parent){const d=document.createElement('div');d.className='kv';d.dataset.auth='1';d.innerHTML='<input value="'+esc(k)+'"><input value="'+esc(v)+'"><button onclick="this.parentElement.remove()">×</button>';parent.insertBefore(d,parent.querySelector('.kv:last-child'));}
|
|
if(at==='header')mkAuth('Authorization',tk,hr);
|
|
if(at==='cookie')mkAuth('Cookie','HOTIME='+tk,hr);
|
|
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));
|
|
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);}
|
|
}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');}
|
|
}else{
|
|
const el=document.getElementById('xb');if(el)el.value='';
|
|
const fr=document.getElementById('f-rows');if(fr){fr.innerHTML='';addKV('f-rows');}
|
|
}
|
|
document.getElementById('file-rows').innerHTML='';
|
|
if(c.hasFile&&c.fileField)addFileRow(c.fileField);
|
|
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;});
|
|
return Object.keys(obj).length?obj:null;
|
|
}
|
|
|
|
async function send(){
|
|
if(!C)return;const btn=document.getElementById('sbtn');btn.disabled=true;btn.textContent='请求中...';
|
|
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);
|
|
opts.body=fd;delete opts.headers['Content-Type'];
|
|
for(const f of files)curlParts+=' -F "'+f.field+'=@'+f.file.name+'"';
|
|
if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"';
|
|
}else if(isJson){
|
|
const b=bodyEl?.value?.trim();
|
|
if(b&&C.method!=='GET'){opts.body=b;curlParts=" -d '"+b+"'";}
|
|
}else if(formKV){
|
|
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 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;
|
|
}catch(e){
|
|
document.getElementById('resp-st').innerHTML='<span style="color:var(--red)">失败</span>';
|
|
document.getElementById('resp-body').textContent=e.message;
|
|
}
|
|
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 esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
</script></body></html>`
|
|
}
|
|
|
|
func inferSchema(v interface{}) map[string]interface{} {
|
|
if v == nil {
|
|
return map[string]interface{}{"type": "string"}
|
|
}
|
|
switch v.(type) {
|
|
case int, int64, int32, float64, float32:
|
|
return map[string]interface{}{"type": "number"}
|
|
case bool:
|
|
return map[string]interface{}{"type": "boolean"}
|
|
case map[string]interface{}, Map:
|
|
props := map[string]interface{}{}
|
|
var m map[string]interface{}
|
|
switch val := v.(type) {
|
|
case Map:
|
|
m = map[string]interface{}(val)
|
|
case map[string]interface{}:
|
|
m = val
|
|
}
|
|
for k, val := range m {
|
|
props[k] = inferSchema(val)
|
|
}
|
|
return map[string]interface{}{"type": "object", "properties": props}
|
|
default:
|
|
return map[string]interface{}{"type": "string"}
|
|
}
|
|
}
|