Harden ops analytics rendering

This commit is contained in:
john
2026-06-20 16:59:57 +08:00
parent 2129b0e8cf
commit 78907792db
2 changed files with 199 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
type ApiPayload<T> = { data: T; error?: { code: string; message: string } };
async function opsFetch<T>(path: string, init?: RequestInit): Promise<T> {
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<T> & {
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 } }>;
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import { fetchAnalytics } from '../api/client';
export function AnalyticsPage() {
const [data, setData] = useState<Awaited<ReturnType<typeof fetchAnalytics>> | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void fetchAnalytics()
.then(setData)
.catch((err) => setError(err instanceof Error ? err.message : '加载失败'));
}, []);
if (error) return <p className="alert">{error}</p>;
if (!data) return <p></p>;
const categories = Array.isArray(data.categories) ? data.categories : [];
const topCreators = Array.isArray(data.top_creators) ? data.top_creators : [];
return (
<div className="grid">
<div className="card" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(160px,1fr))', gap: 12 }}>
<div>
<p></p>
<strong style={{ fontSize: 28 }}>{data.today.new_posts}</strong>
<p style={{ color: '#68716c', fontSize: 12 }}> {data.yesterday.new_posts}</p>
</div>
<div>
<p>广</p>
<strong style={{ fontSize: 28 }}>{data.today.plaza_signups}</strong>
</div>
<div>
<p></p>
<strong style={{ fontSize: 28 }}>{data.today.pending_review}</strong>
</div>
</div>
<div className="card">
<h3></h3>
<ul>
{categories.map((item, index) => (
<li key={item?.name ?? index}>
{item?.name ?? '未命名分类'}: {Number(item?.count ?? 0)}
</li>
))}
</ul>
</div>
<div className="card">
<h3>TOP </h3>
<ul>
{topCreators.map((creator, index) => (
<li key={creator?.slug ?? index}>
{creator?.display_name ?? '未命名'} · {Number(creator?.post_count ?? 0)} · {Number(creator?.total_likes ?? 0)}
</li>
))}
</ul>
</div>
</div>
);
}