Files
hotime/testing_swagger.go
T
hoteas 16496a8dc0 feat(testing): Flows/FromCase、增量 swagger 与 Doc-Driven 门禁
补齐多接口流程联调与控制台体验,并落地仓内 Doc-Driven+TDD 总规则与文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 18:11:08 +08:00

1709 lines
76 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"`
Note string `json:"note,omitempty"`
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"`
Expect interface{} `json:"expect,omitempty"`
VerifyError string `json:"verifyError,omitempty"`
FailReason string `json:"failReason,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 {
switch v := s.(type) {
case float64:
return int(v)
case int64:
return int(v)
case int:
return v
}
}
return -1
}
inferType := func(v interface{}) string {
switch v.(type) {
case int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64,
float32, float64:
return "number"
case bool:
return "boolean"
case []interface{}, Slice:
return "array"
case map[string]interface{}, Map:
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、类型和示例值
// 成功用例(status=0)优先级最高,覆盖 in/typ/example,代表真实请求方式和数据类型
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 st == 0 {
p.in = "query"
}
if p.typ == "" && sv(v) != "" {
p.typ = inferType(v)
}
if st == 0 && sv(v) != "" {
p.typ = inferType(v)
p.example = v
}
}
for k, v := range c.Form {
if _, exists := params[k]; !exists {
params[k] = &paramInfo{in: "form"}
}
p := params[k]
if st == 0 {
p.in = "form"
}
if p.typ == "" && sv(v) != "" {
p.typ = inferType(v)
}
if st == 0 && sv(v) != "" {
p.typ = inferType(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 st == 0 {
p.in = "json"
}
if p.typ == "" && sv(v) != "" {
p.typ = inferType(v)
}
if st == 0 && sv(v) != "" {
p.typ = inferType(v)
p.example = v
}
}
}
}
// 第二轮:标记必填 — 参数存在但值为空 且 status==3(不限制 in 类型,因为错误用例可能用不同传参方式)
for _, c := range cases {
if getStatus(c.Response) != 3 {
continue
}
for k, v := range c.Query {
if p, ok := params[k]; ok && sv(v) == "" {
p.required = true
}
}
for k, v := range c.Form {
if p, ok := params[k]; ok && sv(v) == "" {
p.required = true
}
}
if jmap := toStrMap(c.Json); jmap != nil {
for k, v := range jmap {
if p, ok := params[k]; ok && sv(v) == "" {
p.required = true
}
}
}
}
// 第三轮:标记必填 — 字段在 status==3 用例中缺失,但在下一个用例中出现
collectKeys := func(c swaggerTestCase) map[string]bool {
keys := map[string]bool{}
for k := range c.Query {
keys[k] = true
}
for k := range c.Form {
keys[k] = true
}
if jm := toStrMap(c.Json); jm != nil {
for k := range jm {
keys[k] = true
}
}
return keys
}
for i := 0; i < len(cases)-1; i++ {
if getStatus(cases[i].Response) != 3 {
continue
}
curKeys := collectKeys(cases[i])
nextKeys := collectKeys(cases[i+1])
for k := range nextKeys {
if !curKeys[k] {
if p, ok := params[k]; ok {
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 {
if title == "" {
title = that.swaggerTitle
}
if version == "" {
version = that.swaggerVersion
}
if outputDir == "" {
outputDir = that.swaggerOutputDir()
}
if title == "" {
title = "API"
}
if version == "" {
version = "1.0.0"
}
that.swaggerEnabled = true
that.swaggerTitle = title
that.swaggerVersion = version
if that.swaggerDir == "" {
that.swaggerDir = outputDir
}
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)
visited := map[string]bool{}
for k, v := range that.collector.Visited {
visited[k] = v
}
visitedFlows := map[string]bool{}
for k, v := range that.collector.VisitedFlows {
visitedFlows[k] = v
}
that.collector.mu.Unlock()
pathRecords := map[string][]TestRecord{}
flowRecords := map[string][]TestRecord{}
for _, r := range records {
if r.FlowName != "" {
flowRecords[r.FlowName] = append(flowRecords[r.FlowName], r)
continue
}
pathRecords[r.Path] = append(pathRecords[r.Path], r)
}
fullComplete := that.isSwaggerFullComplete(visited, visitedFlows)
activeModules := map[string]bool{}
for projName, projDef := range that.projs {
activeModules[projName] = true
moduleDir := filepath.Join(swaggerRoot, projName)
if err := os.MkdirAll(moduleDir, os.ModePerm); err != nil {
return fmt.Errorf("创建模块目录 %s 失败: %w", projName, err)
}
specPath := filepath.Join(moduleDir, "api-spec.json")
existingEndpoints := loadExistingEndpoints(specPath)
existingFlows := loadExistingFlows(specPath)
endpoints := []map[string]interface{}{}
for ctrName, ctr := range projDef.Proj {
for methodName := range ctr {
apiPath := "/" + projName + "/" + ctrName + "/" + methodName
recs := pathRecords[apiPath]
existingEp := existingEndpoints[apiPath]
// 无本轮用例记录:一律保留旧 swagger,禁止空 cases 覆盖已测内容
if len(recs) == 0 && existingEp != nil {
endpoints = append(endpoints, existingEp)
continue
}
ctrTest, hasCtr := projDef.Tests[ctrName]
hasTest := false
var apiTest ApiTestDef
if hasCtr {
apiTest, hasTest = ctrTest[methodName]
}
summary := methodName
if hasTest {
summary = apiTest.Desc
}
endpoints = append(endpoints, that.buildEndpointMap(projName, ctrName, apiPath, summary, hasTest, recs, existingEp))
}
}
// 未全量时:保留旧 spec 中已不在当前 Proj 的 endpoint
if !fullComplete {
projPaths := map[string]bool{}
for ctrName, ctr := range projDef.Proj {
for methodName := range ctr {
projPaths["/"+projName+"/"+ctrName+"/"+methodName] = true
}
}
have := map[string]bool{}
for _, ep := range endpoints {
if p, ok := ep["path"].(string); ok {
have[p] = true
}
}
for path, ep := range existingEndpoints {
if !projPaths[path] && !have[path] {
endpoints = append(endpoints, ep)
}
}
}
sort.Slice(endpoints, func(i, j int) bool {
return endpoints[i]["path"].(string) < endpoints[j]["path"].(string)
})
flows := that.mergeFlows(existingFlows, flowRecords, visitedFlows, fullComplete)
if err := that.writeModuleSpec(moduleDir, title, version, endpoints, flows); err != nil {
return err
}
fmt.Printf("Swagger 文档已生成: %s\n", moduleDir)
}
if fullComplete {
if err := that.pruneOrphanSwaggerModules(swaggerRoot, activeModules); err != nil {
return err
}
}
if err := generateSwaggerPortal(swaggerRoot); err != nil {
return fmt.Errorf("生成导航页失败: %w", err)
}
return nil
}
func (that *TestApp) isSwaggerFullComplete(visited, visitedFlows map[string]bool) bool {
totalPaths := 0
visitedPaths := 0
for projName, projDef := range that.projs {
for ctrName, ctr := range projDef.Proj {
for methodName := range ctr {
totalPaths++
if visited["/"+projName+"/"+ctrName+"/"+methodName] {
visitedPaths++
}
}
}
}
if totalPaths == 0 || visitedPaths < totalPaths {
return false
}
for name := range that.flows {
if !visitedFlows[name] {
return false
}
}
return true
}
func (that *TestApp) buildEndpointMap(projName, ctrName, apiPath, summary string, hasTest bool, recs []TestRecord, existing map[string]interface{}) map[string]interface{} {
httpMethod := "POST"
if len(recs) > 0 && recs[0].Method != "" {
httpMethod = strings.ToUpper(recs[0].Method)
} else if existing != nil {
if m, ok := existing["method"].(string); ok && m != "" {
httpMethod = m
}
}
cases := make([]swaggerTestCase, 0, len(recs))
for _, r := range recs {
cases = append(cases, recordToSwaggerCase(r))
}
return map[string]interface{}{
"path": apiPath,
"project": projName,
"ctr": ctrName,
"method": httpMethod,
"summary": summary,
"tested": hasTest,
"cases": cases,
"params": inferParamsFromCases(cases),
}
}
func recordToSwaggerCase(r TestRecord) swaggerTestCase {
tc := swaggerTestCase{
Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed,
Response: r.ResponseBody, Expect: r.ExpectResult, VerifyError: r.VerifyError,
FailReason: r.FailReason,
}
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
}
return tc
}
func (that *TestApp) mergeFlows(existing map[string]map[string]interface{}, flowRecords map[string][]TestRecord, visitedFlows map[string]bool, fullComplete bool) []map[string]interface{} {
out := []map[string]interface{}{}
seen := map[string]bool{}
for name, recs := range flowRecords {
out = append(out, that.buildFlowMap(name, recs))
seen[name] = true
}
for name, flowDef := range that.flows {
if seen[name] {
continue
}
if visitedFlows[name] {
continue
}
if existingFlow, ok := existing[name]; ok {
out = append(out, existingFlow)
seen[name] = true
continue
}
_ = flowDef
}
if !fullComplete {
for name, ep := range existing {
if !seen[name] {
// 未全量:保留旧 flow(含已从注册删除的)
out = append(out, ep)
seen[name] = true
}
}
} else {
// 全量:只保留仍注册或本轮跑过的
registered := map[string]bool{}
for name := range that.flows {
registered[name] = true
}
filtered := out[:0]
for _, f := range out {
name, _ := f["name"].(string)
if registered[name] || visitedFlows[name] {
filtered = append(filtered, f)
}
}
out = filtered
}
sort.Slice(out, func(i, j int) bool {
return out[i]["name"].(string) < out[j]["name"].(string)
})
return out
}
func (that *TestApp) buildFlowMap(name string, recs []TestRecord) map[string]interface{} {
desc := name
group := ""
prep := ""
passed := true
steps := []map[string]interface{}{}
sort.Slice(recs, func(i, j int) bool {
if recs[i].FlowStepIndex != recs[j].FlowStepIndex {
return recs[i].FlowStepIndex < recs[j].FlowStepIndex
}
return recs[i].CaseName < recs[j].CaseName
})
for _, r := range recs {
if r.FlowDesc != "" {
desc = r.FlowDesc
}
if r.FlowGroup != "" {
group = r.FlowGroup
}
if r.FlowPrep != "" {
prep = r.FlowPrep
}
if !r.Passed {
passed = false
}
stepName := r.FlowStep
if stepName == "" {
stepName = r.CaseName
}
tc := recordToSwaggerCase(r)
step := map[string]interface{}{
"index": r.FlowStepIndex,
"name": stepName,
"path": r.Path,
"method": tc.Method,
"passed": tc.Passed,
"note": tc.Note,
"query": tc.Query,
"json": tc.Json,
"form": tc.Form,
"response": tc.Response,
"expect": tc.Expect,
"failReason": tc.FailReason,
"verifyError": tc.VerifyError,
"caseName": tc.Name,
}
if r.BindCase != "" {
step["bindCase"] = r.BindCase
}
steps = append(steps, step)
}
if group == "" {
if def, ok := that.flows[name]; ok && def.Group != "" {
group = def.Group
}
}
if prep == "" {
if def, ok := that.flows[name]; ok && def.Prep != "" {
prep = def.Prep
}
}
if desc == name {
if def, ok := that.flows[name]; ok && def.Desc != "" {
desc = def.Desc
}
}
m := map[string]interface{}{
"name": name,
"desc": desc,
"passed": passed,
"steps": steps,
}
if group != "" {
m["group"] = group
}
if prep != "" {
m["prep"] = prep
}
return m
}
// caseTemplate 单接口验收用例的请求模板(供 FromCase 加载)
type caseTemplate struct {
Query map[string]interface{}
Json interface{}
Form map[string]interface{}
Note string
}
func (that *TestApp) findCaseTemplate(apiPath, caseName string) *caseTemplate {
that.collector.mu.Lock()
for _, r := range that.collector.Records {
if r.FlowName == "" && r.Path == apiPath && r.CaseName == caseName {
tmpl := &caseTemplate{Note: r.Note}
if r.Query != nil {
tmpl.Query = r.Query
}
if r.JsonBody != nil {
tmpl.Json = r.JsonBody
}
if r.FormBody != nil {
tmpl.Form = r.FormBody
}
that.collector.mu.Unlock()
return tmpl
}
}
that.collector.mu.Unlock()
dir := that.swaggerOutputDir()
parts := strings.Split(strings.Trim(apiPath, "/"), "/")
if len(parts) == 0 {
return nil
}
specPath := filepath.Join(dir, "swagger", parts[0], "api-spec.json")
existing := loadExistingEndpoints(specPath)
ep, ok := existing[apiPath]
if !ok {
return nil
}
cases, ok := ep["cases"].([]interface{})
if !ok {
// json 反序列化可能是 []swaggerTestCase 经 map 再读
raw, _ := json.Marshal(ep["cases"])
var parsed []swaggerTestCase
if json.Unmarshal(raw, &parsed) != nil {
return nil
}
for _, c := range parsed {
if c.Name == caseName {
return &caseTemplate{Query: c.Query, Json: c.Json, Form: c.Form, Note: c.Note}
}
}
return nil
}
for _, item := range cases {
cm, ok := item.(map[string]interface{})
if !ok {
continue
}
name, _ := cm["name"].(string)
if name != caseName {
continue
}
tmpl := &caseTemplate{}
if n, ok := cm["note"].(string); ok {
tmpl.Note = n
}
if q, ok := cm["query"].(map[string]interface{}); ok {
tmpl.Query = q
}
if j, ok := cm["json"]; ok {
tmpl.Json = j
}
if f, ok := cm["form"].(map[string]interface{}); ok {
tmpl.Form = f
}
return tmpl
}
return nil
}
// syncBoundCaseToEndpoint 流程步骤跑通后,把响应回写到绑定的单接口验收用例
func (that *TestApp) syncBoundCaseToEndpoint(record TestRecord) {
if record.BindCase == "" || record.Path == "" {
return
}
updated := false
that.collector.mu.Lock()
for i := range that.collector.Records {
r := &that.collector.Records[i]
if r.FlowName != "" || r.Path != record.Path || r.CaseName != record.BindCase {
continue
}
r.ResponseBody = record.ResponseBody
r.Passed = record.Passed
r.FailReason = record.FailReason
r.VerifyError = record.VerifyError
r.ExpectResult = record.ExpectResult
if record.Query != nil {
r.Query = record.Query
}
if record.JsonBody != nil {
r.JsonBody = record.JsonBody
}
if record.FormBody != nil {
r.FormBody = record.FormBody
}
updated = true
break
}
that.collector.mu.Unlock()
if !updated || !that.swaggerEnabled {
return
}
parts := strings.Split(strings.Trim(record.Path, "/"), "/")
if len(parts) == 0 {
return
}
that.flushEndpointSwagger(parts[0], record.Path)
}
func (that *TestApp) writeModuleSpec(moduleDir, title, version string, endpoints, flows []map[string]interface{}) error {
that.swaggerMu.Lock()
defer that.swaggerMu.Unlock()
if endpoints == nil {
endpoints = []map[string]interface{}{}
}
if flows == nil {
flows = []map[string]interface{}{}
}
spec := map[string]interface{}{
"title": title,
"version": version,
"endpoints": endpoints,
"flows": flows,
}
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("写入 api-spec.json 失败: %w", err)
}
if err := os.WriteFile(filepath.Join(moduleDir, "index.html"), []byte(apiConsoleHTML()), os.ModePerm); err != nil {
return fmt.Errorf("写入 index.html 失败: %w", err)
}
return nil
}
func (that *TestApp) pruneOrphanSwaggerModules(swaggerRoot string, active map[string]bool) error {
entries, err := os.ReadDir(swaggerRoot)
if err != nil {
return err
}
for _, e := range entries {
if !e.IsDir() {
continue
}
if active[e.Name()] {
continue
}
_ = os.RemoveAll(filepath.Join(swaggerRoot, e.Name()))
}
return nil
}
// flushEndpointSwagger 单个接口跑完后按 path 替换写入;无用例记录则不写盘
func (that *TestApp) flushEndpointSwagger(projName, apiPath string) {
that.ensureInit()
that.collector.mu.Lock()
var recs []TestRecord
for _, r := range that.collector.Records {
if r.FlowName == "" && r.Path == apiPath {
recs = append(recs, r)
}
}
that.collector.mu.Unlock()
if len(recs) == 0 {
return
}
dir := that.swaggerOutputDir()
moduleDir := filepath.Join(dir, "swagger", projName)
_ = os.MkdirAll(moduleDir, os.ModePerm)
parts := strings.Split(strings.Trim(apiPath, "/"), "/")
ctrName := ""
if len(parts) >= 2 {
ctrName = parts[1]
}
summary := parts[len(parts)-1]
hasTest := false
if projDef, ok := that.projs[projName]; ok {
if ctrTest, ok := projDef.Tests[ctrName]; ok {
methodName := parts[len(parts)-1]
if apiTest, ok := ctrTest[methodName]; ok {
hasTest = true
summary = apiTest.Desc
}
}
}
specPath := filepath.Join(moduleDir, "api-spec.json")
existing := loadExistingEndpoints(specPath)
existingFlows := loadExistingFlows(specPath)
ep := that.buildEndpointMap(projName, ctrName, apiPath, summary, hasTest, recs, existing[apiPath])
existing[apiPath] = ep
endpoints := make([]map[string]interface{}, 0, len(existing))
for _, v := range existing {
endpoints = append(endpoints, v)
}
sort.Slice(endpoints, func(i, j int) bool {
return endpoints[i]["path"].(string) < endpoints[j]["path"].(string)
})
flows := make([]map[string]interface{}, 0, len(existingFlows))
for _, f := range existingFlows {
flows = append(flows, f)
}
sort.Slice(flows, func(i, j int) bool {
return flows[i]["name"].(string) < flows[j]["name"].(string)
})
title := that.swaggerTitle
version := that.swaggerVersion
if title == "" {
title = "API"
}
if version == "" {
version = "1.0.0"
}
_ = that.writeModuleSpec(moduleDir, title, version, endpoints, flows)
_ = generateSwaggerPortal(filepath.Join(dir, "swagger"))
}
// flushFlowSwagger 单个流程跑完后替换写入;无步骤记录则不写盘
func (that *TestApp) flushFlowSwagger(flowName string) {
that.ensureInit()
that.collector.mu.Lock()
var recs []TestRecord
for _, r := range that.collector.Records {
if r.FlowName == flowName {
recs = append(recs, r)
}
}
that.collector.mu.Unlock()
if len(recs) == 0 {
return
}
dir := that.swaggerOutputDir()
flowMap := that.buildFlowMap(flowName, recs)
title := that.swaggerTitle
version := that.swaggerVersion
if title == "" {
title = "API"
}
if version == "" {
version = "1.0.0"
}
for projName := range that.projs {
moduleDir := filepath.Join(dir, "swagger", projName)
_ = os.MkdirAll(moduleDir, os.ModePerm)
specPath := filepath.Join(moduleDir, "api-spec.json")
existingEndpoints := loadExistingEndpoints(specPath)
existingFlows := loadExistingFlows(specPath)
existingFlows[flowName] = flowMap
endpoints := make([]map[string]interface{}, 0, len(existingEndpoints))
for _, v := range existingEndpoints {
endpoints = append(endpoints, v)
}
sort.Slice(endpoints, func(i, j int) bool {
return endpoints[i]["path"].(string) < endpoints[j]["path"].(string)
})
flows := make([]map[string]interface{}, 0, len(existingFlows))
for _, f := range existingFlows {
flows = append(flows, f)
}
sort.Slice(flows, func(i, j int) bool {
return flows[i]["name"].(string) < flows[j]["name"].(string)
})
_ = that.writeModuleSpec(moduleDir, title, version, endpoints, flows)
}
_ = generateSwaggerPortal(filepath.Join(dir, "swagger"))
}
// loadExistingEndpoints 读取已有的 api-spec.json,返回 path → endpoint 映射。
func loadExistingEndpoints(specPath string) map[string]map[string]interface{} {
result := map[string]map[string]interface{}{}
data, err := os.ReadFile(specPath)
if err != nil {
return result
}
var spec map[string]interface{}
if err := json.Unmarshal(data, &spec); err != nil {
return result
}
eps, ok := spec["endpoints"].([]interface{})
if !ok {
return result
}
for _, raw := range eps {
ep, ok := raw.(map[string]interface{})
if !ok {
continue
}
if path, ok := ep["path"].(string); ok {
result[path] = ep
}
}
return result
}
func loadExistingFlows(specPath string) map[string]map[string]interface{} {
result := map[string]map[string]interface{}{}
data, err := os.ReadFile(specPath)
if err != nil {
return result
}
var spec map[string]interface{}
if err := json.Unmarshal(data, &spec); err != nil {
return result
}
flows, ok := spec["flows"].([]interface{})
if !ok {
return result
}
for _, raw := range flows {
f, ok := raw.(map[string]interface{})
if !ok {
continue
}
if name, ok := f["name"].(string); ok {
result[name] = f
}
}
return result
}
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:12px}
*{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);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;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:#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}
.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;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)}
.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)}.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;display:flex;align-items:center;gap:6px;flex-shrink:0;max-width:55%}
.path-link{color:var(--accent);cursor:pointer;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px;display:inline-block;vertical-align:bottom}
.path-link:hover{text-decoration:underline}
.bind-tag{font-size:10px;color:#888;padding:1px 6px;border:1px solid var(--border);border-radius:10px;white-space:nowrap;max-width:160px;overflow:hidden;text-overflow:ellipsis}
.flow-prep{margin-top:8px;padding:8px 10px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);color:var(--fg2);font-size:12px;line-height:1.6;white-space:pre-wrap}
.flow-prep .lbl{display:inline-block;color:var(--accent);font-size:10px;margin-bottom:4px;letter-spacing:.5px}
.flow-note{color:#aaa;font-size:12px;line-height:1.5;margin-bottom:8px;white-space:pre-wrap}
.flow-note.empty{color:#555}
.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}
.modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1000;display:flex;align-items:center;justify-content:center;padding:24px}
.modal-box{background:var(--bg);border:1px solid var(--border);border-radius:8px;width:min(920px,96vw);max-height:88vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(0,0,0,.45)}
.modal-hd{padding:10px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0}
.modal-hd .x{margin-left:auto;background:var(--bg3);border:1px solid var(--border);color:var(--fg2);border-radius:var(--r);cursor:pointer;padding:2px 10px;font-size:14px}
.modal-hd .x:hover{color:var(--accent);border-color:var(--accent)}
.modal-bd{overflow:auto;padding:12px;flex:1}
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}
.resp-wrap{display:flex;gap:0}.resp-main{flex:1;min-width:0}
.resp-main .rsp-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px}
.resp-main .rsp-hd .cp-sm{margin-left:auto}
.exp-col-btn{flex-shrink:0;width:36px;display:flex;align-items:stretch;justify-content:center;margin:0 2px}
.exp-col-btn button{writing-mode:vertical-rl;background:var(--accent);border:none;color:#fff;font-size:10px;padding:6px 3px;border-radius:var(--r) 0 0 var(--r);cursor:pointer;user-select:none;line-height:1.2;letter-spacing:1px;opacity:.85}
.exp-col-btn button:hover{opacity:1}
.exp-panel{flex:1;min-width:0;display:none;padding-left:6px}
.resp-wrap.exp-on .exp-panel{display:block}
.exp-panel .ep-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px}
.exp-panel .ep-hd .cp-sm{margin-left:auto}
.exp-panel pre.j{border-left:2px solid var(--accent)}
pre.j.resp-j{max-height:calc(100vh - 280px);min-height:120px}
.verify-err{margin-top:8px;padding:8px 10px;background:rgba(255,77,79,0.08);border:1px solid rgba(255,77,79,0.3);border-radius:var(--r);font-size:12px;color:#ff6b6b;line-height:1.5;white-space:pre-wrap;word-break:break-all}
.verify-label{display:inline-block;background:var(--red);color:#fff;font-size:10px;padding:1px 6px;border-radius:var(--r);margin-right:8px;vertical-align:middle}
.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;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 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;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}
.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)}
.req-star{color:var(--red);font-size:10px;font-weight:700;margin-right:2px;flex-shrink:0;line-height:1.2;align-self:center}
.jp-panel{margin-bottom:6px;border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
.jp-hd{padding:3px 8px;background:var(--bg2);cursor:pointer;display:flex;align-items:center;font-size:11px;color:var(--fg2);user-select:none}
.jp-bd{padding:6px 8px;display:flex;flex-wrap:wrap;gap:4px;border-top:1px solid var(--border)}
.jp-tag{font-size:11px;padding:1px 6px;background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);color:var(--fg2);display:inline-flex;align-items:center;gap:2px}
.jp-req{color:var(--accent);border-color:rgba(79,195,247,.3)}
.jp-type{color:#555;font-size:10px}
</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">&#8592; 选择一个接口开始</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='';
const flows=(D.flows||[]).filter(f=>{
if(q&&!f.name.toLowerCase().includes(q)&&!(f.desc||'').toLowerCase().includes(q))return false;
if(F==='pass'&&!f.passed)return false;
if(F==='fail'&&f.passed)return false;
if(F==='no')return false;
return true;
});
if(flows.length){
const byG={};
for(const f of flows){const g=f.group||'default';if(!byG[g])byG[g]=[];byG[g].push(f);}
let fh='';
for(const g of Object.keys(byG).sort()){
let gh='';
for(const f of byG[g]){
const sc=f.steps?.length||0;
const tg=f.passed?'<span class="tg tg-ok">通过</span>':'<span class="tg tg-err">失败</span>';
gh+='<div class="ep'+(C?.flowName===f.name?' act':'')+'" data-flow="'+esc(f.name)+'" onclick="goFlow(this)"><span class="mt mt-post">FLOW</span><span class="nm" title="'+esc(f.desc||f.name)+'">'+esc(f.name)+'</span>'+tg+'<span class="cn">'+sc+'</span></div>';
}
fh+='<div class="l2h" onclick="tog(this)"><span class="ar o">&#9654;</span>'+esc(g)+'<span class="cn">'+byG[g].length+'</span></div><div class="sub o">'+gh+'</div>';
}
h+='<div class="l1"><div class="l1h" onclick="tog(this)"><span class="ar o">&#9654;</span>业务流程<span class="cn">'+flows.length+'</span></div><div class="sub o">'+fh+'</div></div>';
}
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&&!C?.flowName?' 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">&#9654;</span>'+c+'<span class="cn">'+es.length+'</span></div><div class="sub">'+eh+'</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>';
}
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;C.flowName=undefined;document.querySelectorAll('.ep').forEach(e=>e.classList.remove('act'));el.classList.add('act');show();}
function goFlow(el){const f=(D.flows||[]).find(x=>x.name===el.dataset.flow);if(!f)return;C={flowName:f.name,flow:f};document.querySelectorAll('.ep').forEach(e=>e.classList.remove('act'));el.classList.add('act');showFlow(f);}
function stepCurl(s){
const base=window.location.origin;
let url=base+(s.path||'');
const m=s.method||'POST';
if(s.query){const qs=Object.keys(s.query).filter(k=>s.query[k]!==undefined&&s.query[k]!=='').map(k=>encodeURIComponent(k)+'='+encodeURIComponent(s.query[k])).join('&');if(qs)url+='?'+qs;}
let cmd="curl -X "+m+" '"+url+"'";
if(s.json){cmd+="\\\n -H 'Content-Type: application/json'";cmd+="\\\n -d '"+JSON.stringify(s.json).replace(/'/g,"'\\''")+"'";}
else if(s.form){const fd=Object.keys(s.form).map(k=>"-F '"+k+"="+s.form[k]+"'").join("\\\n ");if(fd)cmd+="\\\n "+fd;}
return cmd;
}
function hasReqData(s){return !!(s&&((s.query&&Object.keys(s.query).length)||s.json||(s.form&&Object.keys(s.form).length)));}
function closeEpModal(){const m=document.getElementById('flow-ep-modal');if(m)m.remove();}
function openEpModal(path,bindCase){
const e=(D.endpoints||[]).find(x=>x.path===path);
closeEpModal();
const mask=document.createElement('div');mask.id='flow-ep-modal';mask.className='modal-mask';
mask.onclick=ev=>{if(ev.target===mask)closeEpModal();};
let hd='<div class="modal-hd">';
if(e){const m=(e.method||'POST').toLowerCase();hd+='<span class="mt mt-'+m+'" style="font-size:12px;padding:2px 8px">'+esc(e.method||'')+'</span><span class="pa" style="font-size:14px">'+esc(e.path)+'</span><span class="ds">'+esc(e.summary||'')+'</span><span class="case-cnt">只读详情</span>';}
else{hd+='<span class="pa" style="font-size:14px">'+esc(path)+'</span><span class="ds">未找到接口定义</span>';}
hd+='<button class="x" onclick="closeEpModal()">关闭</button></div>';
let bd='<div class="modal-bd">';
if(!e){bd+='<div style="color:#555;padding:20px">swagger 中无此接口</div>';}
else{
let view=e;
if(bindCase&&e.cases?.length){
const idx=e.cases.findIndex(c=>c.name===bindCase);
if(idx>=0){view=Object.assign({},e,{cases:[e.cases[idx]].concat(e.cases.filter((_,i)=>i!==idx))});}
}
bd+=cases(view);
}
bd+='</div>';
mask.innerHTML='<div class="modal-box" onclick="event.stopPropagation()">'+hd+bd+'</div>';
document.body.appendChild(mask);
}
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeEpModal();});
function showFlow(f){
let h='<div class="ct-hd"><div class="ehd"><span class="mt mt-post" style="font-size:13px;padding:3px 8px">FLOW</span><span class="pa">'+esc(f.name)+'</span>';
if(f.desc)h+='<span class="ds">'+esc(f.desc)+'</span>';
h+='<span class="'+(f.passed?'tg tg-ok':'tg tg-err')+'">'+(f.passed?'通过':'失败')+'</span>';
h+='<span class="case-cnt">'+(f.steps?.length||0)+'个步骤</span></div>';
if(f.prep)h+='<div class="flow-prep"><div class="lbl">业务说明</div>'+esc(f.prep)+'</div>';
h+='</div>';
h+='<div class="flow-steps" style="padding:12px;overflow:auto;flex:1">';
if(!f.steps?.length){h+='<div style="color:#555;padding:20px">暂无步骤</div>';}
else{for(let i=0;i<f.steps.length;i++){const s=f.steps[i];
const m=(s.method||'POST').toLowerCase();
const curlId='fc'+i;const respId='fr'+i;
const failCls=(!s.passed)?' flow-step-fail':'';
h+='<div class="cs'+failCls+'" data-step="'+i+'"><div class="csh" onclick="flowStepToggle(this)"><span class="ar o">&#9654;</span><span class="dot '+(s.passed?'ok':'er')+'"></span><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc((s.index||i+1)+'. '+(s.name||s.caseName||''))+'</span>';
h+='<span class="ml"><span class="mt mt-'+m+'" style="font-size:10px;padding:1px 5px">'+(s.method||'')+'</span>';
if(s.path)h+='<a class="path-link" title="'+esc(s.path)+'" href="javascript:void(0)" onclick="event.stopPropagation();openEpModal(\''+esc(s.path)+'\''+(s.bindCase?',\''+esc(s.bindCase)+'\'':'')+')">'+esc(s.path)+'</a>';
h+='</span></div>';
h+='<div class="csb o"><div class="csr">';
h+='<div class="csc">';
if(s.bindCase)h+='<h5>关联验收用例</h5><div class="flow-note"><a class="path-link" href="javascript:void(0)" onclick="event.stopPropagation();openEpModal(\''+esc(s.path||'')+'\',\''+esc(s.bindCase)+'\')">'+esc(s.bindCase)+'</a></div>';
h+='<h5>备注</h5>';
if(s.note)h+='<div class="flow-note">'+esc(s.note)+'</div>';
else h+='<div class="flow-note empty">无备注</div>';
if(!s.passed&&s.failReason)h+='<div class="verify-err"><span class="verify-label">失败原因</span>'+esc(s.failReason)+'</div>';
else if(s.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify</span>'+esc(s.verifyError)+'</div>';
h+='<h5>请求<button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+curlId+'\')">复制</button></h5>';
h+='<div class="curl" id="'+curlId+'">'+esc(stepCurl(s))+'</div>';
if(hasReqData(s)){
if(s.query)h+='<h5>Query</h5><pre class="j">'+fj(s.query)+'</pre>';
if(s.json)h+='<h5>JSON</h5><pre class="j">'+fj(s.json)+'</pre>';
if(s.form)h+='<h5>Form</h5><pre class="j">'+fj(s.form)+'</pre>';
}else{h+='<div style="color:#555;font-size:11px;margin-top:6px">无请求参数</div>';}
h+='</div>';
h+='<div class="csc"><h5>响应<span class="'+(s.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(s.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+respId+'\')">复制</button></h5>';
if(s.response)h+='<pre class="j" id="'+respId+'" style="max-height:320px">'+fj(s.response)+'</pre>';
else h+='<span style="color:#555" id="'+respId+'">无响应</span>';
h+='</div></div></div></div>';
}}
h+='</div>';
document.getElementById('ct').innerHTML=h;
const failEl=document.querySelector('#ct .flow-step-fail');
if(failEl)failEl.scrollIntoView({block:'nearest',behavior:'smooth'});
}
function flowStepToggle(el){
const bd=el.nextElementSibling;if(!bd)return;
bd.classList.toggle('o');
el.querySelector('.ar')?.classList.toggle('o');
}
function show(){
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>';
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)">用例结果</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:'');
bindLiveCurl();
refreshLiveCurl();
}
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 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>';
let h='';
for(let i=0;i<e.cases.length;i++){const c=e.cases[i];
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 style="padding:8px 10px">';
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
if(!c.passed&&c.failReason)h+='<div class="verify-err"><span class="verify-label">失败原因</span>'+esc(c.failReason)+'</div>';
else if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
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>';
const hasDetail=c.expect&&c.expect.result!==undefined;const expId='ce'+i;
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></div>';
if(hasDetail){const vis=getExpVis();h+='<div class="resp-wrap'+(vis?' exp-on':'')+'">';
h+='<div class="resp-main"><div class="rsp-hd"><span>实际响应</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+respId+'\')">复制</button></div>';if(c.response)h+='<pre class="j resp-j" id="'+respId+'">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="'+respId+'">无响应</span>';h+='</div>';
h+='<div class="exp-col-btn"><button class="exp-btn" style=" width: 100%; box-sizing: border-box; " onclick="event.stopPropagation();toggleExpect()">'+(vis?'▶ 收起 预期响应':'◀ 展开 预期响应')+'</button></div>';
h+='<div class="exp-panel"><div class="ep-hd"><span>预期响应</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+expId+'\')">复制</button></div><pre class="j resp-j" id="'+expId+'">'+fj(c.expect)+'</pre></div>';
h+='</div>';}
else{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;if(!bd)return;const wasOpen=bd.classList.contains('o');
const panel=el.closest('.modal-bd')||el.closest('.rpn')||el.closest('#ct')||document;
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 respPanel(){
let r='';
r+='<div id="note-box" style="display:none;color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5"></div>';
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 - 200px)">发送请求后显示</pre>';
return 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+='<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();refreshLiveCurl()">&#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();refreshLiveCurl()">&#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();refreshLiveCurl()">&#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();refreshLiveCurl()">&#215;</button></div>';
qDef+='<div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove();refreshLiveCurl()">&#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();refreshLiveCurl()">+ 添加</button></div><div id="file-rows"><div class="kv"><input placeholder="字段名" value="" style="max-width:120px"><input type="file"><button onclick="this.parentElement.remove();refreshLiveCurl()">&#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();refreshLiveCurl()">&#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':'')+'">';
if(e.params){
const jp=e.params.filter(p=>p.in==='json');
if(jp.length){
const jpVis=localStorage.getItem('json_params_vis')!=='0';
l+='<div class="jp-panel"><div class="jp-hd" onclick="togJP()"><span class="ar jp-ar'+(jpVis?' o':'')+'">&#9654;</span><span>JSON 参数</span><span style="margin-left:auto;font-size:10px" class="jp-toggle">'+(jpVis?'收起':'展开')+'</span></div>';
l+='<div class="jp-bd"'+(jpVis?'':' style="display:none"')+'>';
for(const p of jp){
const star=p.required?'<span class="req-star">*</span>':'';
const cls=p.required?'jp-tag jp-req':'jp-tag';
l+=star+'<span class="'+cls+'">'+esc(p.name);
if(p.type)l+=' <span class="jp-type">'+p.type+'</span>';
l+='</span>';
}
l+='</div></div>';
}
}
l+='<textarea id="xb" oninput="autoH(this)" placeholder="'+(jsonPH?esc(jsonPH):'{&quot;key&quot;:&quot;value&quot;}')+'"></textarea></div>';
l+='</div></div>';
return l;
}
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();refreshLiveCurl()">&#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();refreshLiveCurl()">&#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||'')+'" style="max-width:120px"><input type="file"><button onclick="this.parentElement.remove();refreshLiveCurl()">&#215;</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';
refreshLiveCurl();
}
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';refreshLiveCurl();}
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){refreshLiveCurl();return;}
hr.querySelectorAll('[data-auth]').forEach(e=>e.remove());
qr.querySelectorAll('[data-auth]').forEach(e=>e.remove());
if(tk){
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();refreshLiveCurl()">&#215;</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);
}
refreshLiveCurl();
}
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">';
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
if(!c.passed&&c.failReason)h+='<div class="verify-err"><span class="verify-label">失败原因</span>'+esc(c.failReason)+'</div>';
else if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
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>';
const hasDetail=c.expect&&c.expect.result!==undefined;
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span></div>';
if(hasDetail){const vis=getExpVis();h+='<div class="resp-wrap'+(vis?' exp-on':'')+'">';
h+='<div class="resp-main"><div class="rsp-hd"><span>实际响应</span><button class="cp-sm" onclick="copyEl(\'c-resp\')">复制</button></div>';if(c.response)h+='<pre class="j resp-j" id="c-resp">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="c-resp">无响应</span>';h+='</div>';
h+='<div class="exp-col-btn"><button class="exp-btn" style=" width: 100%; box-sizing: border-box; " onclick="toggleExpect()">'+(vis?'▶ 收起 预期响应':'◀ 展开 预期响应')+'</button></div>';
h+='<div class="exp-panel"><div class="ep-hd"><span>预期响应</span><button class="cp-sm" onclick="copyEl(\'c-exp\')">复制</button></div><pre class="j resp-j" id="c-exp">'+fj(c.expect)+'</pre></div>';
h+='</div>';}
else{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();
const nb0=document.getElementById('note-box');if(nb0){nb0.style.display='none';nb0.textContent='';}
syncAuth();return;
}
const i=parseInt(v);
if(isNaN(i)||i<0||!C?.cases?.[i])return;
const c=C.cases[i];
const nb=document.getElementById('note-box');
if(nb){if(c.note){nb.textContent='备注: '+c.note;nb.style.display='block';}else{nb.textContent='';nb.style.display='none';}}
if(c.query){
for(const[k,val]of Object.entries(c.query)){
const req=C?.params?.find(p=>p.name===k)?.required||false;
addKVReq('q-rows',k,String(val),req);
}
}
addKV('q-rows');
if(c.json){
setAcc('json');const el=document.getElementById('xb');
if(el){
const rq=new Set((C?.params||[]).filter(p=>p.required).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');
if(fr){
fr.innerHTML='';
for(const[k,val]of Object.entries(c.form)){
const req=C?.params?.find(p=>p.name===k)?.required||false;
addKVReq('f-rows',k,String(val),req);
}
addKV('f-rows');
}
}else{
const el=document.getElementById('xb');if(el)el.value='';
if(fr){fr.innerHTML='';addKV('f-rows');}
}
document.getElementById('file-rows').innerHTML='';
if(c.hasFile&&c.fileField)addFileRow(c.fileField);
addFileRow();syncAuth();
refreshLiveCurl();
}
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;
}
function buildLiveCurl(){
if(!C)return '';
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||'none';
const headers={};
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;
headers[k]=v;
}
if(token&&authType==='header')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)return;if(fi.files.length)files.push({field:fn,name:fi.files[0].name});else files.push({field:fn,name:'filename'});});
const isJson=document.getElementById('bt-json')?.classList.contains('on');
const bodyEl=document.getElementById('xb');const formKV=getKV('f-rows');let curlParts='';
if(files.length){
for(const f of files)curlParts+=' -F "'+f.field+'=@'+f.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')curlParts=" -d '"+b.replace(/'/g,"'\\''")+"'";
}else if(formKV){
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(headers)){if(k&&v)curl+=" -H '"+k+": "+v+"'";}
if(token&&authType==='cookie')curl+=" --cookie 'HOTIME="+token+"'";
curl+=curlParts+' '+location.origin+url;
return curl;
}
function refreshLiveCurl(){
const curlBox=document.getElementById('curl-box');
if(!curlBox||!C)return;
curlBox.textContent=buildLiveCurl();
}
function bindLiveCurl(){
const left=document.querySelector('#ct .split-l');
if(!left||left.dataset.liveCurl==='1')return;
left.dataset.liveCurl='1';
left.addEventListener('input',refreshLiveCurl);
left.addEventListener('change',refreshLiveCurl);
}
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');
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'];
}else if(isJson){
const b=bodyEl?.value?.trim();
if(b&&C.method!=='GET')opts.body=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'];
}
refreshLiveCurl();
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){}
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){
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='发送';
}
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 getExpVis(){const v=localStorage.getItem('expect_vis');return v===null||v==='1';}
function toggleExpect(){const vis=getExpVis();const nv=!vis;localStorage.setItem('expect_vis',nv?'1':'0');document.querySelectorAll('.resp-wrap').forEach(w=>{w.classList.toggle('exp-on',nv);});document.querySelectorAll('.exp-btn').forEach(b=>{b.textContent=nv?'▶ 收起 预期响应':'◀ 展开 预期响应';});}
function togJP(){const bd=document.querySelector('.jp-bd');const ar=document.querySelector('.jp-ar');const tg=document.querySelector('.jp-toggle');if(!bd)return;const vis=bd.style.display!=='none';bd.style.display=vis?'none':'flex';if(ar)ar.classList.toggle('o',!vis);if(tg)tg.textContent=vis?'展开':'收起';localStorage.setItem('json_params_vis',vis?'0':'1');}
function fj(o){return esc(JSON.stringify(o,null,2));}
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>`
}
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"}
}
}