3467 lines
100 KiB
Vue
3467 lines
100 KiB
Vue
<template>
|
||
<el-container :class="{ 'mobile-mode': isMobile }" v-loading="loading" element-loading-text="正在加载接口列表..."
|
||
element-loading-background="rgba(255, 255, 255, 1)">
|
||
<el-header v-if="isMobile" class="mobile-header">
|
||
<el-button :icon="Menu" circle text class="menu-toggle-btn" @click="toggleMenu" />
|
||
<h2>API 文档中心</h2>
|
||
<span class="mobile-header-spacer" />
|
||
</el-header>
|
||
|
||
<el-aside :width="isMobile ? '100%' : '280px'" class="sidebar"
|
||
v-show="(!isMobile || (isMobile && showMenu)) && !loadError">
|
||
<div class="sidebar-inner">
|
||
<div class="sidebar-header">
|
||
<div class="sidebar-brand">
|
||
<div class="brand-text">
|
||
<span class="brand-title">API 文档中心</span>
|
||
<span v-if="!loading && !loadError" class="brand-subtitle">
|
||
共 {{ apis.length }} 个接口
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<el-button v-if="isMobile" :icon="Close" circle text class="sidebar-close-btn" @click="toggleMenu" />
|
||
</div>
|
||
|
||
<div class="sidebar-search">
|
||
<el-input placeholder="搜索接口..." v-model="searchQuery" clearable :prefix-icon="Search"
|
||
:disabled="loading || !!loadError" />
|
||
</div>
|
||
|
||
<div class="sidebar-content">
|
||
<div v-if="loadError" class="sidebar-error">
|
||
<el-empty description="加载失败" :image-size="80">
|
||
<el-button type="primary" size="small" @click="location.reload()">
|
||
重新加载
|
||
</el-button>
|
||
</el-empty>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<div v-if="currentPath.length > 0" class="breadcrumb-nav">
|
||
<div class="breadcrumb-toolbar">
|
||
<el-button link :icon="ArrowLeft" @click="goBack" class="breadcrumb-btn">
|
||
返回
|
||
</el-button>
|
||
<el-button link :icon="HomeFilled" @click="goHome" class="breadcrumb-btn">
|
||
根目录
|
||
</el-button>
|
||
</div>
|
||
<div class="breadcrumb-path">
|
||
<span class="breadcrumb-tag breadcrumb-clickable" @click="goHome">根目录</span>
|
||
<template v-for="(path, index) in currentPath" :key="index">
|
||
<el-icon class="breadcrumb-chevron">
|
||
<ArrowRight />
|
||
</el-icon>
|
||
<span class="breadcrumb-tag" :class="{
|
||
'breadcrumb-clickable': index < currentPath.length - 1,
|
||
'breadcrumb-current': index === currentPath.length - 1,
|
||
}" @click="index < currentPath.length - 1 && navigateToPath(index)">
|
||
{{ path }}
|
||
</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<el-empty v-if="
|
||
!loading &&
|
||
!loadError &&
|
||
currentLevelNodes.length === 0 &&
|
||
currentLevelApis.length === 0
|
||
" description="没有找到相关接口" :image-size="64" class="sidebar-empty" />
|
||
|
||
<el-menu v-else class="menu-vertical" :default-active="activeMenuIndex">
|
||
<template v-for="node in currentLevelNodes" :key="node.key">
|
||
<el-menu-item v-if="
|
||
!node.isUngrouped && (node.children.length > 0 || node.apis.length > 0)
|
||
" :index="node.key" @click="enterGroup(node)" class="menu-folder-item" :disabled="loading">
|
||
<div class="menu-item-inner">
|
||
<el-icon class="menu-item-icon folder">
|
||
<Folder />
|
||
</el-icon>
|
||
<span class="menu-item-label">{{ node.displayName }}</span>
|
||
<span class="menu-item-count">{{ getNodeApiCount(node) }}</span>
|
||
<el-icon class="menu-item-arrow">
|
||
<ArrowRight />
|
||
</el-icon>
|
||
</div>
|
||
</el-menu-item>
|
||
|
||
<el-menu-item v-else-if="node.isUngrouped" :index="node.key" @click="
|
||
goToApi(node.apis[0]);
|
||
isMobile && toggleMenu();
|
||
" class="menu-api-item" :disabled="loading">
|
||
<div class="menu-item-inner">
|
||
<el-icon class="menu-item-icon api">
|
||
<Document />
|
||
</el-icon>
|
||
<span class="menu-item-label">{{ node.displayName }}</span>
|
||
</div>
|
||
</el-menu-item>
|
||
</template>
|
||
|
||
<template v-if="currentLevelApis.length > 0">
|
||
<el-menu-item v-if="currentLevelNodes.length > 0" index="__section_apis__" disabled
|
||
class="menu-section-item">
|
||
<span class="menu-section-label">接口</span>
|
||
</el-menu-item>
|
||
<el-menu-item v-for="api in currentLevelApis" :key="api.uniqueId" :index="api.uniqueId" @click="
|
||
goToApi(api);
|
||
isMobile && toggleMenu();
|
||
" class="menu-api-item" :disabled="loading">
|
||
<div class="menu-item-inner">
|
||
<el-icon class="menu-item-icon api">
|
||
<Document />
|
||
</el-icon>
|
||
<span class="menu-item-label">{{
|
||
getCurrentNodeApiDisplayName(api)
|
||
}}</span>
|
||
</div>
|
||
</el-menu-item>
|
||
</template>
|
||
</el-menu>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</el-aside>
|
||
|
||
<el-main class="main-content">
|
||
<!-- 接口列表加载失败时全屏显示错误 -->
|
||
<div v-if="loadError" class="error-container fullscreen-error">
|
||
<el-result icon="error" title="加载失败" :sub-title="loadError">
|
||
<template #extra>
|
||
<el-button type="primary" @click="location.reload()">重新加载</el-button>
|
||
</template>
|
||
</el-result>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<div class="content-frame">
|
||
<div class="content-nav-wrap">
|
||
<div class="content-section content-nav-bar">
|
||
<div class="content-nav-tabs">
|
||
<button type="button" class="content-nav-tab" :class="{ active: activeTab === 'docs' }"
|
||
@click="activeTab = 'docs'">
|
||
接口文档
|
||
</button>
|
||
<button type="button" class="content-nav-tab" :class="{ active: activeTab === 'common' }"
|
||
@click="activeTab = 'common'">
|
||
通用信息
|
||
</button>
|
||
</div>
|
||
<el-button type="primary" :icon="Download" @click="showExportDrawer = true">
|
||
导出文档
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content-frame-body">
|
||
<div v-show="activeTab === 'docs'" class="content-tab-panel">
|
||
<!-- README引导页 -->
|
||
<div v-if="!currentApi" class="content-page readme-guide">
|
||
<div class="content-section page-header-card page-header-card-center">
|
||
<div class="page-header-main">
|
||
<el-icon class="page-hero-icon">
|
||
<Document />
|
||
</el-icon>
|
||
<div>
|
||
<h1 class="page-hero-title">接口文档中心</h1>
|
||
<p class="page-hero-desc">
|
||
欢迎使用 API 文档中心,这里提供了完整的接口文档和使用说明
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="readme-grid">
|
||
<div class="readme-card">
|
||
<div class="readme-card-num">01</div>
|
||
<h3>浏览接口</h3>
|
||
<p>在左侧菜单中浏览所有可用的接口,接口按模块分组显示。</p>
|
||
<ul>
|
||
<li>点击文件夹进入下一级目录</li>
|
||
<li>点击接口名称查看详细文档</li>
|
||
<li>使用搜索框快速查找接口</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="readme-card">
|
||
<div class="readme-card-num">02</div>
|
||
<h3>查看接口详情</h3>
|
||
<p>点击任意接口后,右侧会显示该接口的详细信息,包括:</p>
|
||
<ul>
|
||
<li>请求参数说明</li>
|
||
<li>响应数据结构</li>
|
||
<li>调用示例(cURL、JavaScript)</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="readme-card">
|
||
<div class="readme-card-num">03</div>
|
||
<h3>导出文档</h3>
|
||
<p>点击上方「导出文档」按钮,可以:</p>
|
||
<ul>
|
||
<li>一键导出所有接口文档</li>
|
||
<li>按需选择接口进行导出</li>
|
||
<li>导出为 Markdown 格式</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="readme-card">
|
||
<div class="readme-card-num">04</div>
|
||
<h3>通用信息</h3>
|
||
<p>切换到「通用信息」标签页,可以查看:</p>
|
||
<ul>
|
||
<li>通用请求参数说明</li>
|
||
<li>通用响应格式说明</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content-section tips-card">
|
||
<div class="tips-card-title">使用提示</div>
|
||
<ul>
|
||
<li>所有接口都需要携带鉴权 token</li>
|
||
<li>请求参数需要放在 params 对象中</li>
|
||
<li>响应格式统一,code 为 0 表示成功</li>
|
||
<li>可以在「通用信息」标签页查看详细的请求和响应说明</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="currentApi" class="content-page api-detail">
|
||
<div class="content-section page-header-card">
|
||
<div class="page-header-main">
|
||
<div class="page-header-text">
|
||
<div class="page-header-label">接口</div>
|
||
<h1 class="api-action-name">{{ currentApi.action }}</h1>
|
||
</div>
|
||
</div>
|
||
<el-button type="primary" plain size="small" :icon="DocumentCopy"
|
||
:disabled="apiDetailLoading || !apiDetail" @click="copyApiMarkdown">
|
||
复制文档
|
||
</el-button>
|
||
</div>
|
||
|
||
<div v-if="apiDetailError" class="api-detail-alert">
|
||
<el-alert :title="apiDetailError" type="warning" :closable="false" show-icon />
|
||
</div>
|
||
|
||
<div class="api-detail-body">
|
||
<template v-if="apiDetailLoading">
|
||
<section class="content-section">
|
||
<h3 class="section-title">请求参数</h3>
|
||
<el-skeleton animated class="detail-skeleton">
|
||
<template #template>
|
||
<div class="detail-skeleton-table detail-skeleton-table--params">
|
||
<div class="detail-skeleton-row detail-skeleton-row--head">
|
||
<el-skeleton-item variant="text" />
|
||
<el-skeleton-item variant="text" />
|
||
<el-skeleton-item variant="text" />
|
||
<el-skeleton-item variant="text" />
|
||
</div>
|
||
<div v-for="row in 4" :key="'param-' + row" class="detail-skeleton-row">
|
||
<el-skeleton-item variant="text" />
|
||
<el-skeleton-item variant="text" />
|
||
<el-skeleton-item variant="text" />
|
||
<el-skeleton-item variant="text" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-skeleton>
|
||
</section>
|
||
|
||
<section class="content-section">
|
||
<h3 class="section-title">请求响应</h3>
|
||
<el-skeleton animated class="detail-skeleton">
|
||
<template #template>
|
||
<div class="detail-skeleton-table detail-skeleton-table--response">
|
||
<div class="detail-skeleton-row detail-skeleton-row--head">
|
||
<el-skeleton-item variant="text" />
|
||
<el-skeleton-item variant="text" />
|
||
<el-skeleton-item variant="text" />
|
||
</div>
|
||
<div v-for="row in 4" :key="'response-' + row" class="detail-skeleton-row">
|
||
<el-skeleton-item variant="text" />
|
||
<el-skeleton-item variant="text" />
|
||
<el-skeleton-item variant="text" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-skeleton>
|
||
</section>
|
||
|
||
<section class="content-section">
|
||
<h3 class="section-title">调用示例</h3>
|
||
<el-skeleton animated class="detail-skeleton">
|
||
<template #template>
|
||
<div class="detail-skeleton-tabs">
|
||
<el-skeleton-item variant="text" />
|
||
<el-skeleton-item variant="text" />
|
||
</div>
|
||
<el-skeleton-item variant="rect" class="detail-skeleton-code" />
|
||
</template>
|
||
</el-skeleton>
|
||
</section>
|
||
</template>
|
||
|
||
<template v-else-if="apiDetail">
|
||
<section class="content-section">
|
||
<h3 class="section-title">请求参数</h3>
|
||
<div v-if="apiDetail?.paramModel">
|
||
<p v-if="apiDetail.paramModel.basicProperty" class="basic-type-display">
|
||
类型:{{ getFullTypeName(apiDetail.paramModel) }}
|
||
</p>
|
||
<div v-for="model in getAllModels(apiDetail.paramModel)" :key="model.simpleName"
|
||
class="model-card">
|
||
<h4 :id="'model-' + model.simpleName" class="model-title">{{ getModelDisplayName(model) }}
|
||
</h4>
|
||
|
||
<!-- 桌面端表格布局 -->
|
||
<el-table v-if="!isMobile" :data="model.fields" class="data-table">
|
||
<el-table-column prop="name" label="参数名" width="auto" />
|
||
<el-table-column label="类型" width="auto">
|
||
<template #default="scope">
|
||
<div class="type-display">
|
||
<template v-if="isGenericType(scope.row)">
|
||
<template v-if="isObjectType(scope.row)">
|
||
<a :href="'#model-' + getTypeName(scope.row)">{{
|
||
getTypeName(scope.row)
|
||
}}</a>
|
||
</template>
|
||
<template v-else>
|
||
{{ getTypeName(scope.row) }}
|
||
</template>
|
||
<
|
||
<template v-for="(param, index) in getGenericParams(scope.row)" :key="index">
|
||
<template v-if="param.isObject">
|
||
<a :href="'#model-' + param.name">{{ param.name }}</a>
|
||
</template>
|
||
<template v-else>
|
||
{{ param.name }}
|
||
</template>
|
||
<template v-if="index < getGenericParams(scope.row).length - 1">,</template>
|
||
</template>
|
||
>
|
||
</template>
|
||
<template v-else>
|
||
<template v-if="isObjectType(scope.row)">
|
||
<a :href="'#model-' + getTypeName(scope.row)">{{
|
||
getTypeName(scope.row)
|
||
}}</a>
|
||
</template>
|
||
<template v-else>
|
||
{{ getTypeName(scope.row) }}
|
||
</template>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="required" label="必填" width="auto">
|
||
<template #default="scope">
|
||
{{ scope.row.required ? "是" : "否" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="desc" label="说明">
|
||
<template #default="scope">
|
||
<div>
|
||
<span v-if="scope.row.desc" style="margin-right: 8px">{{ scope.row.desc }}</span>
|
||
<span v-if="scope.row.enumInfo && scope.row.enumInfo.properties">
|
||
<span style="font-weight: bolder;color: #409eff;margin-right: 4px;">
|
||
枚举值
|
||
</span>
|
||
<span v-html="scope.row.enumInfo.properties.map((item) => {
|
||
return `${item.desc}(${item.value})`;
|
||
}).join(', ')" style="color: #606266"></span>
|
||
</span>
|
||
<div v-if="
|
||
!scope.row.desc &&
|
||
!(scope.row.enumInfo && scope.row.enumInfo.properties)
|
||
">
|
||
-
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<!-- 移动端卡片布局 -->
|
||
<div v-else class="mobile-field-list">
|
||
<el-collapse accordion>
|
||
<el-collapse-item v-for="field in model.fields" :key="field.name">
|
||
<template #title>
|
||
<div class="mobile-field-title">
|
||
<span class="field-name">{{ field.name }}</span>
|
||
<span v-if="field.required" class="field-required">必填</span>
|
||
</div>
|
||
</template>
|
||
<div class="mobile-field-content">
|
||
<div class="field-item">
|
||
<div class="field-label">类型:</div>
|
||
<div class="field-value type-display">
|
||
<template v-if="isGenericType(field)">
|
||
<template v-if="isObjectType(field)">
|
||
<a :href="'#model-' + getTypeName(field)">{{
|
||
getTypeName(field)
|
||
}}</a>
|
||
</template>
|
||
<template v-else>
|
||
{{ getTypeName(field) }}
|
||
</template>
|
||
<
|
||
<template v-for="(param, index) in getGenericParams(field)" :key="index">
|
||
<template v-if="param.isObject">
|
||
<a :href="'#model-' + param.name">{{ param.name }}</a>
|
||
</template>
|
||
<template v-else>
|
||
{{ param.name }}
|
||
</template>
|
||
<template v-if="index < getGenericParams(field).length - 1">,</template>
|
||
</template>
|
||
>
|
||
</template>
|
||
<template v-else>
|
||
<template v-if="isObjectType(field)">
|
||
<a :href="'#model-' + getTypeName(field)">{{
|
||
getTypeName(field)
|
||
}}</a>
|
||
</template>
|
||
<template v-else>
|
||
{{ getTypeName(field) }}
|
||
</template>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div class="field-item" v-if="field.desc">
|
||
<div class="field-label">说明:</div>
|
||
<div class="field-value">{{ field.desc }}</div>
|
||
</div>
|
||
<div class="field-item" v-if="field.enumInfo && field.enumInfo.properties">
|
||
<div class="field-label">枚举值:</div>
|
||
<div class="field-value">
|
||
<div v-html="field.enumInfo.properties.map((item) => {
|
||
return `${item.desc}(${item.value})`;
|
||
}).join(', ')" style="color: #606266"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="content-section">
|
||
<h3 class="section-title">请求响应</h3>
|
||
<div v-if="apiDetail?.responseModel">
|
||
<p v-if="apiDetail.responseModel.basicProperty" class="basic-type-display">
|
||
类型:{{ getFullTypeName(apiDetail.responseModel) }}
|
||
</p>
|
||
<div v-for="model in getAllModels(apiDetail.responseModel)" :key="model.simpleName"
|
||
class="model-card">
|
||
<h4 :id="'model-' + model.simpleName" class="model-title">{{ getModelDisplayName(model) }}
|
||
</h4>
|
||
<!-- 桌面端表格布局 -->
|
||
<el-table v-if="!isMobile" :data="model.fields" class="data-table">
|
||
<el-table-column prop="name" label="参数名" width="auto" />
|
||
<el-table-column label="类型" width="auto">
|
||
<template #default="scope">
|
||
<div class="type-display">
|
||
<template v-if="isGenericType(scope.row)">
|
||
<template v-if="isObjectType(scope.row)">
|
||
<a :href="'#model-' + getTypeName(scope.row)">{{
|
||
getTypeName(scope.row)
|
||
}}</a>
|
||
</template>
|
||
<template v-else>
|
||
{{ getTypeName(scope.row) }}
|
||
</template>
|
||
<
|
||
<template v-for="(param, index) in getGenericParams(scope.row)" :key="index">
|
||
<template v-if="param.isObject">
|
||
<a :href="'#model-' + param.name">{{ param.name }}</a>
|
||
</template>
|
||
<template v-else>
|
||
{{ param.name }}
|
||
</template>
|
||
<template v-if="index < getGenericParams(scope.row).length - 1">,</template>
|
||
</template>
|
||
>
|
||
</template>
|
||
<template v-else>
|
||
<template v-if="isObjectType(scope.row)">
|
||
<a :href="'#model-' + getTypeName(scope.row)">{{
|
||
getTypeName(scope.row)
|
||
}}</a>
|
||
</template>
|
||
<template v-else>
|
||
{{ getTypeName(scope.row) }}
|
||
</template>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="desc" label="说明">
|
||
<template #default="scope">
|
||
<div>
|
||
<span v-if="scope.row.desc" style="margin-right: 8px">{{ scope.row.desc }}</span>
|
||
<span v-if="scope.row.enumInfo && scope.row.enumInfo.properties">
|
||
<span style="font-weight: bolder;color: #409eff; margin-right: 4px;">
|
||
枚举值
|
||
</span>
|
||
<span v-html="scope.row.enumInfo.properties.map((item) => {
|
||
return `${item.desc}(${item.value})`;
|
||
}).join(', ')" style="color: #606266"></span>
|
||
</span>
|
||
<div v-if="
|
||
!scope.row.desc &&
|
||
!(scope.row.enumInfo && scope.row.enumInfo.properties)
|
||
">
|
||
-
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<!-- 移动端卡片布局 -->
|
||
<div v-else class="mobile-field-list">
|
||
<el-collapse accordion>
|
||
<el-collapse-item v-for="field in model.fields" :key="field.name">
|
||
<template #title>
|
||
<div class="mobile-field-title">
|
||
<span class="field-name">{{ field.name }}</span>
|
||
</div>
|
||
</template>
|
||
<div class="mobile-field-content">
|
||
<div class="field-item">
|
||
<div class="field-label">类型:</div>
|
||
<div class="field-value type-display">
|
||
<template v-if="isGenericType(field)">
|
||
<template v-if="isObjectType(field)">
|
||
<a :href="'#model-' + getTypeName(field)">{{
|
||
getTypeName(field)
|
||
}}</a>
|
||
</template>
|
||
<template v-else>
|
||
{{ getTypeName(field) }}
|
||
</template>
|
||
<
|
||
<template v-for="(param, index) in getGenericParams(field)" :key="index">
|
||
<template v-if="param.isObject">
|
||
<a :href="'#model-' + param.name">{{ param.name }}</a>
|
||
</template>
|
||
<template v-else>
|
||
{{ param.name }}
|
||
</template>
|
||
<template v-if="index < getGenericParams(field).length - 1">,</template>
|
||
</template>
|
||
>
|
||
</template>
|
||
<template v-else>
|
||
<template v-if="isObjectType(field)">
|
||
<a :href="'#model-' + getTypeName(field)">{{
|
||
getTypeName(field)
|
||
}}</a>
|
||
</template>
|
||
<template v-else>
|
||
{{ getTypeName(field) }}
|
||
</template>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div class="field-item" v-if="field.desc">
|
||
<div class="field-label">说明:</div>
|
||
<div class="field-value">{{ field.desc }}</div>
|
||
</div>
|
||
<div class="field-item" v-if="field.enumInfo && field.enumInfo.properties">
|
||
<div class="field-label">枚举值:</div>
|
||
<div class="field-value">
|
||
<div v-html="field.enumInfo.properties.map((item) => {
|
||
return `${item.desc}(${item.value})`;
|
||
})
|
||
"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="content-section">
|
||
<h3 class="section-title">调用示例</h3>
|
||
<el-tabs class="code-tabs">
|
||
<el-tab-pane label="cURL">
|
||
<pre class="code-block">{{ generateCurlExample(currentApi) }}</pre>
|
||
</el-tab-pane>
|
||
<el-tab-pane label="JavaScript">
|
||
<pre class="code-block">{{ generateJsExample(currentApi) }}</pre>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</section>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-show="activeTab === 'common'" class="content-tab-panel">
|
||
<div class="content-page common-info">
|
||
<div class="content-section page-header-card">
|
||
<div class="page-header-main">
|
||
<div class="page-header-text">
|
||
<div class="page-header-label">说明</div>
|
||
<h1 class="page-hero-title">调用说明</h1>
|
||
<p class="page-hero-desc">所有接口通用的请求参数与响应格式</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="content-section">
|
||
<h3 class="section-title">通用请求参数</h3>
|
||
<el-table :data="commonParams" class="data-table" :class="{ 'responsive-table': isMobile }">
|
||
<el-table-column prop="name" label="参数名" width="auto" />
|
||
<el-table-column prop="required" label="必填" width="auto" />
|
||
<el-table-column prop="desc" label="说明" />
|
||
</el-table>
|
||
</section>
|
||
|
||
<section class="content-section">
|
||
<h3 class="section-title">通用响应格式</h3>
|
||
<el-table :data="commonResponse" class="data-table" :class="{ 'responsive-table': isMobile }">
|
||
<el-table-column prop="name" label="字段名" width="auto" />
|
||
<el-table-column prop="desc" label="说明" />
|
||
</el-table>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-main>
|
||
</el-container>
|
||
|
||
<el-drawer v-model="showExportDrawer" title="导出文档" direction="rtl" :size="exportDrawerSize" class="export-drawer">
|
||
<div class="export-module">
|
||
<p class="export-description">
|
||
导出接口文档为 Markdown 格式,支持导出全部或按需选择
|
||
</p>
|
||
|
||
<el-card shadow="never" class="quick-export-card">
|
||
<template #header>
|
||
<span class="card-header">快速导出</span>
|
||
</template>
|
||
<div class="quick-export-content">
|
||
<p class="quick-export-desc">一键导出所有接口文档</p>
|
||
<el-button type="primary" @click="downloadApiDocumentation" :loading="downloading" style="width: 100%">
|
||
{{ downloading ? "下载中..." : "下载全部接口文档" }}
|
||
</el-button>
|
||
</div>
|
||
</el-card>
|
||
|
||
<div class="export-content">
|
||
<div class="export-section-title">
|
||
<span class="card-header">按需导出</span>
|
||
<p class="section-desc">选择需要导出的接口,生成自定义文档</p>
|
||
</div>
|
||
<div class="export-select-header">
|
||
<el-input v-model="exportSearchQuery" placeholder="搜索接口" clearable :prefix-icon="Search" />
|
||
<div class="select-actions">
|
||
<el-button type="primary" @click="selectAllExportApis">全选</el-button>
|
||
<el-button @click="deselectAllExportApis">取消全选</el-button>
|
||
<span class="selected-count">
|
||
已选择: {{ selectedApiIds.length }} / {{ totalExportApiCount }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="export-api-list">
|
||
<el-tree ref="exportTreeRef" :data="exportTreeData" :props="exportTreeProps" show-checkbox node-key="id"
|
||
:default-expand-all="false" :filter-node-method="filterExportNode" @check="handleExportTreeCheck">
|
||
<template #default="{ node, data }">
|
||
<span class="export-tree-node">
|
||
<el-icon v-if="data.isApi" class="api-icon">
|
||
<Document />
|
||
</el-icon>
|
||
<el-icon v-else class="folder-icon">
|
||
<Folder />
|
||
</el-icon>
|
||
<span class="node-label">{{ node.label }}</span>
|
||
</span>
|
||
</template>
|
||
</el-tree>
|
||
</div>
|
||
<div class="export-actions">
|
||
<el-button type="primary" @click="handleExportSelected" :loading="downloading"
|
||
:disabled="selectedApiIds.length === 0" style="width: 100%">
|
||
{{ downloading ? "导出中..." : "导出所选文档" }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-drawer>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted, computed, watch, defineComponent, h } from "vue";
|
||
import axios from "axios";
|
||
import { ElMessage } from "element-plus";
|
||
import { DocumentCopy, Close, Document, Folder, ArrowRight, ArrowLeft, HomeFilled, Search, Download, Menu } from "@element-plus/icons-vue";
|
||
|
||
const activeMenuIndex = computed(() => currentApi.value?.uniqueId ?? "");
|
||
|
||
// 统计节点下接口总数(含子目录)
|
||
const getNodeApiCount = (node) => {
|
||
let count = node.apis?.length || 0;
|
||
(node.children || []).forEach((child) => {
|
||
count += getNodeApiCount(child);
|
||
});
|
||
return count;
|
||
};
|
||
|
||
// 递归菜单节点组件(用于处理无限级嵌套)
|
||
const MenuTreeNode = defineComponent({
|
||
name: "MenuTreeNode",
|
||
props: {
|
||
node: {
|
||
type: Object,
|
||
required: true,
|
||
},
|
||
},
|
||
emits: ["go-to-api"],
|
||
setup(props, { emit, slots }) {
|
||
const handleGoToApi = (api) => {
|
||
emit("go-to-api", api);
|
||
};
|
||
|
||
return () => {
|
||
const { node } = props;
|
||
|
||
// 未分组的API直接显示为菜单项
|
||
if (node.isUngrouped) {
|
||
return h(
|
||
"el-menu-item",
|
||
{
|
||
index: node.key,
|
||
onClick: () => {
|
||
handleGoToApi(node.apis[0]);
|
||
},
|
||
},
|
||
() => node.displayName
|
||
);
|
||
}
|
||
|
||
// 分组的API显示为子菜单
|
||
return h(
|
||
"el-sub-menu",
|
||
{
|
||
index: node.key,
|
||
},
|
||
{
|
||
title: () => h("span", node.displayName),
|
||
default: () => [
|
||
// 显示该节点下的API
|
||
...(node.apis || []).map((api) =>
|
||
h(
|
||
"el-menu-item",
|
||
{
|
||
key: api.uniqueId,
|
||
index: api.uniqueId,
|
||
onClick: () => {
|
||
handleGoToApi(api);
|
||
},
|
||
},
|
||
() => getActionDisplayName(api.action, node.fullPath)
|
||
)
|
||
),
|
||
// 递归渲染子节点
|
||
...(node.children || []).map((child) =>
|
||
h(MenuTreeNode, {
|
||
key: child.key,
|
||
node: child,
|
||
"onGo-to-api": handleGoToApi,
|
||
})
|
||
),
|
||
],
|
||
}
|
||
);
|
||
};
|
||
},
|
||
});
|
||
|
||
const apis = ref([]);
|
||
const currentApi = ref(null);
|
||
const apiDetail = ref(null);
|
||
const searchQuery = ref("");
|
||
const isMobile = ref(false);
|
||
const windowWidth = ref(typeof window !== "undefined" ? window.innerWidth : 800);
|
||
const showMenu = ref(false);
|
||
const currentPath = ref([]); // 当前路径,用于记录层级
|
||
const currentPathKeys = ref([]); // 当前路径的key数组
|
||
const downloading = ref(false); // 下载状态
|
||
const apiDetailsCache = ref({}); // API详情缓存
|
||
const activeTab = ref("docs"); // 当前激活的标签页
|
||
const selectedApiIds = ref([]); // 选中的API ID列表
|
||
const exportSearchQuery = ref(""); // 导出搜索查询
|
||
const exportTreeRef = ref(null); // 导出树引用
|
||
const loading = ref(true); // 接口列表加载状态(初始为true,减少闪烁)
|
||
const loadError = ref(null); // 加载错误信息(接口列表加载失败)
|
||
const apiDetailLoading = ref(false); // 接口详情加载状态
|
||
const apiDetailError = ref(null); // 接口详情加载错误
|
||
const exportTreeProps = {
|
||
children: "children",
|
||
label: "label",
|
||
};
|
||
|
||
const hostUrl = ref(import.meta.env.VITE_API_URL || "/api");
|
||
const showExportDrawer = ref(false);
|
||
const exportDrawerSize = computed(() => {
|
||
if (isMobile.value) return "100%";
|
||
return `${Math.min(800, windowWidth.value)}px`;
|
||
});
|
||
|
||
// 监听搜索,如果有搜索则自动返回根级别显示所有匹配结果
|
||
watch(searchQuery, (newVal) => {
|
||
if (newVal && newVal.trim()) {
|
||
// 搜索时自动返回根级别,显示所有匹配结果
|
||
currentPath.value = [];
|
||
currentPathKeys.value = [];
|
||
}
|
||
});
|
||
|
||
// IndexedDB 缓存工具
|
||
const DB_NAME = "api-docs-cache";
|
||
const DB_VERSION = 4;
|
||
const STORE_NAME = "api-list";
|
||
|
||
// 初始化 IndexedDB(支持版本兼容)
|
||
const initDB = () => {
|
||
return new Promise((resolve, reject) => {
|
||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||
|
||
request.onerror = (event) => {
|
||
const error = event.target.error;
|
||
// 如果是版本错误,尝试使用当前版本打开
|
||
if (error && error.name === "VersionError") {
|
||
// 先打开数据库获取当前版本
|
||
const checkRequest = indexedDB.open(DB_NAME);
|
||
checkRequest.onsuccess = () => {
|
||
const currentVersion = checkRequest.result.version;
|
||
checkRequest.result.close();
|
||
|
||
// 使用当前版本打开(不触发升级)
|
||
const currentRequest = indexedDB.open(DB_NAME, currentVersion);
|
||
currentRequest.onsuccess = () => resolve(currentRequest.result);
|
||
currentRequest.onerror = () => reject(currentRequest.error);
|
||
};
|
||
checkRequest.onerror = () => reject(checkRequest.error);
|
||
} else {
|
||
reject(error);
|
||
}
|
||
};
|
||
|
||
request.onsuccess = () => resolve(request.result);
|
||
|
||
request.onupgradeneeded = (event) => {
|
||
const db = event.target.result;
|
||
|
||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||
db.createObjectStore(STORE_NAME);
|
||
}
|
||
};
|
||
|
||
request.onblocked = () => {
|
||
console.warn("Database upgrade blocked");
|
||
};
|
||
});
|
||
};
|
||
|
||
// 从 IndexedDB 获取缓存的接口列表
|
||
const getCachedApiList = async () => {
|
||
try {
|
||
const db = await initDB();
|
||
return new Promise((resolve, reject) => {
|
||
const transaction = db.transaction([STORE_NAME], "readonly");
|
||
const store = transaction.objectStore(STORE_NAME);
|
||
const request = store.get("api-list");
|
||
request.onsuccess = () => resolve(request.result || null);
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
} catch (error) {
|
||
console.error("Failed to get cached API list:", error);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
// 将接口列表保存到 IndexedDB
|
||
const saveApiListToCache = async (apiList) => {
|
||
try {
|
||
const db = await initDB();
|
||
return new Promise((resolve, reject) => {
|
||
const transaction = db.transaction([STORE_NAME], "readwrite");
|
||
const store = transaction.objectStore(STORE_NAME);
|
||
const request = store.put(apiList, "api-list");
|
||
request.onsuccess = () => resolve();
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
} catch (error) {
|
||
console.error("Failed to save API list to cache:", error);
|
||
}
|
||
};
|
||
|
||
const commonParams = [
|
||
{ name: "action", desc: "请求的服务接口名称", required: "是" },
|
||
{ name: "token", desc: "携带的鉴权token", required: "是" },
|
||
{ name: "params", desc: "业务参数对象", required: "是" },
|
||
];
|
||
|
||
const commonResponse = [
|
||
{ name: "code", desc: "状态码, 0表示成功,103表示鉴权失败" },
|
||
{ name: "msg", desc: "错误信息" },
|
||
{ name: "data", desc: "数据内容,对象或列表" },
|
||
];
|
||
|
||
// 处理接口列表数据
|
||
const processApiList = (apiData) => {
|
||
// 展开API列表,如果一个API包含多个action,则分开显示
|
||
const expandedApis = [];
|
||
apiData.forEach((api) => {
|
||
// 如果action是数组,展开成多个API项
|
||
if (Array.isArray(api.action)) {
|
||
api.action.forEach((action, index) => {
|
||
expandedApis.push({
|
||
...api,
|
||
action: action,
|
||
uniqueId: `${api.id}_${index}`, // 生成唯一ID
|
||
originalId: api.id, // 保留原始ID用于获取详情
|
||
});
|
||
});
|
||
} else {
|
||
// 单个action,直接添加
|
||
expandedApis.push({
|
||
...api,
|
||
uniqueId: `${api.id}_0`, // 生成唯一ID
|
||
originalId: api.id, // 保留原始ID用于获取详情
|
||
});
|
||
}
|
||
});
|
||
|
||
// 按action排序
|
||
return expandedApis.sort((a, b) => a.action.localeCompare(b.action));
|
||
};
|
||
|
||
|
||
|
||
onMounted(async () => {
|
||
checkDeviceType();
|
||
window.addEventListener("resize", checkDeviceType);
|
||
|
||
loading.value = true; // 先显示加载状态,避免显示空白页面
|
||
loadError.value = null;
|
||
|
||
// 先从缓存获取接口列表
|
||
let cachedApiList = null;
|
||
try {
|
||
cachedApiList = await getCachedApiList();
|
||
if (cachedApiList) {
|
||
// 使用缓存数据先渲染
|
||
apis.value = processApiList(cachedApiList);
|
||
// 有缓存数据后,立即隐藏加载状态,让用户看到内容
|
||
loading.value = false;
|
||
|
||
// 检查URL是否包含API id参数
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const id = urlParams.get("id");
|
||
if (id) {
|
||
const apiToShow = findApiById(id);
|
||
if (apiToShow) {
|
||
goToApi(apiToShow);
|
||
}
|
||
}
|
||
}
|
||
// 如果没有缓存数据,继续显示加载状态,等待请求完成
|
||
} catch (error) {
|
||
console.error("Failed to load cached API list:", error);
|
||
// 缓存加载失败,继续显示加载状态,等待请求完成
|
||
}
|
||
|
||
// 然后请求最新的接口列表(后台无感刷新)
|
||
try {
|
||
const response = await axios.get(hostUrl.value, {
|
||
params: {
|
||
action: "getActionList",
|
||
},
|
||
});
|
||
|
||
// 处理并保存最新的接口列表
|
||
const processedApis = processApiList(response.data.data);
|
||
apis.value = processedApis;
|
||
|
||
// 保存到缓存
|
||
await saveApiListToCache(response.data.data);
|
||
|
||
// 检查URL是否包含API id参数
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const id = urlParams.get("id");
|
||
if (id) {
|
||
const apiToShow = findApiById(id);
|
||
if (apiToShow) {
|
||
goToApi(apiToShow);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to fetch API list:", error);
|
||
// 如果请求失败且没有缓存数据,显示错误
|
||
if (!cachedApiList) {
|
||
loadError.value =
|
||
error.response?.data?.msg || error.message || "获取接口列表失败,请稍后重试";
|
||
} else {
|
||
// 有缓存数据,只提示用户使用的是缓存数据(静默提示,不干扰用户)
|
||
ElMessage.warning("获取最新接口列表失败,已使用缓存数据");
|
||
}
|
||
} finally {
|
||
// 确保最终隐藏加载状态(如果没有缓存数据,这里会隐藏;如果有缓存数据,已经在上面隐藏了)
|
||
loading.value = false;
|
||
}
|
||
|
||
});
|
||
|
||
// 根据id查找API
|
||
const findApiById = (id) => {
|
||
return apis.value.find((a) => a.uniqueId === id || a.originalId === id) || null;
|
||
};
|
||
|
||
// 更新URL参数
|
||
const updateUrlParameter = (id) => {
|
||
const url = new URL(window.location);
|
||
if (id) {
|
||
url.searchParams.set("id", id);
|
||
} else {
|
||
url.searchParams.delete("id");
|
||
}
|
||
window.history.pushState({}, "", url);
|
||
};
|
||
|
||
// 路由到首页
|
||
const goHome = () => {
|
||
currentApi.value = null;
|
||
currentPath.value = [];
|
||
currentPathKeys.value = [];
|
||
updateUrlParameter(null);
|
||
};
|
||
|
||
// 在侧边栏树中查找 API 所在的目录路径
|
||
const findApiMenuPath = (api) => {
|
||
if (!api) return { pathKeys: [], pathNames: [] };
|
||
|
||
const search = (nodes, pathKeys, pathNames) => {
|
||
for (const node of nodes) {
|
||
if (node.apis?.some((item) => item.uniqueId === api.uniqueId)) {
|
||
if (node.isUngrouped) {
|
||
return { pathKeys: [], pathNames: [] };
|
||
}
|
||
return {
|
||
pathKeys: [...pathKeys, node.key],
|
||
pathNames: [...pathNames, node.displayName],
|
||
};
|
||
}
|
||
if (node.children?.length) {
|
||
const result = search(
|
||
node.children,
|
||
[...pathKeys, node.key],
|
||
[...pathNames, node.displayName]
|
||
);
|
||
if (result) return result;
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
return search(apiTree.value, [], []) || { pathKeys: [], pathNames: [] };
|
||
};
|
||
|
||
// 将左侧菜单导航到 API 所在目录
|
||
const navigateToApiInMenu = (api) => {
|
||
const { pathKeys, pathNames } = findApiMenuPath(api);
|
||
currentPathKeys.value = pathKeys;
|
||
currentPath.value = pathNames;
|
||
};
|
||
|
||
// 路由到指定API
|
||
const goToApi = (api) => {
|
||
navigateToApiInMenu(api);
|
||
showApiDetail(api);
|
||
updateUrlParameter(api.uniqueId);
|
||
};
|
||
|
||
const checkDeviceType = () => {
|
||
windowWidth.value = window.innerWidth;
|
||
isMobile.value = windowWidth.value <= 768;
|
||
showMenu.value = !isMobile.value;
|
||
};
|
||
|
||
const toggleMenu = () => {
|
||
showMenu.value = !showMenu.value;
|
||
};
|
||
|
||
const showApiDetail = async (api) => {
|
||
activeTab.value = "docs";
|
||
currentApi.value = api;
|
||
apiDetail.value = null; // 清空之前的详情
|
||
apiDetailError.value = null; // 清空错误信息
|
||
apiDetailLoading.value = true;
|
||
try {
|
||
const response = await axios.get(hostUrl.value, {
|
||
params: {
|
||
action: "getActionDetail",
|
||
id: api.originalId || api.id,
|
||
},
|
||
});
|
||
apiDetail.value = response.data.data;
|
||
} catch (error) {
|
||
console.error("Failed to fetch API detail:", error);
|
||
// 如果接口详情加载失败,尝试从接口列表中获取基本信息
|
||
if (api) {
|
||
// 使用接口列表中的基本信息作为详情
|
||
apiDetail.value = {
|
||
action: api.action,
|
||
paramModel: null,
|
||
responseModel: null,
|
||
// 可以添加其他从接口列表中获取的字段
|
||
};
|
||
apiDetailError.value = "获取接口详情失败,已使用接口列表中的基本信息";
|
||
ElMessage.warning("获取接口详情失败,已使用接口列表中的基本信息");
|
||
} else {
|
||
apiDetailError.value =
|
||
error.response?.data?.msg || error.message || "获取接口详情失败";
|
||
}
|
||
} finally {
|
||
apiDetailLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const generateCurlExample = (api) => {
|
||
// 获取必传参数
|
||
const requiredParams =
|
||
apiDetail.value?.paramModel?.fields
|
||
?.filter((field) => field.required)
|
||
?.reduce((acc, field) => {
|
||
acc[field.name] = getExampleValue(field);
|
||
return acc;
|
||
}, {}) || {};
|
||
|
||
return `curl -X POST 'API_URL' \\
|
||
-H 'Content-Type: application/json' \\
|
||
-d '{
|
||
"action": "${api.action}",
|
||
"token": "YOUR_TOKEN",
|
||
"params": {${Object.entries(requiredParams)
|
||
.map(([key, value]) => '\n "' + key + '": ' + JSON.stringify(value))
|
||
.join(",") || ""
|
||
}
|
||
}
|
||
}'`;
|
||
};
|
||
|
||
const generateJsExample = (api) => {
|
||
// 获取必传参数
|
||
const requiredParams =
|
||
apiDetail.value?.paramModel?.fields
|
||
?.filter((field) => field.required)
|
||
?.reduce((acc, field) => {
|
||
acc[field.name] = getExampleValue(field);
|
||
return acc;
|
||
}, {}) || {};
|
||
|
||
return `// 使用 axios
|
||
axios.post('API_URL', {
|
||
action: '${api.action}',
|
||
token: 'YOUR_TOKEN',
|
||
params: {${Object.entries(requiredParams)
|
||
.map(([key, value]) => "\n " + key + ": " + JSON.stringify(value))
|
||
.join(",") || ""
|
||
}
|
||
}
|
||
})
|
||
.then(response => {
|
||
console.log(response.data);
|
||
})
|
||
.catch(error => {
|
||
console.error(error);
|
||
});`;
|
||
};
|
||
|
||
// 根据类型生成示例值
|
||
const getExampleValue = (field) => {
|
||
if (!field) return null;
|
||
|
||
if (field.typeName === "String") return "example";
|
||
if (field.typeName === "Integer" || field.typeName === "Long") return 0;
|
||
if (field.typeName === "Double" || field.typeName === "Float") return 0.0;
|
||
if (field.typeName === "Boolean") return false;
|
||
if (field.typeName === "Date") return "2023-01-01";
|
||
if (field.typeName === "DateTime") return "2023-01-01 00:00:00";
|
||
if (field.typeName === "List" || field.typeName === "Set" || field.typeName === "Array")
|
||
return [];
|
||
if (field.typeName === "Map" || !field.basicProperty) return {};
|
||
|
||
return null;
|
||
};
|
||
|
||
const isModelGroup = (model) => {
|
||
return (
|
||
model &&
|
||
!model.basicProperty &&
|
||
Array.isArray(model.fields) &&
|
||
model.fields.length > 0
|
||
);
|
||
};
|
||
|
||
const getModelDisplayName = (model) => {
|
||
if (!model) return "";
|
||
const containerTypes = ["List", "Set", "Map", "Array"];
|
||
if (model.simpleName && !containerTypes.includes(model.simpleName)) {
|
||
return model.simpleName;
|
||
}
|
||
if (model.className) {
|
||
const shortName = model.className.split(".").pop();
|
||
if (shortName) return shortName;
|
||
}
|
||
return model.simpleName || "";
|
||
};
|
||
|
||
const hasNestedModelContent = (model) => {
|
||
if (!model) return false;
|
||
if (isModelGroup(model)) return true;
|
||
return (model.parametricModels || []).some((item) => hasNestedModelContent(item));
|
||
};
|
||
|
||
const getAllModels = (model, parentInfo) => {
|
||
if (!model) return [];
|
||
|
||
const models = [];
|
||
|
||
if (isModelGroup(model)) {
|
||
models.push(Object.assign({ parentInfo }, model));
|
||
}
|
||
|
||
const collectFromFields = (fields) => {
|
||
(fields || []).forEach((field) => {
|
||
if (field.parametricModels?.length) {
|
||
field.parametricModels.forEach((parametricModel) => {
|
||
models.push(...getAllModels(parametricModel));
|
||
});
|
||
} else if (field.model?.parametricModels?.length) {
|
||
field.model.parametricModels.forEach((parametricModel) => {
|
||
models.push(...getAllModels(parametricModel));
|
||
});
|
||
} else if (field.model && !field.model.basicProperty) {
|
||
models.push(...getAllModels(field.model));
|
||
}
|
||
});
|
||
};
|
||
|
||
if (isModelGroup(model)) {
|
||
collectFromFields(model.fields);
|
||
}
|
||
|
||
// List / Map 等 basicProperty 泛型容器,继续展开泛型参数中的模型
|
||
if (model.parametricModels?.length) {
|
||
model.parametricModels.forEach((parametricModel, index) => {
|
||
models.push(
|
||
...getAllModels(parametricModel, {
|
||
index,
|
||
length: model.parametricModels.length,
|
||
simpleName: model.simpleName,
|
||
})
|
||
);
|
||
});
|
||
}
|
||
|
||
return models;
|
||
};
|
||
|
||
// 是否应显示模型锚点链接(basicProperty 基础类型不跳转,泛型参数中的对象类型可跳转)
|
||
const isObjectType = (field) => {
|
||
if (!field) return false;
|
||
// 泛型容器(List/Map 等)本身不跳转,由 getGenericParams 处理泛型参数链接
|
||
if (field.basicProperty && isGenericType(field)) return false;
|
||
if (field.basicProperty) return false;
|
||
if (field.model?.basicProperty && !field.model?.parametricModels?.length) return false;
|
||
if (field.model) return hasNestedModelContent(field.model);
|
||
return true;
|
||
};
|
||
|
||
const isGenericType = (field) => {
|
||
return field && field.parametricModels && field.parametricModels.length > 0;
|
||
};
|
||
|
||
const getTypeName = (field) => {
|
||
if (!field) {
|
||
return "";
|
||
}
|
||
return field.typeName || field.simpleName || "";
|
||
};
|
||
|
||
const getGenericParams = (field) => {
|
||
if (!field || !field.parametricModels) {
|
||
return [];
|
||
}
|
||
return field.parametricModels
|
||
.filter((item) => !!item)
|
||
.map((item) => ({
|
||
name: getModelDisplayName(item),
|
||
isObject: hasNestedModelContent(item),
|
||
}));
|
||
};
|
||
|
||
// 获取完整的类型名称(包括泛型参数)
|
||
const getFullTypeName = (field) => {
|
||
if (!field) {
|
||
return "";
|
||
}
|
||
|
||
const baseType = getTypeName(field);
|
||
|
||
// 如果是泛型类型,添加泛型参数
|
||
if (isGenericType(field)) {
|
||
const genericParams = getGenericParams(field)
|
||
.map((param) => param.name)
|
||
.join(", ");
|
||
return `${baseType}<${genericParams}>`;
|
||
}
|
||
|
||
return baseType;
|
||
};
|
||
|
||
// 获取action的显示名称(去掉上级路径)
|
||
const getActionDisplayName = (action, parentPath) => {
|
||
if (!action || typeof action !== "string") return action;
|
||
if (!parentPath) return action;
|
||
const prefix = parentPath + ".";
|
||
if (action.startsWith(prefix)) {
|
||
return action.substring(prefix.length);
|
||
}
|
||
return action;
|
||
};
|
||
|
||
// 解析点分路径,构建目录/文件树(最后一段为接口,其余为目录)
|
||
const buildTree = (paths) => {
|
||
const root = {};
|
||
|
||
paths.forEach((path) => {
|
||
const parts = path.split(".");
|
||
let current = root;
|
||
|
||
parts.forEach((part, index) => {
|
||
const isFile = index === parts.length - 1;
|
||
|
||
if (!current[part]) {
|
||
current[part] = isFile
|
||
? { type: "file" }
|
||
: { type: "dir", children: {} };
|
||
}
|
||
|
||
if (!isFile && current[part].type !== "dir") {
|
||
throw new Error(`冲突: "${part}" 不能同时是文件和目录`);
|
||
}
|
||
|
||
if (!isFile) {
|
||
current = current[part].children;
|
||
}
|
||
});
|
||
});
|
||
|
||
return root;
|
||
};
|
||
|
||
// 将 buildTree 结果转为侧边栏节点,并挂载 API 对象
|
||
const convertTreeToUiNodes = (treeObj, parentPath, apiMap) => {
|
||
return Object.entries(treeObj)
|
||
.filter(([, node]) => node.type === "dir")
|
||
.map(([name, node]) => {
|
||
const fullPath = parentPath ? `${parentPath}.${name}` : name;
|
||
const children = [];
|
||
const apis = [];
|
||
|
||
Object.entries(node.children).forEach(([part, child]) => {
|
||
if (child.type === "file") {
|
||
const api = apiMap.get(`${fullPath}.${part}`);
|
||
if (api) apis.push(api);
|
||
} else if (child.type === "dir") {
|
||
children.push(...convertTreeToUiNodes({ [part]: child }, fullPath, apiMap));
|
||
}
|
||
});
|
||
|
||
return {
|
||
key: fullPath,
|
||
displayName: name,
|
||
fullPath,
|
||
children,
|
||
apis,
|
||
};
|
||
})
|
||
.sort((a, b) => {
|
||
if (a.children.length > 0 && b.children.length === 0) return -1;
|
||
if (a.children.length === 0 && b.children.length > 0) return 1;
|
||
return a.displayName.localeCompare(b.displayName);
|
||
});
|
||
};
|
||
|
||
// 按点分路径分类接口(有层级 / 未分组)
|
||
const classifyApisByPath = (apiList) => {
|
||
const ungrouped = [];
|
||
const groupedApis = [];
|
||
|
||
apiList.forEach((api) => {
|
||
if (!api.action || typeof api.action !== "string") {
|
||
ungrouped.push(api);
|
||
return;
|
||
}
|
||
|
||
const parts = api.action.split(".");
|
||
if (parts.length <= 1 || parts[0] === "") {
|
||
ungrouped.push(api);
|
||
} else {
|
||
groupedApis.push(api);
|
||
}
|
||
});
|
||
|
||
return { groupedApis, ungrouped };
|
||
};
|
||
|
||
// 导出树节点排序:目录在前,接口在后,同类型按名称排序
|
||
const sortExportNodes = (nodes) => {
|
||
return nodes.sort((a, b) => {
|
||
if (!a.isApi && b.isApi) return -1;
|
||
if (a.isApi && !b.isApi) return 1;
|
||
return a.label.localeCompare(b.label);
|
||
});
|
||
};
|
||
|
||
// 将 buildTree 结果转为导出树节点(el-tree 格式)
|
||
const convertTreeToExportNodes = (treeObj, parentPath, apiMap) => {
|
||
const result = [];
|
||
|
||
Object.entries(treeObj).forEach(([name, node]) => {
|
||
if (node.type === "dir") {
|
||
const fullPath = parentPath ? `${parentPath}.${name}` : name;
|
||
const children = [];
|
||
|
||
Object.entries(node.children).forEach(([part, child]) => {
|
||
if (child.type === "file") {
|
||
const api = apiMap.get(`${fullPath}.${part}`);
|
||
if (api) {
|
||
children.push({
|
||
id: api.uniqueId,
|
||
label: getActionDisplayName(api.action, fullPath),
|
||
isApi: true,
|
||
api,
|
||
});
|
||
}
|
||
} else if (child.type === "dir") {
|
||
const subNodes = convertTreeToExportNodes({ [part]: child }, fullPath, apiMap);
|
||
children.push(...subNodes);
|
||
}
|
||
});
|
||
|
||
result.push({
|
||
id: fullPath,
|
||
label: name,
|
||
isApi: false,
|
||
children: sortExportNodes(children),
|
||
});
|
||
} else if (node.type === "file") {
|
||
const actionPath = parentPath ? `${parentPath}.${name}` : name;
|
||
const api = apiMap.get(actionPath);
|
||
if (api) {
|
||
result.push({
|
||
id: api.uniqueId,
|
||
label: api.action,
|
||
isApi: true,
|
||
api,
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
return sortExportNodes(result);
|
||
};
|
||
|
||
// 构建导出树(支持无限级)
|
||
const buildExportTree = (apiList) => {
|
||
const { groupedApis, ungrouped } = classifyApisByPath(apiList);
|
||
const result = [];
|
||
|
||
if (groupedApis.length > 0) {
|
||
try {
|
||
const tree = buildTree(groupedApis.map((api) => api.action));
|
||
const apiMap = new Map(groupedApis.map((api) => [api.action, api]));
|
||
result.push(...convertTreeToExportNodes(tree, "", apiMap));
|
||
} catch (error) {
|
||
console.error("导出树路径解析冲突:", error);
|
||
groupedApis.forEach((api) => ungrouped.push(api));
|
||
}
|
||
}
|
||
|
||
ungrouped.forEach((api) => {
|
||
result.push({
|
||
id: api.uniqueId,
|
||
label: api.action,
|
||
isApi: true,
|
||
api,
|
||
});
|
||
});
|
||
|
||
return sortExportNodes(result);
|
||
};
|
||
|
||
// 构建树形结构(支持无限级)
|
||
const buildApiTree = (apiList) => {
|
||
const { groupedApis, ungrouped } = classifyApisByPath(apiList);
|
||
|
||
let treeArray = [];
|
||
if (groupedApis.length > 0) {
|
||
try {
|
||
const tree = buildTree(groupedApis.map((api) => api.action));
|
||
const apiMap = new Map(groupedApis.map((api) => [api.action, api]));
|
||
treeArray = convertTreeToUiNodes(tree, "", apiMap);
|
||
} catch (error) {
|
||
console.error("接口路径解析冲突:", error);
|
||
groupedApis.forEach((api) => ungrouped.push(api));
|
||
}
|
||
}
|
||
|
||
// 未分组的API也添加到结果中
|
||
ungrouped.forEach((api) => {
|
||
treeArray.push({
|
||
key: api.uniqueId,
|
||
displayName: api.action,
|
||
fullPath: "",
|
||
children: [],
|
||
apis: [api], // 将api放入apis数组中,保持结构一致
|
||
isUngrouped: true, // 标记为未分组
|
||
});
|
||
});
|
||
|
||
return treeArray;
|
||
};
|
||
|
||
// 搜索匹配函数(大小写不敏感)
|
||
const matchesSearch = (text, query) => {
|
||
if (!text || !query) return false;
|
||
return text.toLowerCase().includes(query.toLowerCase());
|
||
};
|
||
|
||
// 过滤后的API列表(搜索时使用)
|
||
const filteredApis = computed(() => {
|
||
if (!searchQuery.value) {
|
||
return apis.value;
|
||
}
|
||
|
||
const query = searchQuery.value.toLowerCase();
|
||
return apis.value.filter((api) => {
|
||
if (!api.action) return false;
|
||
return matchesSearch(api.action, query);
|
||
});
|
||
});
|
||
|
||
// API树形结构(根据是否有搜索决定使用原始数据还是过滤后的数据)
|
||
const apiTree = computed(() => {
|
||
// 如果有搜索,使用过滤后的API列表构建树;否则使用原始API列表
|
||
const apiList = searchQuery.value ? filteredApis.value : apis.value;
|
||
return buildApiTree(apiList);
|
||
});
|
||
|
||
// 过滤API树(保持兼容性,实际已通过 apiTree 实现)
|
||
const filteredApiTree = computed(() => {
|
||
return apiTree.value;
|
||
});
|
||
|
||
// 根据当前路径获取当前层级要显示的节点和API
|
||
const currentLevelNodes = computed(() => {
|
||
// 根据路径找到当前节点
|
||
let currentNode = apiTree.value;
|
||
let currentParentNode = null;
|
||
|
||
for (const key of currentPathKeys.value) {
|
||
const found = currentNode.find((node) => node.key === key);
|
||
if (found) {
|
||
currentParentNode = found;
|
||
currentNode = found.children || [];
|
||
} else {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// 如果当前父节点只有API没有子节点,返回空数组(API由 currentLevelApis 处理)
|
||
if (
|
||
currentParentNode &&
|
||
currentParentNode.apis.length > 0 &&
|
||
currentNode.length === 0
|
||
) {
|
||
return [];
|
||
}
|
||
|
||
// 如果有搜索,由于已经通过 filteredApis 过滤了数据并重新构建树,所以直接返回当前节点即可
|
||
// 搜索只匹配 action,不匹配节点名称
|
||
if (searchQuery.value) {
|
||
// 树中已经只包含匹配的 API,直接返回
|
||
return currentNode;
|
||
}
|
||
|
||
return currentNode;
|
||
});
|
||
|
||
// 当前层级的API(用于显示当前节点下的API列表,即使该节点有子节点也会显示)
|
||
const currentLevelApis = computed(() => {
|
||
if (currentPathKeys.value.length === 0) return [];
|
||
|
||
let currentNode = apiTree.value;
|
||
let currentParentNode = null;
|
||
|
||
for (const key of currentPathKeys.value) {
|
||
const found = currentNode.find((node) => node.key === key);
|
||
if (found) {
|
||
currentParentNode = found;
|
||
currentNode = found.children || [];
|
||
} else {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// 如果当前父节点有API,返回这些API(即使该节点有子节点也会显示)
|
||
if (currentParentNode && currentParentNode.apis.length > 0) {
|
||
// 如果有搜索,由于已经通过 filteredApis 过滤了数据,所以 apis 中已经只包含匹配的 API
|
||
// 搜索只匹配 action,不匹配节点名称
|
||
if (searchQuery.value) {
|
||
const queryLower = searchQuery.value.toLowerCase();
|
||
return currentParentNode.apis.sort((a, b) => {
|
||
// 排序:精确匹配优先
|
||
const aMatch = a.action.toLowerCase() === queryLower;
|
||
const bMatch = b.action.toLowerCase() === queryLower;
|
||
if (aMatch && !bMatch) return -1;
|
||
if (!aMatch && bMatch) return 1;
|
||
return a.action.localeCompare(b.action);
|
||
});
|
||
}
|
||
return currentParentNode.apis;
|
||
}
|
||
|
||
return [];
|
||
});
|
||
|
||
// 进入分组
|
||
const enterGroup = (node) => {
|
||
currentPath.value.push(node.displayName);
|
||
currentPathKeys.value.push(node.key);
|
||
|
||
// 自动遍历:如果当前目录没有接口且只有一个子目录,自动进入下一个目录
|
||
autoNavigateToNextGroup();
|
||
};
|
||
|
||
// 自动遍历到下一个目录(如果当前目录没有接口且只有一个子目录)
|
||
const autoNavigateToNextGroup = () => {
|
||
// 根据路径找到当前节点
|
||
let currentNode = apiTree.value;
|
||
let currentParentNode = null;
|
||
|
||
for (const key of currentPathKeys.value) {
|
||
const found = currentNode.find((node) => node.key === key);
|
||
if (found) {
|
||
currentParentNode = found;
|
||
currentNode = found.children || [];
|
||
} else {
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 如果当前目录没有API且只有一个子目录,自动进入该子目录
|
||
// 循环直到找到有API的目录或者有多个子目录的目录
|
||
while (
|
||
currentParentNode &&
|
||
currentParentNode.apis.length === 0 &&
|
||
currentNode.length === 1
|
||
) {
|
||
const nextNode = currentNode[0];
|
||
currentPath.value.push(nextNode.displayName);
|
||
currentPathKeys.value.push(nextNode.key);
|
||
|
||
// 更新当前节点
|
||
currentParentNode = nextNode;
|
||
currentNode = nextNode.children || [];
|
||
}
|
||
};
|
||
|
||
// 返回上一级
|
||
const goBack = () => {
|
||
if (currentPath.value.length > 0) {
|
||
currentPath.value.pop();
|
||
currentPathKeys.value.pop();
|
||
}
|
||
};
|
||
|
||
// 导航到指定路径
|
||
const navigateToPath = (index) => {
|
||
// 截取到指定索引的路径
|
||
currentPath.value = currentPath.value.slice(0, index + 1);
|
||
currentPathKeys.value = currentPathKeys.value.slice(0, index + 1);
|
||
};
|
||
|
||
// 获取当前节点API的显示名称
|
||
const getCurrentNodeApiDisplayName = (api) => {
|
||
if (!api || !api.action) return api.action || "";
|
||
|
||
// 找到当前节点的 fullPath
|
||
let currentNode = apiTree.value;
|
||
let currentParentNode = null;
|
||
|
||
for (const key of currentPathKeys.value) {
|
||
const found = currentNode.find((node) => node.key === key);
|
||
if (found) {
|
||
currentParentNode = found;
|
||
currentNode = found.children || [];
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (currentParentNode && currentParentNode.fullPath) {
|
||
return getActionDisplayName(api.action, currentParentNode.fullPath);
|
||
}
|
||
|
||
return api.action;
|
||
};
|
||
|
||
// 下载接口文档
|
||
const downloadApiDocumentation = async () => {
|
||
downloading.value = true;
|
||
try {
|
||
// 直接使用接口列表数据,不请求详情
|
||
const allApiDetails = apis.value.map((api) => ({
|
||
api,
|
||
detail: api, // 使用接口列表数据本身作为详情
|
||
}));
|
||
|
||
// 生成Markdown文档
|
||
const markdown = generateMarkdownDocumentation(allApiDetails);
|
||
|
||
// 下载文件
|
||
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = `API文档_${new Date().toISOString().split("T")[0]}.md`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
} catch (error) {
|
||
console.error("Failed to download documentation:", error);
|
||
alert("下载失败,请稍后重试");
|
||
} finally {
|
||
downloading.value = false;
|
||
}
|
||
};
|
||
|
||
// 生成Markdown文档内容
|
||
const generateMarkdownDocumentation = (allApiDetails) => {
|
||
let markdown = "# API 接口文档\n\n";
|
||
markdown += `生成时间: ${new Date().toLocaleString("zh-CN")}\n\n`;
|
||
markdown += `总计接口数: ${allApiDetails.length}\n\n`;
|
||
markdown += "---\n\n";
|
||
|
||
// 通用请求参数
|
||
markdown += "## 通用请求参数\n\n";
|
||
markdown += "| 参数名| 必填 | 说明 |\n";
|
||
markdown += "|--------|------|----------|\n";
|
||
commonParams.forEach((param) => {
|
||
markdown += `| ${param.name} | ${param.required} | ${param.desc} |\n`;
|
||
});
|
||
markdown += "\n";
|
||
|
||
// 通用响应格式
|
||
markdown += "## 通用响应格式\n\n";
|
||
markdown += "| 字段名 | 说明 |\n";
|
||
markdown += "|--------|------|\n";
|
||
commonResponse.forEach((field) => {
|
||
markdown += `| ${field.name} | ${field.desc} |\n`;
|
||
});
|
||
markdown += "\n---\n\n";
|
||
|
||
// 接口详情
|
||
markdown += "## 接口详情\n\n";
|
||
allApiDetails.forEach(({ api, detail }, index) => {
|
||
markdown += `### ${index + 1}. ${api.action}\n\n`;
|
||
|
||
if (detail) {
|
||
// 请求参数
|
||
if (detail.paramModel) {
|
||
markdown += "#### 请求参数\n\n";
|
||
if (detail.paramModel.basicProperty) {
|
||
markdown += `类型:${getFullTypeName(detail.paramModel)}\n\n`;
|
||
}
|
||
const paramModels = getAllModels(detail.paramModel);
|
||
if (paramModels.length > 0) {
|
||
paramModels.forEach((model) => {
|
||
markdown += `**${getModelDisplayName(model)}**\n\n`;
|
||
markdown += "| 参数名 | 类型 | 必填 | 说明 |\n";
|
||
markdown += "|--------|------|------|----------|\n";
|
||
(model.fields || []).forEach((field) => {
|
||
const typeName = getFullTypeName(field);
|
||
const required = field.required ? "是" : "否";
|
||
let desc = field.desc || "-";
|
||
|
||
if (
|
||
field.enumInfo &&
|
||
field.enumInfo.properties &&
|
||
field.enumInfo.properties.length > 0
|
||
) {
|
||
const enumValues = field.enumInfo.properties
|
||
.map((item) => `${item.desc}(${item.value})`)
|
||
.join("、");
|
||
desc =
|
||
desc !== "-"
|
||
? `${desc};枚举值:${enumValues}`
|
||
: `枚举值:${enumValues}`;
|
||
}
|
||
|
||
markdown += `| ${field.name} | ${typeName} | ${required} | ${desc} |\n`;
|
||
});
|
||
markdown += "\n";
|
||
});
|
||
} else if (!detail.paramModel.basicProperty) {
|
||
markdown += "无参数\n\n";
|
||
}
|
||
}
|
||
|
||
// 请求响应
|
||
if (detail.responseModel) {
|
||
markdown += "#### 请求响应\n\n";
|
||
if (detail.responseModel.basicProperty) {
|
||
markdown += `类型:${getFullTypeName(detail.responseModel)}\n\n`;
|
||
}
|
||
const responseModels = getAllModels(detail.responseModel);
|
||
if (responseModels.length > 0) {
|
||
responseModels.forEach((model) => {
|
||
markdown += `**${getModelDisplayName(model)}**\n\n`;
|
||
markdown += "| 字段名 | 类型 | 说明 |\n";
|
||
markdown += "|--------|------|------|\n";
|
||
(model.fields || []).forEach((field) => {
|
||
const typeName = getFullTypeName(field);
|
||
let desc = field.desc || "-";
|
||
|
||
if (
|
||
field.enumInfo &&
|
||
field.enumInfo.properties &&
|
||
field.enumInfo.properties.length > 0
|
||
) {
|
||
const enumValues = field.enumInfo.properties
|
||
.map((item) => `${item.desc}(${item.value})`)
|
||
.join("、");
|
||
desc =
|
||
desc !== "-"
|
||
? `${desc};枚举值:${enumValues}`
|
||
: `枚举值:${enumValues}`;
|
||
}
|
||
|
||
markdown += `| ${field.name} | ${typeName} | ${desc} |\n`;
|
||
});
|
||
markdown += "\n";
|
||
});
|
||
} else if (!detail.responseModel.basicProperty) {
|
||
markdown += "无响应数据\n\n";
|
||
}
|
||
} else {
|
||
markdown += "#### 请求响应\n\n";
|
||
markdown += "无响应数据\n\n";
|
||
}
|
||
} else {
|
||
markdown += "> 无法获取接口详情\n\n";
|
||
}
|
||
|
||
markdown += "---\n\n";
|
||
});
|
||
|
||
return markdown;
|
||
};
|
||
|
||
// 生成单个接口的Markdown文档
|
||
const generateSingleApiMarkdown = (api, detail) => {
|
||
let markdown = `# ${api.action}\n\n`;
|
||
markdown += `生成时间: ${new Date().toLocaleString("zh-CN")}\n\n`;
|
||
markdown += "---\n\n";
|
||
|
||
// 通用请求参数
|
||
markdown += "## 通用请求参数\n\n";
|
||
markdown += "| 参数名| 必填 | 说明 |\n";
|
||
markdown += "|--------|------|----------|\n";
|
||
commonParams.forEach((param) => {
|
||
markdown += `| ${param.name} | ${param.required} | ${param.desc} |\n`;
|
||
});
|
||
markdown += "\n";
|
||
|
||
// 通用响应格式
|
||
markdown += "## 通用响应格式\n\n";
|
||
markdown += "| 字段名 | 说明 |\n";
|
||
markdown += "|--------|------|\n";
|
||
commonResponse.forEach((field) => {
|
||
markdown += `| ${field.name} | ${field.desc} |\n`;
|
||
});
|
||
markdown += "\n---\n\n";
|
||
|
||
// 接口详情
|
||
markdown += `## ${api.action}\n\n`;
|
||
|
||
if (detail) {
|
||
// 请求参数
|
||
if (detail.paramModel) {
|
||
markdown += "### 请求参数\n\n";
|
||
if (detail.paramModel.basicProperty) {
|
||
markdown += `类型:${getFullTypeName(detail.paramModel)}\n\n`;
|
||
}
|
||
const paramModels = getAllModels(detail.paramModel);
|
||
if (paramModels.length > 0) {
|
||
paramModels.forEach((model) => {
|
||
markdown += `**${getModelDisplayName(model)}**\n\n`;
|
||
markdown += "| 参数名 | 类型 | 必填 | 说明 |\n";
|
||
markdown += "|--------|------|------|----------|\n";
|
||
(model.fields || []).forEach((field) => {
|
||
const typeName = getFullTypeName(field);
|
||
const required = field.required ? "是" : "否";
|
||
let desc = field.desc || "-";
|
||
|
||
if (
|
||
field.enumInfo &&
|
||
field.enumInfo.properties &&
|
||
field.enumInfo.properties.length > 0
|
||
) {
|
||
const enumValues = field.enumInfo.properties
|
||
.map((item) => `${item.desc}(${item.value})`)
|
||
.join("、");
|
||
desc =
|
||
desc !== "-" ? `${desc};枚举值:${enumValues}` : `枚举值:${enumValues}`;
|
||
}
|
||
|
||
markdown += `| ${field.name} | ${typeName} | ${required} | ${desc} |\n`;
|
||
});
|
||
markdown += "\n";
|
||
});
|
||
} else if (!detail.paramModel.basicProperty) {
|
||
markdown += "无参数\n\n";
|
||
}
|
||
}
|
||
|
||
// 请求响应
|
||
if (detail.responseModel) {
|
||
markdown += "### 请求响应\n\n";
|
||
if (detail.responseModel.basicProperty) {
|
||
markdown += `类型:${getFullTypeName(detail.responseModel)}\n\n`;
|
||
}
|
||
const responseModels = getAllModels(detail.responseModel);
|
||
if (responseModels.length > 0) {
|
||
responseModels.forEach((model) => {
|
||
markdown += `**${getModelDisplayName(model)}**\n\n`;
|
||
markdown += "| 字段名 | 类型 | 说明 |\n";
|
||
markdown += "|--------|------|------|\n";
|
||
(model.fields || []).forEach((field) => {
|
||
const typeName = getFullTypeName(field);
|
||
let desc = field.desc || "-";
|
||
|
||
if (
|
||
field.enumInfo &&
|
||
field.enumInfo.properties &&
|
||
field.enumInfo.properties.length > 0
|
||
) {
|
||
const enumValues = field.enumInfo.properties
|
||
.map((item) => `${item.desc}(${item.value})`)
|
||
.join("、");
|
||
desc =
|
||
desc !== "-" ? `${desc};枚举值:${enumValues}` : `枚举值:${enumValues}`;
|
||
}
|
||
|
||
markdown += `| ${field.name} | ${typeName} | ${desc} |\n`;
|
||
});
|
||
markdown += "\n";
|
||
});
|
||
} else if (!detail.responseModel.basicProperty) {
|
||
markdown += "无响应数据\n\n";
|
||
}
|
||
} else {
|
||
markdown += "### 请求响应\n\n";
|
||
markdown += "无响应数据\n\n";
|
||
}
|
||
} else {
|
||
markdown += "> 无法获取接口详情\n\n";
|
||
}
|
||
|
||
return markdown;
|
||
};
|
||
|
||
// 复制接口MD文档到剪贴板
|
||
const copyApiMarkdown = async () => {
|
||
if (!currentApi.value) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 使用apiDetail(如果有)或api本身作为detail
|
||
const detail = apiDetail.value || currentApi.value;
|
||
const markdown = generateSingleApiMarkdown(currentApi.value, detail);
|
||
|
||
// 使用Clipboard API复制到剪贴板
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
await navigator.clipboard.writeText(markdown);
|
||
ElMessage.success("接口文档已复制到剪贴板");
|
||
} else {
|
||
// 降级方案:使用传统方法
|
||
const textArea = document.createElement("textarea");
|
||
textArea.value = markdown;
|
||
textArea.style.position = "fixed";
|
||
textArea.style.left = "-999999px";
|
||
textArea.style.top = "-999999px";
|
||
document.body.appendChild(textArea);
|
||
textArea.focus();
|
||
textArea.select();
|
||
try {
|
||
document.execCommand("copy");
|
||
ElMessage.success("接口文档已复制到剪贴板");
|
||
} catch (err) {
|
||
ElMessage.error("复制失败,请手动复制");
|
||
}
|
||
document.body.removeChild(textArea);
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to copy markdown:", error);
|
||
ElMessage.error("复制失败,请稍后重试");
|
||
}
|
||
};
|
||
|
||
// 导出树数据
|
||
const exportTreeData = computed(() => {
|
||
const apiList = exportSearchQuery.value
|
||
? apis.value.filter((api) => {
|
||
if (!api.action) return false;
|
||
return api.action.toLowerCase().includes(exportSearchQuery.value.toLowerCase());
|
||
})
|
||
: apis.value;
|
||
return buildExportTree(apiList);
|
||
});
|
||
|
||
// 计算总API数量
|
||
const totalExportApiCount = computed(() => {
|
||
return apis.value.length;
|
||
});
|
||
|
||
// 过滤树节点
|
||
const filterExportNode = (value, data) => {
|
||
if (!value) return true;
|
||
const query = value.toLowerCase();
|
||
if (data.isApi) {
|
||
return data.label.toLowerCase().includes(query);
|
||
}
|
||
// 对于文件夹节点,检查其子节点是否匹配
|
||
return true;
|
||
};
|
||
|
||
// 监听搜索查询,过滤树
|
||
watch(exportSearchQuery, (val) => {
|
||
if (exportTreeRef.value) {
|
||
exportTreeRef.value.filter(val);
|
||
}
|
||
});
|
||
|
||
// 处理树节点选择
|
||
const handleExportTreeCheck = (data, checked) => {
|
||
const checkedKeys = exportTreeRef.value.getCheckedKeys();
|
||
const halfCheckedKeys = exportTreeRef.value.getHalfCheckedKeys();
|
||
|
||
// 收集所有选中的API ID
|
||
const apiIds = new Set();
|
||
|
||
const collectApiIds = (nodes) => {
|
||
nodes.forEach((node) => {
|
||
if (node.isApi) {
|
||
if (checkedKeys.includes(node.id) || halfCheckedKeys.includes(node.id)) {
|
||
apiIds.add(node.id);
|
||
}
|
||
} else if (node.children) {
|
||
collectApiIds(node.children);
|
||
}
|
||
});
|
||
};
|
||
|
||
// 从树数据中收集所有API ID
|
||
const collectAllApiIds = (nodes) => {
|
||
nodes.forEach((node) => {
|
||
if (node.isApi && checkedKeys.includes(node.id)) {
|
||
apiIds.add(node.id);
|
||
} else if (node.children) {
|
||
collectAllApiIds(node.children);
|
||
}
|
||
});
|
||
};
|
||
|
||
collectAllApiIds(exportTreeData.value);
|
||
selectedApiIds.value = Array.from(apiIds);
|
||
};
|
||
|
||
// 全选所有API
|
||
const selectAllExportApis = () => {
|
||
if (exportTreeRef.value) {
|
||
// 获取所有API节点
|
||
const getAllApiNodes = (nodes) => {
|
||
const apiNodes = [];
|
||
nodes.forEach((node) => {
|
||
if (node.isApi) {
|
||
apiNodes.push(node.id);
|
||
} else if (node.children) {
|
||
apiNodes.push(...getAllApiNodes(node.children));
|
||
}
|
||
});
|
||
return apiNodes;
|
||
};
|
||
|
||
const allApiIds = getAllApiNodes(exportTreeData.value);
|
||
exportTreeRef.value.setCheckedKeys(allApiIds);
|
||
selectedApiIds.value = allApiIds;
|
||
}
|
||
};
|
||
|
||
// 取消全选
|
||
const deselectAllExportApis = () => {
|
||
if (exportTreeRef.value) {
|
||
exportTreeRef.value.setCheckedKeys([]);
|
||
selectedApiIds.value = [];
|
||
}
|
||
};
|
||
|
||
// 下载文件辅助函数
|
||
const downloadFile = (content, filename, mimeType) => {
|
||
const blob = new Blob([content], { type: mimeType });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = filename;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
// 处理按需导出
|
||
const handleExportSelected = async () => {
|
||
if (selectedApiIds.value.length === 0) {
|
||
return;
|
||
}
|
||
|
||
downloading.value = true;
|
||
try {
|
||
// 过滤选中的API,直接使用接口列表数据
|
||
const selectedApis = apis.value.filter((api) =>
|
||
selectedApiIds.value.includes(api.uniqueId)
|
||
);
|
||
|
||
// 直接使用接口列表数据,不请求详情
|
||
const allApiDetails = selectedApis.map((api) => ({
|
||
api,
|
||
detail: api, // 使用接口列表数据本身作为详情
|
||
}));
|
||
|
||
// 导出文档
|
||
const markdown = generateMarkdownDocumentation(allApiDetails);
|
||
const filename = `API文档_${new Date().toISOString().split("T")[0]}.md`;
|
||
downloadFile(markdown, filename, "text/markdown;charset=utf-8");
|
||
|
||
ElMessage.success("导出成功!");
|
||
} catch (error) {
|
||
console.error("Failed to export:", error);
|
||
ElMessage.error("导出失败,请稍后重试");
|
||
} finally {
|
||
downloading.value = false;
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
/* 移除 scoped,使样式全局生效 */
|
||
.type-display a.model-link {
|
||
color: var(--el-color-primary);
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.type-display a.model-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* 响应式表格样式 */
|
||
@media (max-width: 768px) {
|
||
.responsive-table {
|
||
width: 100%;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.responsive-table .el-table__body,
|
||
.responsive-table .el-table__header {
|
||
min-width: 100%;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<style scoped>
|
||
.el-container {
|
||
height: 100vh;
|
||
}
|
||
|
||
.mobile-mode {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.mobile-header {
|
||
display: grid;
|
||
grid-template-columns: 40px 1fr 40px;
|
||
align-items: center;
|
||
background-color: #fff;
|
||
border-bottom: 1px solid #eef0f3;
|
||
height: 52px;
|
||
width: 100%;
|
||
padding: 0 8px;
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||
}
|
||
|
||
.mobile-header h2 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #1f2329;
|
||
text-align: center;
|
||
}
|
||
|
||
.menu-toggle-btn {
|
||
font-size: 20px;
|
||
color: #303133;
|
||
}
|
||
|
||
.mobile-header-spacer {
|
||
width: 40px;
|
||
}
|
||
|
||
.mobile-menu-close {
|
||
text-align: right;
|
||
padding: 10px;
|
||
cursor: pointer;
|
||
color: var(--el-color-primary);
|
||
}
|
||
|
||
/* 手机端侧边栏顶部头部(已合并至 sidebar-header) */
|
||
|
||
.el-aside.sidebar {
|
||
background: #f7f8fa;
|
||
border-right: 1px solid #e4e7ed;
|
||
z-index: 1000;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sidebar-inner {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
}
|
||
|
||
.sidebar-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 16px 12px;
|
||
flex-shrink: 0;
|
||
border-bottom: 1px solid #eef0f3;
|
||
background: #fff;
|
||
}
|
||
|
||
.sidebar-brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.brand-icon {
|
||
font-size: 22px;
|
||
color: var(--el-color-primary);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.brand-text {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
}
|
||
|
||
.brand-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #1f2329;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.brand-subtitle {
|
||
font-size: 12px;
|
||
color: #8f959e;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.sidebar-close-btn {
|
||
flex-shrink: 0;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.sidebar-search {
|
||
padding: 10px 12px;
|
||
flex-shrink: 0;
|
||
background: #fff;
|
||
border-bottom: 1px solid #eef0f3;
|
||
}
|
||
|
||
.sidebar-search :deep(.el-input__wrapper) {
|
||
border-radius: 8px;
|
||
box-shadow: 0 0 0 1px #dcdfe6 inset;
|
||
transition: box-shadow 0.2s ease;
|
||
}
|
||
|
||
.sidebar-search :deep(.el-input__wrapper.is-focus) {
|
||
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||
}
|
||
|
||
.sidebar-content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
padding-bottom: 16px;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #dcdfe6 transparent;
|
||
}
|
||
|
||
.sidebar-content::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
|
||
.sidebar-content::-webkit-scrollbar-thumb {
|
||
background: #dcdfe6;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.sidebar-content::-webkit-scrollbar-thumb:hover {
|
||
background: #c0c4cc;
|
||
}
|
||
|
||
.sidebar-error {
|
||
padding: 32px 16px;
|
||
}
|
||
|
||
.sidebar-empty {
|
||
padding: 24px 0;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.sidebar {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
height: 100vh;
|
||
width: 100%;
|
||
z-index: 2000;
|
||
}
|
||
}
|
||
|
||
.menu-vertical {
|
||
background: transparent;
|
||
border: none;
|
||
padding: 4px 0;
|
||
}
|
||
|
||
.menu-vertical :deep(.el-menu-item) {
|
||
height: 40px;
|
||
line-height: 40px;
|
||
border-radius: 8px;
|
||
margin: 2px 8px;
|
||
padding: 0 10px !important;
|
||
transition: background-color 0.2s ease, color 0.2s ease;
|
||
position: relative;
|
||
}
|
||
|
||
.menu-item-inner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
min-width: 0;
|
||
}
|
||
|
||
.menu-item-icon {
|
||
font-size: 16px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.menu-item-icon.folder {
|
||
color: #e6a23c;
|
||
}
|
||
|
||
.menu-item-icon.api {
|
||
color: #409eff;
|
||
}
|
||
|
||
.menu-item-label {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-size: 13px;
|
||
color: #303133;
|
||
}
|
||
|
||
.menu-item-count {
|
||
flex-shrink: 0;
|
||
font-size: 11px;
|
||
color: #909399;
|
||
background: #eef0f3;
|
||
padding: 1px 6px;
|
||
border-radius: 10px;
|
||
line-height: 16px;
|
||
}
|
||
|
||
.menu-item-arrow {
|
||
flex-shrink: 0;
|
||
font-size: 12px;
|
||
color: #c0c4cc;
|
||
transition: transform 0.2s ease, color 0.2s ease;
|
||
}
|
||
|
||
.menu-folder-item:hover .menu-item-arrow {
|
||
color: var(--el-color-primary);
|
||
transform: translateX(2px);
|
||
}
|
||
|
||
.menu-folder-item :deep(.menu-item-label) {
|
||
font-weight: 500;
|
||
}
|
||
|
||
.menu-vertical :deep(.menu-section-item) {
|
||
height: 32px !important;
|
||
line-height: 32px !important;
|
||
margin-top: 4px !important;
|
||
cursor: default !important;
|
||
opacity: 1 !important;
|
||
}
|
||
|
||
.menu-vertical :deep(.menu-section-item:hover) {
|
||
background: transparent !important;
|
||
}
|
||
|
||
.menu-vertical :deep(.menu-section-item::before) {
|
||
display: none !important;
|
||
}
|
||
|
||
.menu-section-label {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: #909399;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.menu-vertical :deep(.el-menu-item:hover) {
|
||
background-color: #eef2ff;
|
||
}
|
||
|
||
.menu-vertical :deep(.el-menu-item.is-active) {
|
||
background-color: #e8f0fe;
|
||
color: var(--el-color-primary);
|
||
}
|
||
|
||
.menu-vertical :deep(.el-menu-item.is-active)::before {
|
||
content: "";
|
||
position: absolute;
|
||
left: 0;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 3px;
|
||
height: 18px;
|
||
background: var(--el-color-primary);
|
||
border-radius: 0 2px 2px 0;
|
||
}
|
||
|
||
.menu-vertical :deep(.menu-api-item.is-active .menu-item-label) {
|
||
color: var(--el-color-primary);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.menu-vertical :deep(.menu-api-item.is-active .menu-item-icon) {
|
||
color: var(--el-color-primary);
|
||
}
|
||
|
||
.main-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
padding: 0 !important;
|
||
background: #f7f8fa;
|
||
}
|
||
|
||
.content-frame {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background: #f7f8fa;
|
||
}
|
||
|
||
.content-nav-wrap,
|
||
.content-page {
|
||
width: 100%;
|
||
padding-left: 24px;
|
||
padding-right: 24px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.content-nav-wrap {
|
||
flex-shrink: 0;
|
||
padding-top: 16px;
|
||
padding-bottom: 0;
|
||
}
|
||
|
||
.content-nav-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 12px 20px;
|
||
margin-bottom: 0;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.content-frame-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.content-tab-panel {
|
||
min-height: 100%;
|
||
}
|
||
|
||
.content-nav-tabs {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.content-nav-tab {
|
||
border: none;
|
||
background: transparent;
|
||
padding: 6px 14px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #606266;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s ease, color 0.2s ease;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.content-nav-tab:hover {
|
||
color: var(--el-color-primary);
|
||
background: #f0f4ff;
|
||
}
|
||
|
||
.content-nav-tab.active {
|
||
color: var(--el-color-primary);
|
||
background: #ecf5ff;
|
||
}
|
||
|
||
.page-header-card {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.page-header-card-center {
|
||
justify-content: center;
|
||
}
|
||
|
||
.page-header-card-center .page-header-main {
|
||
flex-direction: column;
|
||
align-items: center;
|
||
text-align: center;
|
||
}
|
||
|
||
.page-header-main {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.page-header-text {
|
||
min-width: 0;
|
||
}
|
||
|
||
.page-header-label {
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: #909399;
|
||
margin-bottom: 4px;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.page-header-card .page-hero-title {
|
||
font-size: 18px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.page-header-card .page-hero-desc {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.page-header-card-center .page-hero-icon {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.page-header-card-center .page-hero-title {
|
||
font-size: 22px;
|
||
}
|
||
|
||
/* 内容页通用布局 */
|
||
.content-page {
|
||
padding-bottom: 24px;
|
||
animation: fadeIn 0.35s ease;
|
||
}
|
||
|
||
.page-hero-icon {
|
||
font-size: 32px;
|
||
color: var(--el-color-primary);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.page-hero-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #1f2329;
|
||
margin: 0 0 4px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.page-hero-desc {
|
||
font-size: 13px;
|
||
color: #8f959e;
|
||
margin: 0;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.content-section {
|
||
background: #fff;
|
||
border: 1px solid #eef0f3;
|
||
border-radius: 10px;
|
||
padding: 20px 24px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.section-title {
|
||
margin: 0 0 16px;
|
||
padding: 0 0 12px;
|
||
border-bottom: 1px solid #f0f2f5;
|
||
border-left: none;
|
||
background: none;
|
||
border-radius: 0;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #1f2329;
|
||
scroll-margin-top: 20px;
|
||
word-break: break-all;
|
||
transition: none;
|
||
transform: none;
|
||
}
|
||
|
||
.section-title:hover {
|
||
background: none;
|
||
transform: none;
|
||
}
|
||
|
||
.model-card {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.model-card:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.model-title {
|
||
margin: 0 0 12px;
|
||
padding: 8px 12px;
|
||
background: #f7f8fa;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #409eff;
|
||
font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace;
|
||
scroll-margin-top: 20px;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.basic-type-display {
|
||
display: inline-block;
|
||
color: #606266;
|
||
margin-bottom: 12px;
|
||
padding: 6px 12px;
|
||
font-size: 13px;
|
||
background: #f7f8fa;
|
||
border-radius: 6px;
|
||
font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace;
|
||
}
|
||
|
||
/* API 详情页 */
|
||
.api-detail-icon {
|
||
font-size: 20px;
|
||
color: var(--el-color-primary);
|
||
flex-shrink: 0;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.api-action-name {
|
||
margin: 0;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #1f2329;
|
||
line-height: 1.5;
|
||
word-break: break-all;
|
||
font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace;
|
||
}
|
||
|
||
.api-detail-alert {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.api-detail-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
}
|
||
|
||
.detail-skeleton {
|
||
width: 100%;
|
||
}
|
||
|
||
.detail-skeleton-table {
|
||
width: 100%;
|
||
}
|
||
|
||
.detail-skeleton-row {
|
||
display: grid;
|
||
gap: 16px;
|
||
padding: 14px 0;
|
||
border-bottom: 1px solid #eef0f3;
|
||
}
|
||
|
||
.detail-skeleton-row--head {
|
||
padding-top: 4px;
|
||
padding-bottom: 10px;
|
||
}
|
||
|
||
.detail-skeleton-table--params .detail-skeleton-row {
|
||
grid-template-columns: minmax(80px, 1fr) minmax(80px, 1fr) 72px minmax(120px, 2fr);
|
||
}
|
||
|
||
.detail-skeleton-table--response .detail-skeleton-row {
|
||
grid-template-columns: minmax(80px, 1fr) minmax(80px, 1fr) minmax(120px, 2fr);
|
||
}
|
||
|
||
.detail-skeleton-tabs {
|
||
display: flex;
|
||
gap: 24px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.detail-skeleton-tabs .el-skeleton__item {
|
||
width: 64px;
|
||
}
|
||
|
||
.detail-skeleton-code {
|
||
width: 100%;
|
||
height: 168px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
/* README 引导页 */
|
||
.readme-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 16px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.readme-card {
|
||
background: #fff;
|
||
border: 1px solid #eef0f3;
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||
}
|
||
|
||
.readme-card:hover {
|
||
border-color: #d0d7de;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||
}
|
||
|
||
.readme-card-num {
|
||
display: inline-block;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: var(--el-color-primary);
|
||
background: #ecf5ff;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.readme-card h3 {
|
||
margin: 0 0 8px;
|
||
padding: 0;
|
||
border: none;
|
||
background: none;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #1f2329;
|
||
}
|
||
|
||
.readme-card p {
|
||
margin: 0 0 10px;
|
||
font-size: 13px;
|
||
color: #8f959e;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.readme-card ul {
|
||
margin: 0;
|
||
padding-left: 18px;
|
||
font-size: 13px;
|
||
color: #606266;
|
||
line-height: 1.8;
|
||
}
|
||
|
||
.readme-card li {
|
||
margin: 4px 0;
|
||
}
|
||
|
||
.tips-card {
|
||
border-left: 3px solid var(--el-color-primary);
|
||
}
|
||
|
||
.tips-card-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #1f2329;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.tips-card ul {
|
||
margin: 0;
|
||
padding-left: 20px;
|
||
font-size: 13px;
|
||
color: #606266;
|
||
line-height: 1.8;
|
||
}
|
||
|
||
.tips-card li {
|
||
margin: 6px 0;
|
||
}
|
||
|
||
/* 数据表格 */
|
||
.data-table {
|
||
width: 100%;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.data-table :deep(.el-table__header th) {
|
||
background: #f7f8fa !important;
|
||
color: #606266;
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.data-table :deep(.el-table__body td) {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.data-table :deep(.el-table__row:hover > td) {
|
||
background: #fafbfc !important;
|
||
}
|
||
|
||
/* 代码示例 */
|
||
.code-tabs :deep(.el-tabs__header) {
|
||
margin-bottom: 1px;
|
||
border-radius: 8px 8px 0 0;
|
||
background: #f7f8fa;
|
||
padding: 0 20px;
|
||
}
|
||
|
||
.code-tabs :deep(.el-tabs__item) {
|
||
padding: 0px 10px;
|
||
}
|
||
|
||
.code-tabs :deep(.el-tabs__content) {
|
||
padding: 0;
|
||
}
|
||
|
||
.code-block {
|
||
margin: 0;
|
||
padding: 16px 20px;
|
||
background: #1e1e1e;
|
||
color: #d4d4d4;
|
||
border-radius: 0 0 8px 8px;
|
||
overflow-x: auto;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace;
|
||
border: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.code-block:hover {
|
||
border: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.common-info .content-section:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(10px);
|
||
}
|
||
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.el-card {
|
||
margin: 20px 0;
|
||
border-radius: 12px;
|
||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||
transition: all 0.3s ease;
|
||
border: 1px solid #ebeef5;
|
||
}
|
||
|
||
.el-card:hover {
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
h2 {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
h3 {
|
||
margin: 20px 0;
|
||
padding: 12px 16px;
|
||
border-left: 4px solid var(--el-color-primary);
|
||
scroll-margin-top: 20px;
|
||
word-break: break-all;
|
||
background: linear-gradient(90deg, #f0f9ff 0%, transparent 100%);
|
||
border-radius: 4px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
|
||
pre {
|
||
background: linear-gradient(135deg, #f5f7fa 0%, #fafbfc 100%);
|
||
padding: 16px;
|
||
border-radius: 8px;
|
||
overflow-x: auto;
|
||
border: 1px solid #e4e7ed;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
h4 {
|
||
margin: 15px 0;
|
||
padding: 10px;
|
||
background-color: #f5f7fa;
|
||
scroll-margin-top: 20px;
|
||
word-break: break-all;
|
||
}
|
||
|
||
/* 对象类型的属性名称样式 */
|
||
.el-table .object-field {
|
||
color: var(--el-color-primary);
|
||
cursor: pointer;
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* 内嵌表格样式 */
|
||
.el-table__expanded-cell {
|
||
padding: 20px !important;
|
||
}
|
||
|
||
.el-table__expanded-cell .el-table {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
|
||
.content-nav-wrap,
|
||
.content-page {
|
||
padding-left: 16px;
|
||
padding-right: 16px;
|
||
}
|
||
|
||
.content-page {
|
||
padding-bottom: 16px;
|
||
}
|
||
|
||
.content-nav-wrap {
|
||
padding-top: 12px;
|
||
}
|
||
|
||
.content-nav-bar {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 12px;
|
||
}
|
||
|
||
.content-nav-tabs {
|
||
width: 100%;
|
||
}
|
||
|
||
.content-nav-tab {
|
||
flex: 1;
|
||
text-align: center;
|
||
}
|
||
|
||
.page-header-card {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.readme-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.page-header-card-center .page-hero-title {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.api-action-name {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.content-section {
|
||
padding: 16px;
|
||
}
|
||
}
|
||
|
||
/* 隐藏滚动条但保持可滚动 */
|
||
.hide-scrollbar {
|
||
-ms-overflow-style: none;
|
||
/* IE and Edge */
|
||
scrollbar-width: none;
|
||
/* Firefox */
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.hide-scrollbar::-webkit-scrollbar {
|
||
display: none;
|
||
/* Chrome, Safari and Opera */
|
||
}
|
||
|
||
.type-display {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.type-display a {
|
||
color: var(--el-color-primary);
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.type-display a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* 移动端适配样式 */
|
||
@media (max-width: 768px) {
|
||
.el-container {
|
||
height: auto;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.el-main.main-content {
|
||
padding: 0 !important;
|
||
background: #f7f8fa;
|
||
}
|
||
|
||
.api-detail {
|
||
max-width: 100%;
|
||
}
|
||
|
||
pre {
|
||
font-size: 12px;
|
||
}
|
||
|
||
h2 {
|
||
font-size: 1.5rem;
|
||
}
|
||
|
||
h3 {
|
||
font-size: 1.2rem;
|
||
}
|
||
|
||
h4 {
|
||
font-size: 1rem;
|
||
}
|
||
}
|
||
|
||
/* 移动端字段列表样式 */
|
||
.mobile-field-list {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.mobile-field-list :deep(.el-collapse) {
|
||
border: 1px solid #eef0f3;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.mobile-field-list :deep(.el-collapse-item__header) {
|
||
padding: 0 12px;
|
||
font-size: 13px;
|
||
background: #fafbfc;
|
||
}
|
||
|
||
.mobile-field-list :deep(.el-collapse-item__wrap) {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.mobile-field-title {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding-right: 8px;
|
||
}
|
||
|
||
.field-name {
|
||
font-weight: 600;
|
||
color: #1f2329;
|
||
}
|
||
|
||
.field-required {
|
||
color: #f56c6c;
|
||
padding: 1px 6px;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.mobile-field-content {
|
||
padding: 12px;
|
||
background: #fff;
|
||
}
|
||
|
||
.field-item {
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.field-item:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.field-label {
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
color: #909399;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.field-value {
|
||
padding: 2px 0;
|
||
color: #303133;
|
||
font-size: 13px;
|
||
}
|
||
|
||
/* 目录式导航样式 */
|
||
.breadcrumb-nav {
|
||
margin: 8px 8px 4px;
|
||
padding: 10px 12px;
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
border: 1px solid #eef0f3;
|
||
}
|
||
|
||
.breadcrumb-toolbar {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-bottom: 8px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid #f0f2f5;
|
||
}
|
||
|
||
.breadcrumb-btn {
|
||
font-size: 13px;
|
||
padding: 4px 8px;
|
||
height: auto;
|
||
}
|
||
|
||
.breadcrumb-path {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 2px;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.breadcrumb-tag {
|
||
color: #606266;
|
||
padding: 1px 4px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.breadcrumb-clickable {
|
||
cursor: pointer;
|
||
color: var(--el-color-primary);
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
|
||
.breadcrumb-clickable:hover {
|
||
background-color: #ecf5ff;
|
||
}
|
||
|
||
.breadcrumb-current {
|
||
color: #303133;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.breadcrumb-chevron {
|
||
font-size: 10px;
|
||
color: #c0c4cc;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 导出模块样式 */
|
||
.export-drawer :deep(.el-drawer__body) {
|
||
padding: 0;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.export-module {
|
||
padding: 0 20px 24px;
|
||
}
|
||
|
||
.export-description {
|
||
color: #909399;
|
||
font-size: 13px;
|
||
margin: 0 0 16px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.quick-export-card {
|
||
border-radius: 8px;
|
||
margin-bottom: 20px;
|
||
border: 1px solid #eef0f3;
|
||
}
|
||
|
||
.quick-export-card :deep(.el-card__header) {
|
||
padding: 12px 16px;
|
||
background: #fafbfc;
|
||
border-bottom: 1px solid #eef0f3;
|
||
}
|
||
|
||
.quick-export-card :deep(.el-card__body) {
|
||
padding: 16px;
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
|
||
.quick-export-content {
|
||
text-align: center;
|
||
padding: 10px 0;
|
||
}
|
||
|
||
.quick-export-desc {
|
||
margin-bottom: 20px;
|
||
color: #606266;
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* 按需导出部分 */
|
||
.export-section-title {
|
||
margin-bottom: 20px;
|
||
padding-bottom: 15px;
|
||
}
|
||
|
||
.export-section-title h3 {
|
||
margin-bottom: 8px;
|
||
color: #303133;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.section-desc {
|
||
color: #909399;
|
||
font-size: 13px;
|
||
margin: 0;
|
||
}
|
||
|
||
.export-content {
|
||
background: #fafbfc;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
border: 1px solid #eef0f3;
|
||
}
|
||
|
||
/* 导出对话框样式 */
|
||
.export-dialog-content {
|
||
max-height: 60vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.export-options {
|
||
display: flex;
|
||
gap: 20px;
|
||
margin-bottom: 20px;
|
||
padding-bottom: 15px;
|
||
border-bottom: 1px solid #e4e7ed;
|
||
}
|
||
|
||
.export-select-header {
|
||
margin-bottom: 20px;
|
||
padding-bottom: 15px;
|
||
border-bottom: 1px solid #e4e7ed;
|
||
}
|
||
|
||
.select-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.selected-count {
|
||
margin-left: auto;
|
||
color: var(--el-color-primary);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.export-api-list {
|
||
max-height: calc(100vh - 420px);
|
||
min-height: 200px;
|
||
overflow-y: auto;
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
margin-bottom: 16px;
|
||
background: #fff;
|
||
}
|
||
|
||
.export-api-item {
|
||
padding: 8px 0;
|
||
border-bottom: 1px solid #f5f7fa;
|
||
}
|
||
|
||
.export-api-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.api-action {
|
||
font-family: "Courier New", monospace;
|
||
color: #606266;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.export-api-item:hover {
|
||
background-color: #f5f7fa;
|
||
border-radius: 4px;
|
||
padding-left: 8px;
|
||
padding-right: 8px;
|
||
}
|
||
|
||
.export-actions {
|
||
text-align: center;
|
||
padding-top: 20px;
|
||
border-top: 1px solid #e4e7ed;
|
||
}
|
||
|
||
/* 导出树样式 */
|
||
.export-tree-node {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex: 1;
|
||
}
|
||
|
||
.api-icon,
|
||
.folder-icon {
|
||
font-size: 16px;
|
||
}
|
||
|
||
.node-label {
|
||
flex: 1;
|
||
}
|
||
|
||
.export-api-list :deep(.el-tree-node__content) {
|
||
height: 32px;
|
||
line-height: 32px;
|
||
}
|
||
|
||
.export-api-list :deep(.el-tree-node__label) {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.export-api-list :deep(.el-checkbox) {
|
||
margin-right: 8px;
|
||
}
|
||
|
||
/* 全屏错误状态样式 */
|
||
.fullscreen-error {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 100vh;
|
||
width: 100%;
|
||
}
|
||
|
||
/* 禁用 loading 的淡入淡出动画,让初始显示时立即显示 */
|
||
:deep(.el-loading-mask) {
|
||
transition: none !important;
|
||
}
|
||
|
||
:deep(.el-loading-mask.is-fullscreen) {
|
||
transition: none !important;
|
||
}
|
||
|
||
:deep(.el-table__inner-wrapper:before) {
|
||
background-color: transparent;
|
||
}
|
||
|
||
:deep(.el-collapse-item__wrap) {
|
||
border-bottom: none;
|
||
}
|
||
</style>
|