feat(ops): add plaza post management page

List published and hidden posts with category filters, pagination, and
inline hide/restore actions for the operations console.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-05 19:27:21 +08:00
parent f63bb894d9
commit 13485558b5
5 changed files with 353 additions and 0 deletions
+2
View File
@@ -22,6 +22,7 @@ import { CreatorsPage } from './ops/pages/CreatorsPage';
import { FeaturedPage } from './ops/pages/FeaturedPage';
import { ReportsPage } from './ops/pages/ReportsPage';
import { ReviewPage } from './ops/pages/ReviewPage';
import { PostsPage } from './ops/pages/PostsPage';
import type { PortalUser } from './types';
function LegacyAdminRedirect() {
@@ -129,6 +130,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
)}
<Route path="/ops" element={<OpsLayout user={user} onLogout={onLogout} />}>
<Route index element={<ReviewPage />} />
<Route path="posts" element={<PostsPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="featured" element={<FeaturedPage />} />
<Route path="creators" element={<CreatorsPage />} />
+12
View File
@@ -24,12 +24,24 @@ export type ReviewPost = {
title: string;
summary: string;
cover_url: string;
status: string;
author: { display_name: string; slug: string };
category: { name: string; slug: string; icon: string };
published_at: string;
sla_warning: boolean;
};
export type PlazaCategory = {
id: string;
name: string;
slug: string;
icon: string;
};
export async function fetchPlazaCategories() {
return opsFetch<{ categories: PlazaCategory[] }>('/api/ops/v1/categories');
}
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 }>(
+1
View File
@@ -10,6 +10,7 @@ const opsSections = [
{
label: '内容管理',
items: [
{ to: '/ops/posts', label: '帖子管理' },
{ to: '/ops/reports', label: '举报处理' },
{ to: '/ops/featured', label: '精选管理' },
{ to: '/ops/creators', label: '创作者' },
+58
View File
@@ -708,3 +708,61 @@ input[type='checkbox'] {
align-items: stretch;
}
}
.ops-filter-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-start;
}
.ops-filter-label {
flex: 0 0 auto;
padding-top: 8px;
font-size: 13px;
color: var(--ops-muted);
}
.ops-chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
flex: 1;
}
.ops-chip {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: var(--ops-text);
border-radius: 999px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
}
.ops-chip.active {
border-color: rgba(120, 170, 255, 0.45);
background: rgba(120, 170, 255, 0.14);
}
.ops-title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.ops-inline-action {
padding: 4px 10px;
font-size: 12px;
}
.ops-pagination {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: flex-end;
padding: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
+280
View File
@@ -0,0 +1,280 @@
import { useCallback, useEffect, useState } from 'react';
import {
fetchPlazaCategories,
fetchReviewQueue,
reviewPost,
type PlazaCategory,
type ReviewPost,
} from '../api/client';
import { plazaPreviewUrl } from '../lib/site';
const PAGE_SIZE = 20;
const STATUS_TABS = [
{ key: 'published', label: '已发布' },
{ key: 'hidden', label: '已下线' },
] as const;
type StatusTab = (typeof STATUS_TABS)[number]['key'];
function formatPublishedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', { hour12: false });
}
export function PostsPage() {
const [tab, setTab] = useState<StatusTab>('published');
const [categories, setCategories] = useState<PlazaCategory[]>([]);
const [categorySlug, setCategorySlug] = useState<string | null>(null);
const [posts, setPosts] = useState<ReviewPost[]>([]);
const [keyword, setKeyword] = useState('');
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [pageIndex, setPageIndex] = useState(0);
const [pageCursors, setPageCursors] = useState<Array<string | null>>([null]);
const [hasMore, setHasMore] = useState(false);
useEffect(() => {
void fetchPlazaCategories()
.then((data) => setCategories(data.categories))
.catch(() => {
setCategories([]);
});
}, []);
const loadPage = useCallback(
async ({
status = tab,
cursor = pageCursors[pageIndex] ?? null,
category = categorySlug,
search = keyword,
nextPageIndex = pageIndex,
}: {
status?: StatusTab;
cursor?: string | null;
category?: string | null;
search?: string;
nextPageIndex?: number;
} = {}) => {
setLoading(true);
setError(null);
try {
const query = new URLSearchParams({
status,
limit: String(PAGE_SIZE),
});
if (cursor) query.set('cursor', cursor);
if (category) query.set('category', category);
if (search.trim()) query.set('keyword', search.trim());
const data = await fetchReviewQueue(query.toString());
setPosts(data.posts);
setHasMore(data.has_more);
setPageIndex(nextPageIndex);
if (data.has_more && data.next_cursor) {
setPageCursors((current) => {
const next = current.slice(0, nextPageIndex + 1);
next[nextPageIndex + 1] = data.next_cursor;
return next;
});
}
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoading(false);
}
},
[tab, pageCursors, pageIndex, categorySlug, keyword],
);
useEffect(() => {
setPageIndex(0);
setPageCursors([null]);
void loadPage({ cursor: null, nextPageIndex: 0 });
}, [tab, categorySlug]);
const handleSearch = () => {
setPageIndex(0);
setPageCursors([null]);
void loadPage({ cursor: null, nextPageIndex: 0, search: keyword });
};
const handlePrevPage = () => {
if (pageIndex <= 0 || loading) return;
const nextPageIndex = pageIndex - 1;
void loadPage({ cursor: pageCursors[nextPageIndex] ?? null, nextPageIndex });
};
const handleNextPage = () => {
if (!hasMore || loading) return;
const nextPageIndex = pageIndex + 1;
void loadPage({ cursor: pageCursors[nextPageIndex] ?? null, nextPageIndex });
};
const handleToggleVisibility = async (id: string, action: 'hide' | 'approve') => {
const label = action === 'hide' ? '下线' : '上线';
if (!window.confirm(`确认${label}该帖子?`)) return;
setBusyId(id);
setError(null);
try {
await reviewPost(id, action);
await loadPage();
} catch (err) {
setError(err instanceof Error ? err.message : `${label}失败`);
} finally {
setBusyId(null);
}
};
return (
<div className="grid">
<div className="card stack">
<div className="ops-summary-row">
<div className="toolbar">
{STATUS_TABS.map((item) => (
<button
key={item.key}
type="button"
className={tab === item.key ? 'btn' : 'btn secondary'}
onClick={() => setTab(item.key)}
>
{item.label}
</button>
))}
</div>
<p className="caption ops-cell-muted"> {PAGE_SIZE} 线 Feed </p>
</div>
<div className="ops-filter-row">
<span className="ops-filter-label"></span>
<div className="ops-chip-row">
<button
type="button"
className={categorySlug === null ? 'ops-chip active' : 'ops-chip'}
onClick={() => setCategorySlug(null)}
>
</button>
{categories.length === 0 ? (
<span className="caption ops-cell-muted"> Admin API </span>
) : null}
{categories.map((category) => (
<button
key={category.id}
type="button"
className={categorySlug === category.slug ? 'ops-chip active' : 'ops-chip'}
onClick={() => setCategorySlug(category.slug)}
>
{category.icon ? `${category.icon} ` : ''}
{category.name}
</button>
))}
</div>
</div>
<div className="ops-toolbar">
<input
type="search"
placeholder="搜索标题 / 作者"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') handleSearch();
}}
className="search-input"
/>
<button type="button" className="btn secondary" onClick={handleSearch}>
</button>
</div>
</div>
{error ? <p className="alert">{error}</p> : null}
{loading && posts.length === 0 ? <div className="card empty-state"></div> : null}
{!loading && posts.length === 0 ? (
<div className="card empty-state">{tab === 'published' ? '已发布' : '已下线'}</div>
) : null}
{posts.length > 0 ? (
<div className="card ops-table-wrap">
<table className="ops-table">
<thead>
<tr>
<th> / </th>
<th> / </th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr key={post.id}>
<td>
<div className="ops-title-row">
<strong>{post.title}</strong>
{tab === 'published' ? (
<button
type="button"
className="btn secondary ops-inline-action"
disabled={busyId === post.id}
onClick={() => void handleToggleVisibility(post.id, 'hide')}
>
{busyId === post.id ? '处理中…' : '下线'}
</button>
) : (
<button
type="button"
className="btn ops-inline-action"
disabled={busyId === post.id}
onClick={() => void handleToggleVisibility(post.id, 'approve')}
>
{busyId === post.id ? '处理中…' : '上线'}
</button>
)}
</div>
</td>
<td>
<div className="ops-stack-mini">
<button
type="button"
className="link-button"
onClick={() => setCategorySlug(post.category.slug)}
>
{post.category.icon} {post.category.name}
</button>
<div className="ops-cell-muted">@{post.author.slug}</div>
</div>
</td>
<td>
<div className="ops-cell-muted">{post.summary || '无摘要'}</div>
</td>
<td>{formatPublishedAt(post.published_at)}</td>
<td>
<a className="btn secondary" href={plazaPreviewUrl(post.id)} target="_blank" rel="noreferrer">
</a>
</td>
</tr>
))}
</tbody>
</table>
<div className="ops-pagination">
<button type="button" className="btn secondary" disabled={pageIndex <= 0 || loading} onClick={handlePrevPage}>
</button>
<span className="ops-cell-muted">
{pageIndex + 1} · {posts.length}
</span>
<button type="button" className="btn secondary" disabled={!hasMore || loading} onClick={handleNextPage}>
</button>
</div>
</div>
) : null}
</div>
);
}