Files
termi-blog/admin/src/lib/api.ts

369 lines
11 KiB
TypeScript

import type {
AdminAnalyticsResponse,
AdminAiImageProviderTestResponse,
AdminAiReindexResponse,
AdminAiProviderTestResponse,
AdminImageUploadResponse,
AdminMediaDeleteResponse,
AdminMediaListResponse,
AdminPostCoverImageRequest,
AdminPostCoverImageResponse,
AdminDashboardResponse,
AdminPostMetadataResponse,
AdminPostPolishResponse,
AdminReviewPolishRequest,
AdminReviewPolishResponse,
AdminR2ConnectivityResponse,
AdminSessionResponse,
AdminSiteSettingsResponse,
CommentListQuery,
CommentRecord,
CreatePostPayload,
CreateReviewPayload,
FriendLinkListQuery,
FriendLinkPayload,
FriendLinkRecord,
MarkdownDeleteResponse,
MarkdownDocumentResponse,
MarkdownImportResponse,
PostListQuery,
PostRecord,
ReviewRecord,
SiteSettingsPayload,
UpdateCommentPayload,
UpdatePostPayload,
UpdateReviewPayload,
} from '@/lib/types'
const API_BASE = import.meta.env.VITE_API_BASE?.trim() || ''
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
async function readErrorMessage(response: Response) {
const text = await response.text().catch(() => '')
if (!text) {
return `请求失败,状态码 ${response.status}`
}
try {
const parsed = JSON.parse(text) as { description?: string; error?: string; message?: string }
return parsed.description || parsed.error || parsed.message || text
} catch {
return text
}
}
function appendQueryParams(path: string, params?: Record<string, unknown>) {
if (!params) {
return path
}
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return
}
if (typeof value === 'boolean') {
searchParams.set(key, String(value))
return
}
searchParams.set(key, String(value))
})
const queryString = searchParams.toString()
return queryString ? `${path}?${queryString}` : path
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers)
if (init?.body && !(init.body instanceof FormData) && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
const response = await fetch(`${API_BASE}${path}`, {
...init,
headers,
})
if (!response.ok) {
throw new ApiError(await readErrorMessage(response), response.status)
}
if (response.status === 204) {
return undefined as T
}
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
return (await response.json()) as T
}
return (await response.text()) as T
}
export const adminApi = {
sessionStatus: () => request<AdminSessionResponse>('/api/admin/session'),
login: (payload: { username: string; password: string }) =>
request<AdminSessionResponse>('/api/admin/session/login', {
method: 'POST',
body: JSON.stringify(payload),
}),
logout: () =>
request<AdminSessionResponse>('/api/admin/session', {
method: 'DELETE',
}),
dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'),
analytics: () => request<AdminAnalyticsResponse>('/api/admin/analytics'),
getSiteSettings: () => request<AdminSiteSettingsResponse>('/api/admin/site-settings'),
updateSiteSettings: (payload: SiteSettingsPayload) =>
request<AdminSiteSettingsResponse>('/api/admin/site-settings', {
method: 'PATCH',
body: JSON.stringify(payload),
}),
reindexAi: () =>
request<AdminAiReindexResponse>('/api/admin/ai/reindex', {
method: 'POST',
}),
testAiProvider: (provider: {
id: string
name: string
provider: string
api_base: string | null
api_key: string | null
chat_model: string | null
}) =>
request<AdminAiProviderTestResponse>('/api/admin/ai/test-provider', {
method: 'POST',
body: JSON.stringify({ provider }),
}),
testAiImageProvider: (provider: {
provider: string
api_base: string | null
api_key: string | null
image_model: string | null
}) =>
request<AdminAiImageProviderTestResponse>('/api/admin/ai/test-image-provider', {
method: 'POST',
body: JSON.stringify({
provider: provider.provider,
api_base: provider.api_base,
api_key: provider.api_key,
image_model: provider.image_model,
}),
}),
uploadReviewCoverImage: (file: File) => {
const formData = new FormData()
formData.append('file', file, file.name)
return request<AdminImageUploadResponse>('/api/admin/storage/review-cover', {
method: 'POST',
body: formData,
})
},
testR2Storage: () =>
request<AdminR2ConnectivityResponse>('/api/admin/storage/r2/test', {
method: 'POST',
}),
listMediaObjects: (query?: { prefix?: string; limit?: number }) =>
request<AdminMediaListResponse>(
appendQueryParams('/api/admin/storage/media', {
prefix: query?.prefix,
limit: query?.limit,
}),
),
deleteMediaObject: (key: string) =>
request<AdminMediaDeleteResponse>(
`/api/admin/storage/media?key=${encodeURIComponent(key)}`,
{
method: 'DELETE',
},
),
generatePostMetadata: (markdown: string) =>
request<AdminPostMetadataResponse>('/api/admin/ai/post-metadata', {
method: 'POST',
body: JSON.stringify({ markdown }),
}),
polishPostMarkdown: (markdown: string) =>
request<AdminPostPolishResponse>('/api/admin/ai/polish-post', {
method: 'POST',
body: JSON.stringify({ markdown }),
}),
polishReviewDescription: (payload: AdminReviewPolishRequest) =>
request<AdminReviewPolishResponse>('/api/admin/ai/polish-review', {
method: 'POST',
body: JSON.stringify({
title: payload.title,
review_type: payload.reviewType,
rating: payload.rating,
review_date: payload.reviewDate,
status: payload.status,
tags: payload.tags,
description: payload.description,
}),
}),
generatePostCoverImage: (payload: AdminPostCoverImageRequest) =>
request<AdminPostCoverImageResponse>('/api/admin/ai/post-cover', {
method: 'POST',
body: JSON.stringify({
title: payload.title,
description: payload.description,
category: payload.category,
tags: payload.tags,
post_type: payload.postType,
slug: payload.slug,
markdown: payload.markdown,
}),
}),
listPosts: (query?: PostListQuery) =>
request<PostRecord[]>(
appendQueryParams('/api/posts', {
slug: query?.slug,
category: query?.category,
tag: query?.tag,
search: query?.search,
type: query?.postType,
pinned: query?.pinned,
}),
),
getPostBySlug: (slug: string) => request<PostRecord>(`/api/posts/slug/${encodeURIComponent(slug)}`),
createPost: (payload: CreatePostPayload) =>
request<MarkdownDocumentResponse>('/api/posts/markdown', {
method: 'POST',
body: JSON.stringify({
title: payload.title,
slug: payload.slug,
description: payload.description,
content: payload.content,
category: payload.category,
tags: payload.tags,
post_type: payload.postType,
image: payload.image,
images: payload.images,
pinned: payload.pinned,
published: payload.published,
}),
}),
updatePost: (id: number, payload: UpdatePostPayload) =>
request<PostRecord>(`/api/posts/${id}`, {
method: 'PATCH',
body: JSON.stringify({
title: payload.title,
slug: payload.slug,
description: payload.description,
content: payload.content,
category: payload.category,
tags: payload.tags,
post_type: payload.postType,
image: payload.image,
images: payload.images,
pinned: payload.pinned,
}),
}),
getPostMarkdown: (slug: string) =>
request<MarkdownDocumentResponse>(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`),
importPosts: async (files: File[]) => {
const formData = new FormData()
files.forEach((file) => {
formData.append('files', file, file.webkitRelativePath || file.name)
})
return request<MarkdownImportResponse>('/api/posts/markdown/import', {
method: 'POST',
body: formData,
})
},
updatePostMarkdown: (slug: string, markdown: string) =>
request<MarkdownDocumentResponse>(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`, {
method: 'PATCH',
body: JSON.stringify({ markdown }),
}),
deletePost: (slug: string) =>
request<MarkdownDeleteResponse>(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`, {
method: 'DELETE',
}),
listComments: (query?: CommentListQuery) =>
request<CommentRecord[]>(
appendQueryParams('/api/comments', {
post_id: query?.postId,
post_slug: query?.postSlug,
scope: query?.scope,
paragraph_key: query?.paragraphKey,
approved: query?.approved,
}),
),
updateComment: (id: number, payload: UpdateCommentPayload) =>
request<CommentRecord>(`/api/comments/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
}),
deleteComment: (id: number) =>
request<void>(`/api/comments/${id}`, {
method: 'DELETE',
}),
listFriendLinks: (query?: FriendLinkListQuery) =>
request<FriendLinkRecord[]>(
appendQueryParams('/api/friend_links', {
status: query?.status,
category: query?.category,
}),
),
createFriendLink: (payload: FriendLinkPayload) =>
request<FriendLinkRecord>('/api/friend_links', {
method: 'POST',
body: JSON.stringify({
siteName: payload.siteName,
siteUrl: payload.siteUrl,
avatarUrl: payload.avatarUrl,
description: payload.description,
category: payload.category,
status: payload.status,
}),
}),
updateFriendLink: (id: number, payload: FriendLinkPayload) =>
request<FriendLinkRecord>(`/api/friend_links/${id}`, {
method: 'PATCH',
body: JSON.stringify({
site_name: payload.siteName,
site_url: payload.siteUrl,
avatar_url: payload.avatarUrl,
description: payload.description,
category: payload.category,
status: payload.status,
}),
}),
deleteFriendLink: (id: number) =>
request<void>(`/api/friend_links/${id}`, {
method: 'DELETE',
}),
listReviews: () => request<ReviewRecord[]>('/api/reviews'),
createReview: (payload: CreateReviewPayload) =>
request<ReviewRecord>('/api/reviews', {
method: 'POST',
body: JSON.stringify(payload),
}),
updateReview: (id: number, payload: UpdateReviewPayload) =>
request<ReviewRecord>(`/api/reviews/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
}),
deleteReview: (id: number) =>
request<void>(`/api/reviews/${id}`, {
method: 'DELETE',
}),
}