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:
@@ -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 />} />
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -10,6 +10,7 @@ const opsSections = [
|
||||
{
|
||||
label: '内容管理',
|
||||
items: [
|
||||
{ to: '/ops/posts', label: '帖子管理' },
|
||||
{ to: '/ops/reports', label: '举报处理' },
|
||||
{ to: '/ops/featured', label: '精选管理' },
|
||||
{ to: '/ops/creators', label: '创作者' },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user