feat: ship blog platform admin and deploy stack

This commit is contained in:
2026-03-31 21:48:39 +08:00
parent a9a05aa105
commit 313f174fbc
210 changed files with 25476 additions and 5803 deletions

View File

@@ -4,8 +4,11 @@ import type {
AdminAiReindexResponse,
AdminAiProviderTestResponse,
AdminImageUploadResponse,
AdminMediaBatchDeleteResponse,
AdminMediaDeleteResponse,
AdminMediaListResponse,
AdminMediaReplaceResponse,
AdminMediaUploadResponse,
AdminPostCoverImageRequest,
AdminPostCoverImageResponse,
AdminDashboardResponse,
@@ -16,7 +19,11 @@ import type {
AdminR2ConnectivityResponse,
AdminSessionResponse,
AdminSiteSettingsResponse,
AuditLogRecord,
CommentListQuery,
CommentBlacklistRecord,
CommentPersonaAnalysisLogRecord,
CommentPersonaAnalysisResponse,
CommentRecord,
CreatePostPayload,
CreateReviewPayload,
@@ -26,16 +33,52 @@ import type {
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 API_BASE = import.meta.env.VITE_API_BASE?.trim() || ''
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
@@ -95,6 +138,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
credentials: 'include',
headers,
})
@@ -126,6 +170,74 @@ export const adminApi = {
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'),
@@ -192,6 +304,35 @@ export const adminApi = {
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',
@@ -237,9 +378,16 @@ export const adminApi = {
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)}`),
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',
@@ -254,6 +402,15 @@ export const adminApi = {
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,
}),
}),
@@ -271,6 +428,15 @@ export const adminApi = {
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) =>
@@ -315,6 +481,59 @@ export const adminApi = {
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', {