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) { 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(path: string, init?: RequestInit): Promise { 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('/api/admin/session'), login: (payload: { username: string; password: string }) => request('/api/admin/session/login', { method: 'POST', body: JSON.stringify(payload), }), logout: () => request('/api/admin/session', { method: 'DELETE', }), listAuditLogs: (query?: { action?: string; targetType?: string; limit?: number }) => request( appendQueryParams('/api/admin/audit-logs', { action: query?.action, target_type: query?.targetType, limit: query?.limit, }), ), listPostRevisions: (query?: { slug?: string; limit?: number }) => request( appendQueryParams('/api/admin/post-revisions', { slug: query?.slug, limit: query?.limit, }), ), getPostRevision: (id: number) => request(`/api/admin/post-revisions/${id}`), restorePostRevision: (id: number, mode: 'full' | 'markdown' | 'metadata' = 'full') => request(`/api/admin/post-revisions/${id}/restore`, { method: 'POST', body: JSON.stringify({ mode }), }), listSubscriptions: async () => (await request('/api/admin/subscriptions')).subscriptions, createSubscription: (payload: SubscriptionPayload) => request('/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(`/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(`/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('/api/admin/subscriptions/digest', { method: 'POST', body: JSON.stringify({ period }), }), dashboard: () => request('/api/admin/dashboard'), analytics: () => request('/api/admin/analytics'), getSiteSettings: () => request('/api/admin/site-settings'), updateSiteSettings: (payload: SiteSettingsPayload) => request('/api/admin/site-settings', { method: 'PATCH', body: JSON.stringify(payload), }), reindexAi: () => request('/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('/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('/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('/api/admin/storage/review-cover', { method: 'POST', body: formData, }) }, testR2Storage: () => request('/api/admin/storage/r2/test', { method: 'POST', }), listMediaObjects: (query?: { prefix?: string; limit?: number }) => request( appendQueryParams('/api/admin/storage/media', { prefix: query?.prefix, limit: query?.limit, }), ), deleteMediaObject: (key: string) => request( `/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('/api/admin/storage/media', { method: 'POST', body: formData, }) }, batchDeleteMediaObjects: (keys: string[]) => request('/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('/api/admin/storage/media/replace', { method: 'POST', body: formData, }) }, generatePostMetadata: (markdown: string) => request('/api/admin/ai/post-metadata', { method: 'POST', body: JSON.stringify({ markdown }), }), polishPostMarkdown: (markdown: string) => request('/api/admin/ai/polish-post', { method: 'POST', body: JSON.stringify({ markdown }), }), polishReviewDescription: (payload: AdminReviewPolishRequest) => request('/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('/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( 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(`/api/posts/slug/${encodeURIComponent(slug)}?preview=true&include_private=true`), createPost: (payload: CreatePostPayload) => request('/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(`/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(`/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('/api/posts/markdown/import', { method: 'POST', body: formData, }) }, updatePostMarkdown: (slug: string, markdown: string) => request(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`, { method: 'PATCH', body: JSON.stringify({ markdown }), }), deletePost: (slug: string) => request(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`, { method: 'DELETE', }), listComments: (query?: CommentListQuery) => request( 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(`/api/comments/${id}`, { method: 'PATCH', body: JSON.stringify(payload), }), deleteComment: (id: number) => request(`/api/comments/${id}`, { method: 'DELETE', }), listCommentBlacklist: () => request('/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('/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(`/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('/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( appendQueryParams('/api/admin/comments/analyze/logs', { matcher_type: query?.matcher_type, matcher_value: query?.matcher_value, limit: query?.limit, }), ), listFriendLinks: (query?: FriendLinkListQuery) => request( appendQueryParams('/api/friend_links', { status: query?.status, category: query?.category, }), ), createFriendLink: (payload: FriendLinkPayload) => request('/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(`/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(`/api/friend_links/${id}`, { method: 'DELETE', }), listReviews: () => request('/api/reviews'), createReview: (payload: CreateReviewPayload) => request('/api/reviews', { method: 'POST', body: JSON.stringify(payload), }), updateReview: (id: number, payload: UpdateReviewPayload) => request(`/api/reviews/${id}`, { method: 'PUT', body: JSON.stringify(payload), }), deleteReview: (id: number) => request(`/api/reviews/${id}`, { method: 'DELETE', }), }