588 lines
18 KiB
TypeScript
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',
|
|
}),
|
|
}
|