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 { FeaturedPage } from './ops/pages/FeaturedPage';
|
||||||
import { ReportsPage } from './ops/pages/ReportsPage';
|
import { ReportsPage } from './ops/pages/ReportsPage';
|
||||||
import { ReviewPage } from './ops/pages/ReviewPage';
|
import { ReviewPage } from './ops/pages/ReviewPage';
|
||||||
|
import { PostsPage } from './ops/pages/PostsPage';
|
||||||
import type { PortalUser } from './types';
|
import type { PortalUser } from './types';
|
||||||
|
|
||||||
function LegacyAdminRedirect() {
|
function LegacyAdminRedirect() {
|
||||||
@@ -129,6 +130,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
|
|||||||
)}
|
)}
|
||||||
<Route path="/ops" element={<OpsLayout user={user} onLogout={onLogout} />}>
|
<Route path="/ops" element={<OpsLayout user={user} onLogout={onLogout} />}>
|
||||||
<Route index element={<ReviewPage />} />
|
<Route index element={<ReviewPage />} />
|
||||||
|
<Route path="posts" element={<PostsPage />} />
|
||||||
<Route path="reports" element={<ReportsPage />} />
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
<Route path="featured" element={<FeaturedPage />} />
|
<Route path="featured" element={<FeaturedPage />} />
|
||||||
<Route path="creators" element={<CreatorsPage />} />
|
<Route path="creators" element={<CreatorsPage />} />
|
||||||
|
|||||||
@@ -24,12 +24,24 @@ export type ReviewPost = {
|
|||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
cover_url: string;
|
cover_url: string;
|
||||||
|
status: string;
|
||||||
author: { display_name: string; slug: string };
|
author: { display_name: string; slug: string };
|
||||||
category: { name: string; slug: string; icon: string };
|
category: { name: string; slug: string; icon: string };
|
||||||
published_at: string;
|
published_at: string;
|
||||||
sla_warning: boolean;
|
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') {
|
export async function fetchReviewQueue(query = 'status=pending_review') {
|
||||||
const suffix = query.startsWith('?') ? query : query ? `?${query}` : '';
|
const suffix = query.startsWith('?') ? query : query ? `?${query}` : '';
|
||||||
return opsFetch<{ posts: ReviewPost[]; next_cursor: string | null; has_more: boolean }>(
|
return opsFetch<{ posts: ReviewPost[]; next_cursor: string | null; has_more: boolean }>(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const opsSections = [
|
|||||||
{
|
{
|
||||||
label: '内容管理',
|
label: '内容管理',
|
||||||
items: [
|
items: [
|
||||||
|
{ to: '/ops/posts', label: '帖子管理' },
|
||||||
{ to: '/ops/reports', label: '举报处理' },
|
{ to: '/ops/reports', label: '举报处理' },
|
||||||
{ to: '/ops/featured', label: '精选管理' },
|
{ to: '/ops/featured', label: '精选管理' },
|
||||||
{ to: '/ops/creators', label: '创作者' },
|
{ to: '/ops/creators', label: '创作者' },
|
||||||
|
|||||||
@@ -708,3 +708,61 @@ input[type='checkbox'] {
|
|||||||
align-items: stretch;
|
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