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

588 lines
18 KiB
TypeScript

import type {
AdminAnalyticsResponse,
AdminAiImageProviderTestResponse,
AdminAiReindexResponse,
AdminAiProviderTestResponse,
AdminImageUploadResponse,
AdminMediaBatchDeleteResponse,
AdminMediaDeleteResponse,
AdminMediaListResponse,
AdminMediaReplaceResponse,
AdminMediaUploadResponse,
AdminPostCoverImageRequest,
AdminPostCoverImageResponse,
AdminDashboardResponse,
AdminPostMetadataResponse,
AdminPostPolishResponse,
AdminReviewPolishRequest,
AdminReviewPolishResponse,
AdminR2ConnectivityResponse,
AdminSessionResponse,
AdminSiteSettingsResponse,
AuditLogRecord,
CommentListQuery,
CommentBlacklistRecord,
CommentPersonaAnalysisLogRecord,
CommentPersonaAnalysisResponse,
CommentRecord,
CreatePostPayload,
CreateReviewPayload,
FriendLinkListQuery,
FriendLinkPayload,
FriendLinkRecord,
MarkdownDeleteResponse,
MarkdownDocumentResponse,
MarkdownImportResponse,
NotificationDeliveryRecord,
PostListQuery,
PostRevisionDetail,
PostRevisionRecord,
PostRecord,
ReviewRecord,
RestoreRevisionResponse,
SiteSettingsPayload,
SubscriptionDigestResponse,
SubscriptionListResponse,
SubscriptionPayload,
SubscriptionRecord,
SubscriptionUpdatePayload,
UpdateCommentPayload,
UpdatePostPayload,
UpdateReviewPayload,
} from '@/lib/types'
import { getRuntimeAdminBaseUrl, normalizeAdminBaseUrl } from '@/lib/runtime-config'
const envApiBase = normalizeAdminBaseUrl(import.meta.env.VITE_API_BASE)
const DEV_API_BASE = 'http://localhost:5150'
const PROD_DEFAULT_API_PORT = '5150'
function getApiBase() {
const runtimeApiBase = getRuntimeAdminBaseUrl('apiBaseUrl')
if (runtimeApiBase) {
return runtimeApiBase
}
if (envApiBase) {
return envApiBase
}
if (import.meta.env.DEV) {
return DEV_API_BASE
}
if (typeof window === 'undefined') {
return DEV_API_BASE
}
const { protocol, hostname } = window.location
return `${protocol}//${hostname}:${PROD_DEFAULT_API_PORT}`
}
const API_BASE = getApiBase()
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,
credentials: 'include',
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',
}),
listAuditLogs: (query?: { action?: string; targetType?: string; limit?: number }) =>
request<AuditLogRecord[]>(
appendQueryParams('/api/admin/audit-logs', {
action: query?.action,
target_type: query?.targetType,
limit: query?.limit,
}),
),
listPostRevisions: (query?: { slug?: string; limit?: number }) =>
request<PostRevisionRecord[]>(
appendQueryParams('/api/admin/post-revisions', {
slug: query?.slug,
limit: query?.limit,
}),
),
getPostRevision: (id: number) => request<PostRevisionDetail>(`/api/admin/post-revisions/${id}`),
restorePostRevision: (id: number, mode: 'full' | 'markdown' | 'metadata' = 'full') =>
request<RestoreRevisionResponse>(`/api/admin/post-revisions/${id}/restore`, {
method: 'POST',
body: JSON.stringify({ mode }),
}),
listSubscriptions: async () =>
(await request<SubscriptionListResponse>('/api/admin/subscriptions')).subscriptions,
createSubscription: (payload: SubscriptionPayload) =>
request<SubscriptionRecord>('/api/admin/subscriptions', {
method: 'POST',
body: JSON.stringify({
channelType: payload.channelType,
target: payload.target,
displayName: payload.displayName,
status: payload.status,
filters: payload.filters,
metadata: payload.metadata,
secret: payload.secret,
notes: payload.notes,
}),
}),
updateSubscription: (id: number, payload: SubscriptionUpdatePayload) =>
request<SubscriptionRecord>(`/api/admin/subscriptions/${id}`, {
method: 'PATCH',
body: JSON.stringify({
channelType: payload.channelType,
target: payload.target,
displayName: payload.displayName,
status: payload.status,
filters: payload.filters,
metadata: payload.metadata,
secret: payload.secret,
notes: payload.notes,
}),
}),
deleteSubscription: (id: number) =>
request<void>(`/api/admin/subscriptions/${id}`, {
method: 'DELETE',
}),
testSubscription: (id: number) =>
request<{ queued: boolean; id: number; delivery_id: number }>(`/api/admin/subscriptions/${id}/test`, {
method: 'POST',
}),
listSubscriptionDeliveries: async (limit = 80) =>
(await request<{ deliveries: NotificationDeliveryRecord[] }>(
appendQueryParams('/api/admin/subscriptions/deliveries', { limit }),
)).deliveries,
sendSubscriptionDigest: (period: 'weekly' | 'monthly') =>
request<SubscriptionDigestResponse>('/api/admin/subscriptions/digest', {
method: 'POST',
body: JSON.stringify({ period }),
}),
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',
},
),
uploadMediaObjects: (files: File[], options?: { prefix?: string }) => {
const formData = new FormData()
if (options?.prefix) {
formData.append('prefix', options.prefix)
}
files.forEach((file) => {
formData.append('files', file, file.name)
})
return request<AdminMediaUploadResponse>('/api/admin/storage/media', {
method: 'POST',
body: formData,
})
},
batchDeleteMediaObjects: (keys: string[]) =>
request<AdminMediaBatchDeleteResponse>('/api/admin/storage/media/batch-delete', {
method: 'POST',
body: JSON.stringify({ keys }),
}),
replaceMediaObject: (key: string, file: File) => {
const formData = new FormData()
formData.append('key', key)
formData.append('file', file, file.name)
return request<AdminMediaReplaceResponse>('/api/admin/storage/media/replace', {
method: 'POST',
body: formData,
})
},
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,
status: query?.status,
visibility: query?.visibility,
listed_only: query?.listedOnly,
include_private: query?.includePrivate ?? true,
include_redirects: query?.includeRedirects ?? true,
preview: query?.preview ?? true,
}),
),
getPostBySlug: (slug: string) =>
request<PostRecord>(`/api/posts/slug/${encodeURIComponent(slug)}?preview=true&include_private=true`),
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,
status: payload.status,
visibility: payload.visibility,
publish_at: payload.publishAt,
unpublish_at: payload.unpublishAt,
canonical_url: payload.canonicalUrl,
noindex: payload.noindex,
og_image: payload.ogImage,
redirect_from: payload.redirectFrom,
redirect_to: payload.redirectTo,
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,
status: payload.status,
visibility: payload.visibility,
publish_at: payload.publishAt,
unpublish_at: payload.unpublishAt,
canonical_url: payload.canonicalUrl,
noindex: payload.noindex,
og_image: payload.ogImage,
redirect_from: payload.redirectFrom,
redirect_to: payload.redirectTo,
}),
}),
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',
}),
listCommentBlacklist: () =>
request<CommentBlacklistRecord[]>('/api/admin/comments/blacklist'),
createCommentBlacklist: (payload: {
matcher_type: 'ip' | 'email' | 'user_agent' | string
matcher_value: string
reason?: string | null
active?: boolean
expires_at?: string | null
}) =>
request<CommentBlacklistRecord>('/api/admin/comments/blacklist', {
method: 'POST',
body: JSON.stringify(payload),
}),
updateCommentBlacklist: (
id: number,
payload: {
reason?: string | null
active?: boolean
expires_at?: string | null
clear_expires_at?: boolean
},
) =>
request<CommentBlacklistRecord>(`/api/admin/comments/blacklist/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
}),
deleteCommentBlacklist: (id: number) =>
request<{ deleted: boolean; id: number }>(`/api/admin/comments/blacklist/${id}`, {
method: 'DELETE',
}),
analyzeCommentPersona: (payload: {
matcher_type: 'ip' | 'email' | 'user_agent' | string
matcher_value: string
from?: string | null
to?: string | null
limit?: number
}) =>
request<CommentPersonaAnalysisResponse>('/api/admin/comments/analyze', {
method: 'POST',
body: JSON.stringify(payload),
}),
listCommentPersonaAnalysisLogs: (query?: {
matcher_type?: 'ip' | 'email' | 'user_agent' | string
matcher_value?: string
limit?: number
}) =>
request<CommentPersonaAnalysisLogRecord[]>(
appendQueryParams('/api/admin/comments/analyze/logs', {
matcher_type: query?.matcher_type,
matcher_value: query?.matcher_value,
limit: query?.limit,
}),
),
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',
}),
}