feat(api-docs): render protected Scalar documentation

This commit is contained in:
2026-08-12 09:05:03 +08:00
parent 1a5489a361
commit ae29e7b2fc
6 changed files with 643 additions and 3359 deletions
-91
View File
@@ -1,91 +0,0 @@
import { ApiReferenceReact } from '@scalar/api-reference-react'
import '@scalar/api-reference-react/style.css'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { ApiError } from '@/api/client'
import { fetchApiDocument, type ApiDocument } from '@/api/modules/api-docs'
import { PERM, requirePermission } from '@/auth/permissions'
import { PageHeader } from '@/components/page-header'
import { Button } from '@/components/ui/button'
export const Route = createFileRoute('/_authed/api-docs')({
beforeLoad: () => requirePermission(PERM.API_DOCS_READ),
component: ApiDocumentationPage,
})
const DOCUMENTS: Array<{ value: ApiDocument; label: string }> = [
{ value: 'public', label: '公共 API' },
{ value: 'admin', label: '管理端 API' },
]
function ApiDocumentationPage() {
const [document, setDocument] = useState<ApiDocument>('public')
const specification = useQuery({
queryKey: ['api-docs', document],
queryFn: ({ signal }) => fetchApiDocument(document, signal),
})
const notGenerated =
specification.error instanceof ApiError &&
specification.error.code === 'API_DOCUMENT_NOT_GENERATED'
return (
<div>
<PageHeader
title="API 文档"
description="仅向拥有开发者文档权限的后台账号开放"
actions={
<div className="flex gap-2">
{DOCUMENTS.map((item) => (
<Button
key={item.value}
variant={document === item.value ? 'default' : 'outline'}
onClick={() => setDocument(item.value)}
>
{item.label}
</Button>
))}
</div>
}
/>
{specification.isPending && (
<div className="rounded-lg border p-8 text-center text-sm text-muted-foreground">
API
</div>
)}
{notGenerated && (
<div className="rounded-lg border border-dashed p-8 text-center">
<p className="font-medium"></p>
<p className="mt-2 text-sm text-muted-foreground">
<code className="font-mono">php artisan api-docs:generate</code>
</p>
</div>
)}
{specification.isError && !notGenerated && (
<div className="rounded-lg border border-destructive/40 p-8 text-center">
<p className="font-medium text-destructive">API </p>
<Button className="mt-4" variant="outline" onClick={() => void specification.refetch()}>
</Button>
</div>
)}
{specification.data && (
<div className="overflow-hidden rounded-lg border">
<ApiReferenceReact
key={document}
configuration={{
content: specification.data,
agent: { disabled: true },
hideModels: false,
}}
/>
</div>
)}
</div>
)
}
+156
View File
@@ -0,0 +1,156 @@
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import { ApiError } from '@/api/client'
import { fetchApiDocument, type ApiDocument } from '@/api/modules/api-docs'
import { fetchMe } from '@/api/modules/auth'
import { PERM, requirePermission } from '@/auth/permissions'
import { isAuthenticated, useAuthStore } from '@/auth/store'
import { Button } from '@/components/ui/button'
export const Route = createFileRoute('/api-docs')({
beforeLoad: async () => {
if (typeof window !== 'undefined' && !isAuthenticated()) {
throw redirect({ to: '/login' })
}
const me = await fetchMe()
const roles = me.meta?.roles ?? []
const permissions = me.meta?.permissions ?? []
useAuthStore.getState().setSession(me.data, roles, permissions)
requirePermission(PERM.API_DOCS_READ)
},
component: ApiDocumentationPage,
})
interface ScalarApi {
createApiReference: (
element: HTMLElement,
configuration: Record<string, unknown>,
) => void
}
const DOCUMENTS: Array<{ value: ApiDocument; label: string }> = [
{ value: 'public', label: '公共 API' },
{ value: 'admin', label: '管理端 API' },
]
declare global {
interface Window {
Scalar?: ScalarApi
}
}
let scalarLoader: Promise<ScalarApi> | null = null
function loadScalar(): Promise<ScalarApi> {
if (window.Scalar) return Promise.resolve(window.Scalar)
if (scalarLoader) return scalarLoader
scalarLoader = new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = 'https://cdn.jsdelivr.net/npm/@scalar/api-reference'
script.async = true
script.onload = () =>
window.Scalar ? resolve(window.Scalar) : reject(new Error('Scalar failed to initialize'))
script.onerror = () => reject(new Error('Scalar failed to load'))
document.head.appendChild(script)
})
return scalarLoader
}
function ScalarReference({ content }: { content: string }) {
const container = useRef<HTMLDivElement>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
let active = true
setFailed(false)
void loadScalar()
.then((scalar) => {
if (!active || !container.current) return
container.current.replaceChildren()
scalar.createApiReference(container.current, { content })
})
.catch(() => {
if (active) setFailed(true)
})
return () => {
active = false
container.current?.replaceChildren()
}
}, [content])
if (failed) {
return <div className="p-8 text-center text-destructive"></div>
}
return <div ref={container} className="min-h-svh" />
}
function ApiDocumentationPage() {
const [document, setDocument] = useState<ApiDocument>('public')
const specification = useQuery({
queryKey: ['api-docs', document],
queryFn: ({ signal }) => fetchApiDocument(document, signal),
})
const notGenerated =
specification.error instanceof ApiError &&
specification.error.code === 'API_DOCUMENT_NOT_GENERATED'
return (
<main className="min-h-svh bg-background">
<div className="fixed bottom-4 right-4 z-[100] flex gap-1 rounded-lg border bg-background/90 p-1 shadow-lg backdrop-blur">
{DOCUMENTS.map((item) => (
<Button
key={item.value}
size="sm"
variant={document === item.value ? 'default' : 'ghost'}
onClick={() => setDocument(item.value)}
>
{item.label}
</Button>
))}
</div>
{specification.isPending && (
<div className="grid min-h-svh place-items-center p-8">
<p className="text-sm text-muted-foreground">
API
</p>
</div>
)}
{notGenerated && (
<div className="grid min-h-svh place-items-center p-8">
<div className="text-center">
<p className="font-medium"></p>
<p className="mt-2 text-sm text-muted-foreground">
{' '}
<code className="font-mono">php artisan api-docs:generate</code>
</p>
</div>
</div>
)}
{specification.isError && !notGenerated && (
<div className="grid min-h-svh place-items-center p-8">
<div className="text-center">
<p className="font-medium text-destructive">API </p>
<Button className="mt-4" variant="outline" onClick={() => void specification.refetch()}>
</Button>
</div>
</div>
)}
{specification.data && (
<ScalarReference key={document} content={specification.data} />
)}
</main>
)
}