ApiDocsPage.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. <script setup>
  2. import { ref, computed, onMounted } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import { Modal, message } from 'ant-design-vue';
  5. import {
  6. KeyOutlined,
  7. ReloadOutlined,
  8. CopyOutlined,
  9. EyeOutlined,
  10. EyeInvisibleOutlined,
  11. SearchOutlined,
  12. ExpandOutlined,
  13. CompressOutlined,
  14. } from '@ant-design/icons-vue';
  15. import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
  16. import AppSidebar from '@/components/AppSidebar.vue';
  17. import { HttpUtil, ClipboardManager } from '@/utils/index.js';
  18. import { sections as allSections } from './endpoints.js';
  19. import EndpointSection from './EndpointSection.vue';
  20. import CodeBlock from './CodeBlock.vue';
  21. const { t } = useI18n();
  22. const basePath = window.X_UI_BASE_PATH || '';
  23. const requestUri = window.location.pathname;
  24. const apiToken = ref('');
  25. const tokenLoading = ref(false);
  26. const tokenRotating = ref(false);
  27. const tokenVisible = ref(false);
  28. const searchQuery = ref('');
  29. const collapsedSections = ref(new Set());
  30. const curlExample = `curl -X GET \\
  31. -H "Authorization: Bearer YOUR_API_TOKEN" \\
  32. -H "Accept: application/json" \\
  33. https://your-panel.example.com/panel/api/inbounds/list`;
  34. const sections = computed(() => {
  35. const q = searchQuery.value.toLowerCase().trim();
  36. if (!q) return allSections;
  37. return allSections
  38. .map(s => {
  39. const matching = s.endpoints.filter(e =>
  40. e.path.toLowerCase().includes(q) ||
  41. e.summary?.toLowerCase().includes(q) ||
  42. e.method.toLowerCase().includes(q)
  43. );
  44. return { ...s, endpoints: matching };
  45. })
  46. .filter(s => s.endpoints.length > 0);
  47. });
  48. const endpointCount = computed(() =>
  49. allSections.reduce((sum, s) => sum + s.endpoints.length, 0)
  50. );
  51. const visibleSections = computed(() =>
  52. sections.value.reduce((sum, s) => sum + s.endpoints.length, 0)
  53. );
  54. function isCollapsed(id) {
  55. return collapsedSections.value.has(id);
  56. }
  57. function toggleSection(id) {
  58. const s = new Set(collapsedSections.value);
  59. if (s.has(id)) s.delete(id); else s.add(id);
  60. collapsedSections.value = s;
  61. }
  62. function expandAll() {
  63. collapsedSections.value = new Set();
  64. }
  65. function collapseAll() {
  66. collapsedSections.value = new Set(allSections.map(s => s.id));
  67. }
  68. async function loadApiToken() {
  69. tokenLoading.value = true;
  70. try {
  71. const msg = await HttpUtil.get('/panel/setting/getApiToken');
  72. if (msg?.success) apiToken.value = msg.obj || '';
  73. } finally {
  74. tokenLoading.value = false;
  75. }
  76. }
  77. function regenerateApiToken() {
  78. Modal.confirm({
  79. title: t('pages.nodes.regenerateConfirm'),
  80. okText: t('confirm'),
  81. cancelText: t('cancel'),
  82. okType: 'danger',
  83. onOk: async () => {
  84. tokenRotating.value = true;
  85. try {
  86. const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
  87. if (msg?.success) {
  88. apiToken.value = msg.obj || '';
  89. message.success(t('success'));
  90. }
  91. } finally {
  92. tokenRotating.value = false;
  93. }
  94. },
  95. });
  96. }
  97. async function copyApiToken() {
  98. if (!apiToken.value) return;
  99. const ok = await ClipboardManager.copyText(apiToken.value);
  100. if (ok) message.success(t('success'));
  101. }
  102. function scrollToSection(id) {
  103. const el = document.getElementById(id);
  104. if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
  105. }
  106. onMounted(() => {
  107. loadApiToken();
  108. });
  109. </script>
  110. <template>
  111. <a-config-provider :theme="antdThemeConfig">
  112. <a-layout class="api-docs-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
  113. <AppSidebar :base-path="basePath" :request-uri="requestUri" />
  114. <a-layout class="content-shell">
  115. <a-layout-content class="content-area">
  116. <div class="docs-wrapper">
  117. <header class="docs-header">
  118. <h1 class="docs-title">API Documentation</h1>
  119. <p class="docs-lead">
  120. The 3x-ui panel exposes a REST API under <code>/panel/api/</code>. Authenticate with the panel session
  121. cookie, or with the <code>Authorization: Bearer &lt;token&gt;</code> header below. Every endpoint
  122. returns a uniform <code>{ success, msg, obj }</code> envelope unless otherwise noted.
  123. </p>
  124. </header>
  125. <a-card class="token-card" size="small">
  126. <div class="token-card-head">
  127. <div class="token-card-title">
  128. <KeyOutlined />
  129. <span>API Token</span>
  130. </div>
  131. <a-space size="small" wrap>
  132. <a-button size="small" @click="tokenVisible = !tokenVisible">
  133. <template #icon>
  134. <EyeInvisibleOutlined v-if="tokenVisible" />
  135. <EyeOutlined v-else />
  136. </template>
  137. {{ tokenVisible ? 'Hide' : 'Show' }}
  138. </a-button>
  139. <a-button size="small" :disabled="!apiToken" @click="copyApiToken">
  140. <template #icon>
  141. <CopyOutlined />
  142. </template>
  143. Copy
  144. </a-button>
  145. <a-button size="small" danger :loading="tokenRotating" @click="regenerateApiToken">
  146. <template #icon>
  147. <ReloadOutlined />
  148. </template>
  149. Regenerate
  150. </a-button>
  151. </a-space>
  152. </div>
  153. <a-spin :spinning="tokenLoading" size="small">
  154. <pre
  155. class="token-value">{{ tokenVisible ? (apiToken || '—') : (apiToken ? '••••••••••••••••••••••••••••' : '—') }}</pre>
  156. </a-spin>
  157. <p class="token-hint">
  158. Send it on every request as <code>Authorization: Bearer &lt;token&gt;</code>. Token-authenticated
  159. callers skip CSRF and don't need a session cookie. Regenerating rotates the secret immediately —
  160. running bots will need the new value.
  161. </p>
  162. </a-card>
  163. <a-card class="curl-card" size="small" title="Quick example">
  164. <CodeBlock :code="curlExample" lang="text" />
  165. </a-card>
  166. <div class="toolbar">
  167. <a-input-search
  168. v-model:value="searchQuery"
  169. placeholder="Search endpoints by path, method, or description…"
  170. allow-clear
  171. class="search-bar"
  172. >
  173. <template #prefix><SearchOutlined /></template>
  174. </a-input-search>
  175. <span class="match-count" v-if="searchQuery">
  176. {{ visibleSections }} / {{ endpointCount }} endpoints
  177. </span>
  178. <a-space size="small">
  179. <a-button size="small" @click="expandAll">
  180. <template #icon><ExpandOutlined /></template>
  181. Expand all
  182. </a-button>
  183. <a-button size="small" @click="collapseAll">
  184. <template #icon><CompressOutlined /></template>
  185. Collapse all
  186. </a-button>
  187. </a-space>
  188. </div>
  189. <nav class="toc-nav">
  190. <span class="toc-label">On this page:</span>
  191. <a v-for="s in sections" :key="s.id" class="toc-link" :href="`#${s.id}`"
  192. @click.prevent="scrollToSection(s.id)">
  193. {{ s.title }} ({{ s.endpoints.length }})
  194. </a>
  195. </nav>
  196. <EndpointSection
  197. v-for="s in sections"
  198. :key="s.id"
  199. :section="s"
  200. :collapsed="isCollapsed(s.id)"
  201. @toggle="toggleSection(s.id)"
  202. />
  203. </div>
  204. </a-layout-content>
  205. </a-layout>
  206. </a-layout>
  207. </a-config-provider>
  208. </template>
  209. <style scoped>
  210. .api-docs-page {
  211. --bg-page: #e6e8ec;
  212. --bg-card: #ffffff;
  213. min-height: 100vh;
  214. background: var(--bg-page);
  215. }
  216. .api-docs-page.is-dark {
  217. --bg-page: #1e1e1e;
  218. --bg-card: #252526;
  219. }
  220. .api-docs-page.is-dark.is-ultra {
  221. --bg-page: #000;
  222. --bg-card: #0a0a0a;
  223. }
  224. .content-shell {
  225. background: var(--bg-page);
  226. }
  227. .content-area {
  228. padding: 24px;
  229. max-width: 100%;
  230. }
  231. @media (max-width: 768px) {
  232. .content-area {
  233. padding: 16px 12px 12px;
  234. padding-top: 64px;
  235. }
  236. }
  237. .docs-wrapper {
  238. max-width: 1100px;
  239. margin: 0 auto;
  240. }
  241. .docs-header {
  242. margin-bottom: 18px;
  243. }
  244. .docs-title {
  245. font-size: 26px;
  246. font-weight: 700;
  247. margin: 0 0 8px;
  248. color: rgba(0, 0, 0, 0.88);
  249. }
  250. .docs-lead {
  251. margin: 0;
  252. color: rgba(0, 0, 0, 0.65);
  253. line-height: 1.6;
  254. font-size: 14px;
  255. }
  256. .docs-lead code,
  257. .token-hint code {
  258. background: rgba(128, 128, 128, 0.12);
  259. padding: 1px 6px;
  260. border-radius: 4px;
  261. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  262. font-size: 12.5px;
  263. }
  264. .token-card,
  265. .curl-card {
  266. margin-bottom: 16px;
  267. }
  268. .token-card-head {
  269. display: flex;
  270. align-items: center;
  271. justify-content: space-between;
  272. gap: 12px;
  273. flex-wrap: wrap;
  274. margin-bottom: 8px;
  275. }
  276. .token-card-title {
  277. display: inline-flex;
  278. align-items: center;
  279. gap: 8px;
  280. font-weight: 600;
  281. font-size: 14px;
  282. }
  283. .token-value {
  284. background: rgba(128, 128, 128, 0.08);
  285. border: 1px solid rgba(128, 128, 128, 0.15);
  286. border-radius: 6px;
  287. padding: 10px 12px;
  288. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  289. font-size: 13px;
  290. margin: 0;
  291. word-break: break-all;
  292. white-space: pre-wrap;
  293. }
  294. .token-hint {
  295. margin: 10px 0 0;
  296. color: rgba(0, 0, 0, 0.55);
  297. font-size: 12.5px;
  298. line-height: 1.55;
  299. }
  300. .code-block {
  301. background: rgba(128, 128, 128, 0.08);
  302. border: 1px solid rgba(128, 128, 128, 0.15);
  303. border-radius: 6px;
  304. padding: 10px 12px;
  305. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  306. font-size: 12.5px;
  307. line-height: 1.55;
  308. margin: 0;
  309. white-space: pre-wrap;
  310. word-break: break-word;
  311. overflow-x: auto;
  312. }
  313. .toolbar {
  314. display: flex;
  315. align-items: center;
  316. gap: 12px;
  317. flex-wrap: wrap;
  318. margin-bottom: 16px;
  319. }
  320. .search-bar {
  321. flex: 1;
  322. min-width: 200px;
  323. max-width: 480px;
  324. }
  325. .match-count {
  326. font-size: 12px;
  327. color: rgba(0, 0, 0, 0.5);
  328. white-space: nowrap;
  329. }
  330. .toc-nav {
  331. display: flex;
  332. flex-wrap: wrap;
  333. align-items: center;
  334. gap: 8px 14px;
  335. padding: 12px 16px;
  336. background: rgba(128, 128, 128, 0.08);
  337. border-radius: 6px;
  338. margin-bottom: 16px;
  339. }
  340. .toc-label {
  341. font-size: 12px;
  342. font-weight: 600;
  343. text-transform: uppercase;
  344. letter-spacing: 0.5px;
  345. color: rgba(0, 0, 0, 0.5);
  346. }
  347. .toc-link {
  348. color: #1677ff;
  349. text-decoration: none;
  350. cursor: pointer;
  351. font-size: 13px;
  352. }
  353. .toc-link:hover {
  354. color: #4096ff;
  355. text-decoration: underline;
  356. }
  357. </style>
  358. <style>
  359. body.dark .docs-title {
  360. color: rgba(255, 255, 255, 0.92);
  361. }
  362. body.dark .docs-lead,
  363. body.dark .token-hint {
  364. color: rgba(255, 255, 255, 0.7);
  365. }
  366. body.dark .docs-lead code,
  367. body.dark .token-hint code {
  368. background: rgba(255, 255, 255, 0.1);
  369. }
  370. body.dark .token-value,
  371. body.dark .code-block {
  372. background: rgba(255, 255, 255, 0.04);
  373. border-color: rgba(255, 255, 255, 0.1);
  374. color: rgba(255, 255, 255, 0.88);
  375. }
  376. body.dark .toc-nav {
  377. background: rgba(255, 255, 255, 0.04);
  378. }
  379. body.dark .toc-label {
  380. color: rgba(255, 255, 255, 0.55);
  381. }
  382. </style>