From 13485558b5d09b5ed984ffc7b68441bb014e28cd Mon Sep 17 00:00:00 2001 From: john Date: Sun, 5 Jul 2026 19:27:21 +0800 Subject: [PATCH] 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 --- src/App.tsx | 2 + src/ops/api/client.ts | 12 ++ src/ops/components/OpsLayout.tsx | 1 + src/ops/ops.css | 58 +++++++ src/ops/pages/PostsPage.tsx | 280 +++++++++++++++++++++++++++++++ 5 files changed, 353 insertions(+) create mode 100644 src/ops/pages/PostsPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 73009d3..83d2ed9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 } )} }> } /> + } /> } /> } /> } /> diff --git a/src/ops/api/client.ts b/src/ops/api/client.ts index 68082b0..09c702b 100644 --- a/src/ops/api/client.ts +++ b/src/ops/api/client.ts @@ -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 }>( diff --git a/src/ops/components/OpsLayout.tsx b/src/ops/components/OpsLayout.tsx index f1e26f8..d0ed300 100644 --- a/src/ops/components/OpsLayout.tsx +++ b/src/ops/components/OpsLayout.tsx @@ -10,6 +10,7 @@ const opsSections = [ { label: '内容管理', items: [ + { to: '/ops/posts', label: '帖子管理' }, { to: '/ops/reports', label: '举报处理' }, { to: '/ops/featured', label: '精选管理' }, { to: '/ops/creators', label: '创作者' }, diff --git a/src/ops/ops.css b/src/ops/ops.css index c03a6c3..2839554 100644 --- a/src/ops/ops.css +++ b/src/ops/ops.css @@ -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); +} diff --git a/src/ops/pages/PostsPage.tsx b/src/ops/pages/PostsPage.tsx new file mode 100644 index 0000000..e3243a3 --- /dev/null +++ b/src/ops/pages/PostsPage.tsx @@ -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('published'); + const [categories, setCategories] = useState([]); + const [categorySlug, setCategorySlug] = useState(null); + const [posts, setPosts] = useState([]); + const [keyword, setKeyword] = useState(''); + const [error, setError] = useState(null); + const [busyId, setBusyId] = useState(null); + const [loading, setLoading] = useState(false); + const [pageIndex, setPageIndex] = useState(0); + const [pageCursors, setPageCursors] = useState>([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 ( +
+
+
+
+ {STATUS_TABS.map((item) => ( + + ))} +
+

每页 {PAGE_SIZE} 条,下线后用户端 Feed 与详情页同步不可见

+
+ +
+ 分类 +
+ + {categories.length === 0 ? ( + 分类暂不可用,请重启 Admin API 后刷新 + ) : null} + {categories.map((category) => ( + + ))} +
+
+ +
+ setKeyword(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') handleSearch(); + }} + className="search-input" + /> + +
+
+ + {error ?

{error}

: null} + + {loading && posts.length === 0 ?
加载中…
: null} + {!loading && posts.length === 0 ? ( +
暂无{tab === 'published' ? '已发布' : '已下线'}帖子
+ ) : null} + + {posts.length > 0 ? ( +
+ + + + + + + + + + + + {posts.map((post) => ( + + + + + + + + ))} + +
标题 / 操作分类 / 作者摘要发布时间查看
+
+ {post.title} + {tab === 'published' ? ( + + ) : ( + + )} +
+
+
+ +
@{post.author.slug}
+
+
+
{post.summary || '无摘要'}
+
{formatPublishedAt(post.published_at)} + + 预览 + +
+ +
+ + + 第 {pageIndex + 1} 页 · 本页 {posts.length} 条 + + +
+
+ ) : null} +
+ ); +}