diff --git a/src/ops/api/client.ts b/src/ops/api/client.ts new file mode 100644 index 0000000..68082b0 --- /dev/null +++ b/src/ops/api/client.ts @@ -0,0 +1,140 @@ +type ApiPayload = { data: T; error?: { code: string; message: string } }; + +async function opsFetch(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + ...init, + credentials: 'include', + headers: { + Accept: 'application/json', + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.headers ?? {}), + }, + }); + const payload = (await response.json().catch(() => ({}))) as ApiPayload & { + message?: string; + }; + if (!response.ok) { + throw new Error(payload?.error?.message ?? payload?.message ?? `请求失败 (${response.status})`); + } + return payload.data; +} + +export type ReviewPost = { + id: string; + title: string; + summary: string; + cover_url: string; + author: { display_name: string; slug: string }; + category: { name: string; slug: string; icon: string }; + published_at: string; + sla_warning: boolean; +}; + +export async function fetchReviewQueue(query = 'status=pending_review') { + const suffix = query.startsWith('?') ? query : query ? `?${query}` : ''; + return opsFetch<{ posts: ReviewPost[]; next_cursor: string | null; has_more: boolean }>( + `/api/ops/v1/review/queue${suffix}`, + ); +} + +export async function reviewPost(id: string, action: 'approve' | 'reject' | 'hide', reason?: string) { + return opsFetch<{ post: { id: string; status: string } }>(`/api/ops/v1/review/posts/${id}`, { + method: 'POST', + body: JSON.stringify({ action, reason }), + }); +} + +export async function batchReviewPosts(postIds: string[], action: 'approve' | 'reject' | 'hide') { + return opsFetch<{ posts: Array<{ id: string; status: string }> }>('/api/ops/v1/review/batch', { + method: 'POST', + body: JSON.stringify({ post_ids: postIds, action }), + }); +} + +export async function fetchReports() { + return opsFetch<{ + reports: Array<{ + id: string; + target_type: string; + target_id: string; + reason: string; + detail: string; + target_report_count: number; + created_at: string; + }>; + }>('/api/ops/v1/reports'); +} + +export async function processReport(id: string, action: 'dismiss' | 'hide_post', actionTaken = '') { + return opsFetch<{ report: { id: string; status: string } }>(`/api/ops/v1/reports/${id}/process`, { + method: 'POST', + body: JSON.stringify({ action, action_taken: actionTaken }), + }); +} + +export async function fetchFeatured() { + return opsFetch<{ + items: Array<{ id: string; post_id: string; position: string; title: string; author: string }>; + }>('/api/ops/v1/featured'); +} + +export async function setFeatured(body: { + post_id: string; + position: string; + sort_order?: number; + expires_at?: string | null; +}) { + return opsFetch('/api/ops/v1/featured', { method: 'POST', body: JSON.stringify(body) }); +} + +export async function removeFeatured(id: string) { + return opsFetch(`/api/ops/v1/featured/${id}`, { method: 'DELETE' }); +} + +export async function fetchAnalytics() { + const data = await opsFetch<{ + today: { new_posts: number; plaza_signups: number; pending_review: number }; + yesterday: { new_posts: number }; + categories: Array<{ name: string; count: number }>; + top_creators: Array<{ slug: string; display_name: string; post_count: number; total_likes: number }>; + daily_posts: Array<{ day: string; count: number }>; + }>('/api/ops/v1/analytics/overview'); + return { + today: data.today ?? { new_posts: 0, plaza_signups: 0, pending_review: 0 }, + yesterday: data.yesterday ?? { new_posts: 0 }, + categories: Array.isArray(data.categories) ? data.categories : [], + top_creators: Array.isArray(data.top_creators) ? data.top_creators : [], + daily_posts: Array.isArray(data.daily_posts) ? data.daily_posts : [], + }; +} + +export async function fetchCreators(keyword = '') { + const query = keyword ? `?keyword=${encodeURIComponent(keyword)}` : ''; + return opsFetch<{ + creators: Array<{ + user_id: string; + slug: string; + display_name: string; + post_count: number; + follower_count: number; + verified: boolean; + post_banned: boolean; + comment_banned: boolean; + }>; + }>(`/api/ops/v1/creators${query}`); +} + +export async function updateCreator( + userId: string, + patch: { verified?: boolean; post_banned?: boolean; comment_banned?: boolean }, +) { + return opsFetch(`/api/ops/v1/creators/${userId}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }); +} + +export async function fetchAuthStatus() { + const response = await fetch('/auth/status', { credentials: 'include' }); + return response.json() as Promise<{ authenticated: boolean; user?: { displayName: string } }>; +} diff --git a/src/ops/pages/AnalyticsPage.tsx b/src/ops/pages/AnalyticsPage.tsx new file mode 100644 index 0000000..a002921 --- /dev/null +++ b/src/ops/pages/AnalyticsPage.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react'; +import { fetchAnalytics } from '../api/client'; + +export function AnalyticsPage() { + const [data, setData] = useState> | null>(null); + const [error, setError] = useState(null); + + useEffect(() => { + void fetchAnalytics() + .then(setData) + .catch((err) => setError(err instanceof Error ? err.message : '加载失败')); + }, []); + + if (error) return

{error}

; + if (!data) return

加载中…

; + + const categories = Array.isArray(data.categories) ? data.categories : []; + const topCreators = Array.isArray(data.top_creators) ? data.top_creators : []; + + return ( +
+
+
+

今日新帖

+ {data.today.new_posts} +

昨日 {data.yesterday.new_posts}

+
+
+

广场注册

+ {data.today.plaza_signups} +
+
+

待审核

+ {data.today.pending_review} +
+
+
+

分类分布

+
    + {categories.map((item, index) => ( +
  • + {item?.name ?? '未命名分类'}: {Number(item?.count ?? 0)} +
  • + ))} +
+
+
+

TOP 创作者

+
    + {topCreators.map((creator, index) => ( +
  • + {creator?.display_name ?? '未命名'} · {Number(creator?.post_count ?? 0)} 篇 · {Number(creator?.total_likes ?? 0)} 赞 +
  • + ))} +
+
+
+ ); +}