|
|
@@ -1,31 +1,87 @@
|
|
|
<script setup lang="ts">
|
|
|
-// 对外 API 协议:服务端按代码中的协议定义渲染(与实际接口一致,由测试保证),这里内嵌显示并提供 PDF 下载
|
|
|
-import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
|
+// 对外 API 协议:按服务端的协议定义渲染(与实际接口一致,由测试保证),右侧目录导航,每个接口可以直接调试。
|
|
|
+// 下载 PDF 与"新窗口打开"使用服务端渲染的同一份内容
|
|
|
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
|
|
+import type { ApiEndpoint, ApiKeyDto, ApiReferenceDto } from '@agent-server/shared'
|
|
|
+import ApiTryPanel from '../components/ApiTryPanel.vue'
|
|
|
import PageHeader from '../components/PageHeader.vue'
|
|
|
import { admin, errorMessage } from '../lib/api'
|
|
|
import { toast } from '../lib/ui'
|
|
|
|
|
|
-const frame = ref<HTMLIFrameElement>()
|
|
|
+const ref_ = ref<ApiReferenceDto | null>(null)
|
|
|
+const keys = ref<ApiKeyDto[]>([])
|
|
|
const pdfAvailable = ref(false)
|
|
|
const downloading = ref(false)
|
|
|
+const base = location.origin
|
|
|
+
|
|
|
+// ---- 渲染 ----
|
|
|
+const esc = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!))
|
|
|
+/** 与服务端渲染相同的标记:`代码`、**加粗**,$BASE 替换为服务地址 */
|
|
|
+const marked = (s: string) => esc(s.replaceAll('$BASE', base)).replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
|
|
|
+const errorParts = (x: string) => /^(\d{3}) ([a-z_]+)(.*)$/.exec(x)
|
|
|
+const anchor = (e: ApiEndpoint) => `${e.method}-${e.path}`.toLowerCase().replace(/[^a-z0-9]+/g, '-')
|
|
|
+const METHOD_CLASS: Record<string, string> = { GET: 'bg-[#1a7f37]', POST: 'bg-[#0969da]', PATCH: 'bg-[#9a6700]', DELETE: 'bg-[#cf222e]' }
|
|
|
|
|
|
-// 文档配色跟随后台当前的亮/暗主题
|
|
|
-const dark = ref(false)
|
|
|
-const media = window.matchMedia('(prefers-color-scheme: dark)')
|
|
|
-const updateTheme = () => {
|
|
|
- const t = document.documentElement.getAttribute('data-theme')
|
|
|
- dark.value = t ? t === 'dark' : media.matches
|
|
|
+// ---- 目录 ----
|
|
|
+const toc = computed(() => {
|
|
|
+ const r = ref_.value
|
|
|
+ if (!r) return []
|
|
|
+ return [
|
|
|
+ { id: 'overview', title: '概述', children: [] as { id: string; title: string; method: string }[] },
|
|
|
+ ...r.groups.map(g => ({ id: g.id, title: g.title, children: g.endpoints.map(e => ({ id: anchor(e), title: e.summary, method: e.method })) })),
|
|
|
+ { id: 'event-types', title: '事件类型', children: [] },
|
|
|
+ { id: 'types', title: '数据结构', children: [] },
|
|
|
+ { id: 'errors', title: '错误码', children: [] },
|
|
|
+ ]
|
|
|
+})
|
|
|
+const scroller = ref<HTMLElement>()
|
|
|
+const active = ref('overview')
|
|
|
+let observer: IntersectionObserver | null = null
|
|
|
+function go(id: string) {
|
|
|
+ active.value = id
|
|
|
+ document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
|
+}
|
|
|
+/** 滚到底部时最后几节的标题到不了视口上部,直接选中最后一节 */
|
|
|
+function onScroll() {
|
|
|
+ const el = scroller.value
|
|
|
+ if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 4) active.value = 'errors'
|
|
|
+}
|
|
|
+/** 当前位置:视口上部最后一个越过的标题 */
|
|
|
+function observeHeadings() {
|
|
|
+ observer?.disconnect()
|
|
|
+ const visible = new Map<string, number>()
|
|
|
+ observer = new IntersectionObserver(entries => {
|
|
|
+ for (const e of entries) {
|
|
|
+ if (e.isIntersecting) visible.set(e.target.id, e.boundingClientRect.top)
|
|
|
+ else visible.delete(e.target.id)
|
|
|
+ }
|
|
|
+ const top = [...visible.entries()].sort((a, b) => a[1] - b[1])[0]
|
|
|
+ if (top) active.value = top[0]
|
|
|
+ }, { root: scroller.value, rootMargin: '0px 0px -70% 0px' })
|
|
|
+ for (const el of scroller.value?.querySelectorAll('[data-toc]') ?? []) observer.observe(el)
|
|
|
}
|
|
|
-const observer = new MutationObserver(updateTheme)
|
|
|
+const activeGroup = computed(() => toc.value.find(t => t.id === active.value || t.children.some(c => c.id === active.value))?.id)
|
|
|
|
|
|
-// 示例中的地址用浏览器看到的地址(经反向代理时与服务自身看到的不同)
|
|
|
-const base = location.origin
|
|
|
-const src = computed(() => `/admin/v1/api-reference.html?theme=${dark.value ? 'dark' : 'light'}&base=${encodeURIComponent(base)}`)
|
|
|
+// ---- 调试 ----
|
|
|
+const trying = reactive<Record<string, boolean>>({})
|
|
|
+/** 调试身份:'' 为后台管理员;否则为 API Key id(服务端签发 10 分钟的浏览器令牌,API Key 不离开服务端) */
|
|
|
+const identity = ref('')
|
|
|
+const identityLabel = computed(() => (identity.value ? `平台「${keys.value.find(k => k.id === identity.value)?.name ?? ''}」` : '后台管理员'))
|
|
|
+const tokens = new Map<string, { token: string; exp: number }>()
|
|
|
+async function auth(): Promise<string | null> {
|
|
|
+ const id = identity.value
|
|
|
+ if (!id) return null
|
|
|
+ const t = tokens.get(id)
|
|
|
+ if (t && t.exp - Date.now() > 60_000) return t.token
|
|
|
+ const r = await admin.apiKeyDebugToken(id)
|
|
|
+ tokens.set(id, { token: r.token, exp: Date.parse(r.expiresAt) })
|
|
|
+ return r.token
|
|
|
+}
|
|
|
+const recentIds = reactive<Record<string, string>>({})
|
|
|
|
|
|
async function downloadPdf() {
|
|
|
if (!pdfAvailable.value) {
|
|
|
- // 服务器没有 Chrome 时改用浏览器打印(在打印对话框中选择"另存为 PDF")
|
|
|
- frame.value?.contentWindow?.print()
|
|
|
+ window.open(htmlUrl.value, '_blank')?.addEventListener('load', e => (e.target as Window).print())
|
|
|
return
|
|
|
}
|
|
|
downloading.value = true
|
|
|
@@ -45,27 +101,149 @@ async function downloadPdf() {
|
|
|
downloading.value = false
|
|
|
}
|
|
|
}
|
|
|
+const htmlUrl = computed(() => `/admin/v1/api-reference.html?base=${encodeURIComponent(base)}`)
|
|
|
|
|
|
onMounted(async () => {
|
|
|
- updateTheme()
|
|
|
- media.addEventListener('change', updateTheme)
|
|
|
- observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
|
|
- pdfAvailable.value = (await admin.apiReferenceMeta().catch(() => ({ pdfAvailable: false }))).pdfAvailable
|
|
|
-})
|
|
|
-onBeforeUnmount(() => {
|
|
|
- media.removeEventListener('change', updateTheme)
|
|
|
- observer.disconnect()
|
|
|
+ try {
|
|
|
+ ;[ref_.value, keys.value, pdfAvailable.value] = await Promise.all([
|
|
|
+ admin.apiReference(), admin.apiKeys(), admin.apiReferenceMeta().then(m => m.pdfAvailable).catch(() => false),
|
|
|
+ ])
|
|
|
+ } catch (err) {
|
|
|
+ toast(errorMessage(err), 'error')
|
|
|
+ }
|
|
|
+ await nextTick()
|
|
|
+ observeHeadings()
|
|
|
})
|
|
|
+onBeforeUnmount(() => observer?.disconnect())
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
<div class="h-full flex flex-col">
|
|
|
- <PageHeader title="API 协议" subtitle="业务平台调用的对外接口(/v1),内容由服务代码中的协议定义生成,与实际接口保持一致">
|
|
|
- <a :href="src" target="_blank" rel="noopener" class="btn-ghost no-underline"><span class="codicon codicon-link-external" />新窗口打开</a>
|
|
|
- <button type="button" class="btn-primary" :disabled="downloading" :title="pdfAvailable ? '' : '服务器没有安装 Chrome,将使用浏览器打印(选择另存为 PDF)'" @click="downloadPdf">
|
|
|
+ <PageHeader title="API 协议" :subtitle="ref_ ? `${ref_.version} · 由服务代码中的协议定义生成,与实际接口保持一致` : ''">
|
|
|
+ <a :href="htmlUrl" target="_blank" rel="noopener" class="btn-ghost no-underline"><span class="codicon codicon-link-external" />新窗口打开</a>
|
|
|
+ <button type="button" class="btn-primary" :disabled="downloading" :title="pdfAvailable ? '' : '服务器没有安装 Chrome,将打开打印对话框(选择另存为 PDF)'" @click="downloadPdf">
|
|
|
<span class="codicon" :class="downloading ? 'codicon-loading codicon-modifier-spin' : 'codicon-file-pdf'" />{{ downloading ? '生成中…' : '下载 PDF' }}
|
|
|
</button>
|
|
|
</PageHeader>
|
|
|
- <iframe ref="frame" :src="src" title="API 协议" class="flex-1 w-full border-none bg-bg" />
|
|
|
+
|
|
|
+ <div v-if="ref_" class="flex-1 min-h-0 flex">
|
|
|
+ <div ref="scroller" class="flex-1 min-w-0 overflow-y-auto" @scroll.passive="onScroll">
|
|
|
+ <article class="doc max-w-220 mx-auto px-8 py-6">
|
|
|
+ <h2 id="overview" data-toc>概述</h2>
|
|
|
+ <ul><li v-for="(p, i) in ref_.overview.points" :key="i" v-html="marked(p)" /></ul>
|
|
|
+ <h4>典型流程</h4>
|
|
|
+ <ol><li v-for="(p, i) in ref_.overview.flow" :key="i" v-html="marked(p)" /></ol>
|
|
|
+
|
|
|
+ <template v-for="g in ref_.groups" :key="g.id">
|
|
|
+ <h2 :id="g.id" data-toc>{{ g.title }}</h2>
|
|
|
+ <p v-if="g.desc" class="muted" v-html="marked(g.desc)" />
|
|
|
+ <section v-for="e in g.endpoints" :id="anchor(e)" :key="anchor(e)" class="endpoint" data-toc>
|
|
|
+ <h3>
|
|
|
+ <span class="method" :class="METHOD_CLASS[e.method]">{{ e.method }}</span>
|
|
|
+ <code class="path">{{ e.path }}</code>
|
|
|
+ <span class="muted font-500">{{ e.summary }}</span>
|
|
|
+ <span class="flex-1" />
|
|
|
+ <button type="button" class="btn-ghost !h-6" :class="{ '!bg-hover': trying[anchor(e)] }" @click="trying[anchor(e)] = !trying[anchor(e)]">
|
|
|
+ <span class="codicon codicon-debug-alt" />{{ trying[anchor(e)] ? '收起调试' : '调试' }}
|
|
|
+ </button>
|
|
|
+ </h3>
|
|
|
+ <p v-if="e.desc" v-html="marked(e.desc)" />
|
|
|
+ <template v-if="e.query?.length">
|
|
|
+ <h4>查询参数</h4>
|
|
|
+ <table><thead><tr><th>字段</th><th>类型</th><th>必填</th><th>说明</th></tr></thead>
|
|
|
+ <tbody><tr v-for="f in e.query" :key="f.name"><td><code>{{ f.name }}</code></td><td><code class="muted">{{ f.type }}</code></td><td>{{ f.required ? '是' : '' }}</td><td v-html="marked(f.desc)" /></tr></tbody></table>
|
|
|
+ </template>
|
|
|
+ <template v-if="e.body?.length">
|
|
|
+ <h4>请求体(JSON)</h4>
|
|
|
+ <table><thead><tr><th>字段</th><th>类型</th><th>必填</th><th>说明</th></tr></thead>
|
|
|
+ <tbody><tr v-for="f in e.body" :key="f.name"><td><code>{{ f.name }}</code></td><td><code class="muted">{{ f.type }}</code></td><td>{{ f.required ? '是' : '' }}</td><td v-html="marked(f.desc)" /></tr></tbody></table>
|
|
|
+ </template>
|
|
|
+ <template v-if="e.bodyNote"><h4>请求体</h4><p v-html="marked(e.bodyNote)" /></template>
|
|
|
+ <h4>响应</h4>
|
|
|
+ <p><b>{{ e.response.status }}</b> <code class="muted">{{ e.response.type }}</code><template v-if="e.response.desc"> — <span v-html="marked(e.response.desc)" /></template></p>
|
|
|
+ <template v-if="e.errors?.length">
|
|
|
+ <h4>常见错误</h4>
|
|
|
+ <ul>
|
|
|
+ <li v-for="x in e.errors" :key="x">
|
|
|
+ <template v-if="errorParts(x)"><b>{{ errorParts(x)![1] }}</b> <code>{{ errorParts(x)![2] }}</code><span v-html="marked(errorParts(x)![3]!)" /></template>
|
|
|
+ <span v-else v-html="marked(x)" />
|
|
|
+ </li>
|
|
|
+ </ul>
|
|
|
+ </template>
|
|
|
+ <template v-if="e.example"><h4>示例</h4><pre>{{ e.example.replaceAll('$BASE', base) }}</pre></template>
|
|
|
+ <ApiTryPanel
|
|
|
+ v-if="trying[anchor(e)]" :key="`${anchor(e)}-${identity}`" :endpoint="e" :auth="auth" :identity-label="identityLabel" :recent-ids="recentIds"
|
|
|
+ @ids="ids => Object.assign(recentIds, ids)"
|
|
|
+ />
|
|
|
+ </section>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <h2 id="event-types" data-toc>事件类型</h2>
|
|
|
+ <p class="muted">事件信封见"数据结构 › AgentEvent"。"落库"为否的事件只在实时事件流中推送,历史事件中只保留对应的 completed 事件。</p>
|
|
|
+ <table><thead><tr><th>type</th><th>payload</th><th>说明</th><th>落库</th></tr></thead>
|
|
|
+ <tbody><tr v-for="ev in ref_.events" :key="ev.type"><td><code>{{ ev.type }}</code></td><td><code class="muted">{{ ev.payload }}</code></td><td v-html="marked(ev.desc)" /><td>{{ ev.persisted ? '是' : '否' }}</td></tr></tbody></table>
|
|
|
+
|
|
|
+ <h2 id="types" data-toc>数据结构</h2>
|
|
|
+ <template v-for="t in ref_.types" :key="t.name">
|
|
|
+ <h3><code class="path">{{ t.name }}</code></h3>
|
|
|
+ <p v-if="t.desc" class="muted" v-html="marked(t.desc)" />
|
|
|
+ <table><thead><tr><th>字段</th><th>类型</th><th>说明</th></tr></thead>
|
|
|
+ <tbody><tr v-for="f in t.fields" :key="f.name"><td><code>{{ f.name }}</code></td><td><code class="muted">{{ f.type }}</code></td><td v-html="marked(f.desc)" /></tr></tbody></table>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <h2 id="errors" data-toc>错误码</h2>
|
|
|
+ <table><thead><tr><th>HTTP</th><th>code</th><th>说明</th></tr></thead>
|
|
|
+ <tbody><tr v-for="er in ref_.errors" :key="er.code"><td>{{ er.status }}</td><td><code>{{ er.code }}</code></td><td v-html="marked(er.desc)" /></tr></tbody></table>
|
|
|
+ </article>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右侧:调试身份 + 目录导航 -->
|
|
|
+ <aside class="w-64 flex-none border-l border-l-solid border-border overflow-y-auto py-4 px-3 flex flex-col gap-4 text-[12.5px]">
|
|
|
+ <div class="flex flex-col gap-1">
|
|
|
+ <label class="label m-0" for="try-identity">调试身份</label>
|
|
|
+ <select id="try-identity" v-model="identity" class="field">
|
|
|
+ <option value="">后台管理员</option>
|
|
|
+ <option v-for="k in keys.filter(x => x.enabled)" :key="k.id" :value="k.id">平台:{{ k.name }}</option>
|
|
|
+ </select>
|
|
|
+ <div class="hint">{{ identity ? '与该平台完全相同的权限与可见范围(使用 10 分钟的临时令牌)' : '可见全部 Profile;创建的会话会出现在 Playground 中' }}</div>
|
|
|
+ </div>
|
|
|
+ <nav class="flex flex-col" aria-label="目录">
|
|
|
+ <div class="section-title mb-1">目录</div>
|
|
|
+ <template v-for="t in toc" :key="t.id">
|
|
|
+ <a
|
|
|
+ :href="`#${t.id}`" class="block px-2 py-1 rounded-sm no-underline hover:bg-hover"
|
|
|
+ :class="activeGroup === t.id ? 'text-fg font-600' : 'text-fg-muted'" @click.prevent="go(t.id)"
|
|
|
+ >{{ t.title }}</a>
|
|
|
+ <template v-if="t.children.length && activeGroup === t.id">
|
|
|
+ <a
|
|
|
+ v-for="c in t.children" :key="c.id" :href="`#${c.id}`"
|
|
|
+ class="flex items-center gap-1.5 pl-4 pr-2 py-0.5 rounded-sm no-underline hover:bg-hover truncate"
|
|
|
+ :class="active === c.id ? 'text-link' : 'text-fg-muted'" @click.prevent="go(c.id)"
|
|
|
+ >
|
|
|
+ <span class="font-mono text-[10px] w-9 flex-none">{{ c.method }}</span><span class="truncate">{{ c.title }}</span>
|
|
|
+ </a>
|
|
|
+ </template>
|
|
|
+ </template>
|
|
|
+ </nav>
|
|
|
+ </aside>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.doc { line-height: 1.65; }
|
|
|
+.doc :deep(h2) { font-size: 18px; margin: 32px 0 10px; padding-bottom: 6px; border-bottom: 1px solid var(--app-primary-border-color); scroll-margin-top: 12px; }
|
|
|
+.doc h2:first-child { margin-top: 0; }
|
|
|
+.doc :deep(h3) { font-size: 14px; margin: 0 0 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; scroll-margin-top: 12px; }
|
|
|
+.doc :deep(h4) { font-size: 12px; margin: 12px 0 4px; color: var(--app-secondary-foreground); }
|
|
|
+.doc :deep(p), .doc :deep(ul), .doc :deep(ol) { margin: 6px 0; }
|
|
|
+.doc :deep(code) { font-family: var(--app-monospace-font-family); font-size: 12px; background: var(--app-code-background); padding: 1px 5px; border-radius: 4px; }
|
|
|
+.doc :deep(pre) { font-family: var(--app-monospace-font-family); font-size: 12px; background: var(--app-code-background); padding: 10px 12px; border-radius: 6px; white-space: pre-wrap; word-break: break-all; margin: 6px 0; }
|
|
|
+.doc :deep(table) { border-collapse: collapse; width: 100%; margin: 6px 0; }
|
|
|
+.doc :deep(th), .doc :deep(td) { border: 1px solid var(--app-primary-border-color); padding: 4px 8px; text-align: left; vertical-align: top; }
|
|
|
+.doc :deep(th) { background: var(--app-code-background); font-weight: 600; white-space: nowrap; }
|
|
|
+.doc .muted { color: var(--app-secondary-foreground); }
|
|
|
+.doc .endpoint { border: 1px solid var(--app-primary-border-color); border-radius: 8px; padding: 12px 14px; margin: 12px 0; scroll-margin-top: 12px; }
|
|
|
+.doc .method { font: 700 11px/1 sans-serif; color: #fff; padding: 4px 7px; border-radius: 4px; }
|
|
|
+.doc .path { font-size: 13px; background: none; padding: 0; font-weight: 600; }
|
|
|
+</style>
|