Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { OpsLayout } from './components/OpsLayout';
|
||||
import { RequireOps } from './components/RequireOps';
|
||||
import { AnalyticsPage } from './pages/AnalyticsPage';
|
||||
import { CreatorsPage } from './pages/CreatorsPage';
|
||||
import { FeaturedPage } from './pages/FeaturedPage';
|
||||
import { ReportsPage } from './pages/ReportsPage';
|
||||
import { ReviewPage } from './pages/ReviewPage';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<RequireOps>
|
||||
<Routes>
|
||||
<Route element={<OpsLayout />}>
|
||||
<Route index element={<ReviewPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="featured" element={<FeaturedPage />} />
|
||||
<Route path="creators" element={<CreatorsPage />} />
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</RequireOps>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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() {
|
||||
return 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');
|
||||
}
|
||||
|
||||
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 } }>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
|
||||
const links = [
|
||||
{ to: '/', label: '审核队列' },
|
||||
{ to: '/reports', label: '举报处理' },
|
||||
{ to: '/featured', label: '精选管理' },
|
||||
{ to: '/creators', label: '创作者' },
|
||||
{ to: '/analytics', label: '数据看板' },
|
||||
];
|
||||
|
||||
export function OpsLayout() {
|
||||
return (
|
||||
<div className="layout">
|
||||
<header>
|
||||
<h1>Plaza 运营后台</h1>
|
||||
<p style={{ color: '#68716c' }}>内容审核、精选与数据概览</p>
|
||||
</header>
|
||||
<nav className="nav">
|
||||
{links.map((link) => (
|
||||
<NavLink
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
end={link.to === '/'}
|
||||
className={({ isActive }) => (isActive ? 'active' : undefined)}
|
||||
>
|
||||
{link.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchAuthStatus, fetchReviewQueue } from '../api/client';
|
||||
import { mindSpaceLoginUrl } from '../lib/site';
|
||||
|
||||
export function RequireOps({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState<'loading' | 'ok' | 'denied' | 'forbidden'>('loading');
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const auth = await fetchAuthStatus();
|
||||
if (!auth.authenticated) {
|
||||
setState('denied');
|
||||
return;
|
||||
}
|
||||
await fetchReviewQueue('status=pending_review&limit=1');
|
||||
setState('ok');
|
||||
} catch (err) {
|
||||
const text = err instanceof Error ? err.message : '无运营权限';
|
||||
if (text.includes('未授权') || text.includes('登录')) {
|
||||
setState('denied');
|
||||
} else {
|
||||
setMessage(text);
|
||||
setState('forbidden');
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (state === 'loading') return <p>检查登录态…</p>;
|
||||
if (state === 'denied') {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>需要登录</h2>
|
||||
<p>请先在 MindSpace 登录,并确保账号已分配 ops_role(reviewer / editor / ops_admin)。</p>
|
||||
<a className="btn" href={mindSpaceLoginUrl()}>
|
||||
前往登录
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state === 'forbidden') {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>无运营权限</h2>
|
||||
<p>{message ?? '当前账号没有 Plaza 运营权限。'}</p>
|
||||
<p style={{ color: '#68716c' }}>
|
||||
本地开发可执行:<code>node scripts/grant-ops-role.mjs admin ops_admin</code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
:root {
|
||||
color: #17221d;
|
||||
background: #f5f0e5;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #d6d0c3;
|
||||
border-radius: 16px;
|
||||
background: #fffdf7;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 999px;
|
||||
border: 1px solid #2f6f57;
|
||||
background: #2f6f57;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
background: transparent;
|
||||
color: #2f6f57;
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
border-color: #b42318;
|
||||
background: #b42318;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.warn {
|
||||
color: #b54708;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
color: #b42318;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 48px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: #ebe4d6;
|
||||
}
|
||||
|
||||
.nav a.active {
|
||||
background: #2f6f57;
|
||||
color: white;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function plazaPreviewUrl(postId: string): string {
|
||||
const base = String(import.meta.env.VITE_PLAZA_BASE ?? 'https://plaza.tkmind.cn').replace(/\/$/, '');
|
||||
return `${base}/plaza/p/${encodeURIComponent(postId)}`;
|
||||
}
|
||||
|
||||
export function mindSpaceLoginUrl(): string {
|
||||
const base = String(import.meta.env.VITE_MINDSPACE_BASE ?? 'http://localhost:5173').replace(/\/$/, '');
|
||||
return base;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename="/ops">
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
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>;
|
||||
|
||||
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>
|
||||
{data.categories.map((item) => (
|
||||
<li key={item.name}>
|
||||
{item.name}: {item.count}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>TOP 创作者</h3>
|
||||
<ul>
|
||||
{data.top_creators.map((creator) => (
|
||||
<li key={creator.slug}>
|
||||
{creator.display_name} · {creator.post_count} 篇 · {creator.total_likes} 赞
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchCreators, updateCreator } from '../api/client';
|
||||
|
||||
export function CreatorsPage() {
|
||||
const [creators, setCreators] = useState<Awaited<ReturnType<typeof fetchCreators>>['creators']>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await fetchCreators(keyword);
|
||||
setCreators(data.creators);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<div className="card" style={{ display: 'flex', gap: 8 }}>
|
||||
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索创作者" />
|
||||
<button type="button" className="btn secondary" onClick={() => void load()}>
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
{error ? <p className="alert">{error}</p> : null}
|
||||
{creators.map((creator) => (
|
||||
<div key={creator.user_id} className="card" style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<strong>{creator.display_name}</strong> @{creator.slug}
|
||||
<p style={{ color: '#68716c', fontSize: 12 }}>
|
||||
{creator.post_count} 篇 · {creator.follower_count} 粉丝
|
||||
{creator.verified ? ' · 已认证' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() =>
|
||||
void updateCreator(creator.user_id, { verified: !creator.verified }).then(load)
|
||||
}
|
||||
>
|
||||
{creator.verified ? '取消认证' : '认证'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() =>
|
||||
void updateCreator(creator.user_id, { post_banned: !creator.post_banned }).then(load)
|
||||
}
|
||||
>
|
||||
{creator.post_banned ? '解除禁发' : '禁发'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchFeatured, removeFeatured, setFeatured } from '../api/client';
|
||||
|
||||
export function FeaturedPage() {
|
||||
const [items, setItems] = useState<Awaited<ReturnType<typeof fetchFeatured>>['items']>([]);
|
||||
const [postId, setPostId] = useState('');
|
||||
const [position, setPosition] = useState('trending');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await fetchFeatured();
|
||||
setItems(data.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<div className="card grid">
|
||||
<h3>添加精选</h3>
|
||||
<input value={postId} onChange={(e) => setPostId(e.target.value)} placeholder="帖子 ID" />
|
||||
<select value={position} onChange={(e) => setPosition(e.target.value)}>
|
||||
<option value="homepage_banner">首页轮播</option>
|
||||
<option value="trending">热门趋势</option>
|
||||
<option value="category_top">分类置顶</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
void setFeatured({ post_id: postId.trim(), position })
|
||||
.then(() => {
|
||||
setPostId('');
|
||||
return load();
|
||||
})
|
||||
.catch((err) => setError(err instanceof Error ? err.message : '添加失败'))
|
||||
}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
{error ? <p className="alert">{error}</p> : null}
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="card" style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<strong>{item.position}</strong>
|
||||
<p>{item.title}</p>
|
||||
<p style={{ color: '#68716c', fontSize: 12 }}>{item.author}</p>
|
||||
</div>
|
||||
<button type="button" className="btn secondary" onClick={() => void removeFeatured(item.id).then(load)}>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchReports, processReport } from '../api/client';
|
||||
|
||||
export function ReportsPage() {
|
||||
const [reports, setReports] = useState<Awaited<ReturnType<typeof fetchReports>>['reports']>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await fetchReports();
|
||||
setReports(data.reports);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
{error ? <p className="alert">{error}</p> : null}
|
||||
{reports.map((report) => (
|
||||
<div key={report.id} className="card">
|
||||
<h3>
|
||||
{report.target_type} · {report.reason}
|
||||
{report.target_report_count > 3 ? (
|
||||
<span className="alert"> · 高频举报 ({report.target_report_count})</span>
|
||||
) : null}
|
||||
</h3>
|
||||
<p>{report.detail || '无补充说明'}</p>
|
||||
<p style={{ color: '#68716c', fontSize: 12 }}>目标 ID:{report.target_id}</p>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
onClick={() =>
|
||||
void processReport(report.id, 'hide_post', 'hide from report queue').then(load)
|
||||
}
|
||||
>
|
||||
隐藏内容
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void processReport(report.id, 'dismiss').then(load)}
|
||||
>
|
||||
驳回举报
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { fetchReviewQueue, reviewPost, batchReviewPosts, type ReviewPost } from '../api/client';
|
||||
import { plazaPreviewUrl } from '../lib/site';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'pending_review', label: '待审核' },
|
||||
{ key: 'published', label: '已通过' },
|
||||
{ key: 'rejected', label: '已拒绝' },
|
||||
] as const;
|
||||
|
||||
type TabKey = (typeof TABS)[number]['key'];
|
||||
|
||||
export function ReviewPage() {
|
||||
const [tab, setTab] = useState<TabKey>('pending_review');
|
||||
const [posts, setPosts] = useState<ReviewPost[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [batchBusy, setBatchBusy] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
const load = async (status: TabKey = tab, search = keyword) => {
|
||||
setError(null);
|
||||
try {
|
||||
const query = new URLSearchParams({ status });
|
||||
if (search.trim()) query.set('keyword', search.trim());
|
||||
const data = await fetchReviewQueue(query.toString());
|
||||
setPosts(data.posts);
|
||||
setSelected(new Set());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load(tab);
|
||||
}, [tab]);
|
||||
|
||||
const pendingCount = useMemo(
|
||||
() => (tab === 'pending_review' ? posts.length : null),
|
||||
[tab, posts.length],
|
||||
);
|
||||
|
||||
const allSelected = useMemo(
|
||||
() => posts.length > 0 && posts.every((post) => selected.has(post.id)),
|
||||
[posts, selected],
|
||||
);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
setSelected(new Set());
|
||||
return;
|
||||
}
|
||||
setSelected(new Set(posts.map((post) => post.id)));
|
||||
};
|
||||
|
||||
const handleReview = async (id: string, action: 'approve' | 'reject') => {
|
||||
setBusyId(id);
|
||||
try {
|
||||
const reason =
|
||||
action === 'reject'
|
||||
? window.prompt('拒绝原因(必填)', '低质内容') ?? undefined
|
||||
: undefined;
|
||||
if (action === 'reject' && !reason) return;
|
||||
await reviewPost(id, action, reason);
|
||||
await load(tab);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '操作失败');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchApprove = async () => {
|
||||
const ids = [...selected];
|
||||
if (ids.length === 0) return;
|
||||
if (!window.confirm(`确认批量通过 ${ids.length} 条帖子?`)) return;
|
||||
setBatchBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await batchReviewPosts(ids, 'approve');
|
||||
await load(tab);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '批量操作失败');
|
||||
} finally {
|
||||
setBatchBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<div className="card" style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{TABS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={tab === item.key ? 'btn' : 'btn secondary'}
|
||||
onClick={() => setTab(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
{item.key === 'pending_review' && tab === 'pending_review' && pendingCount != null
|
||||
? ` (${pendingCount})`
|
||||
: ''}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索标题 / 作者"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void load(tab, keyword);
|
||||
}}
|
||||
style={{ flex: '1 1 200px', minWidth: 180, padding: '8px 12px', borderRadius: 999, border: '1px solid #d6d0c3' }}
|
||||
/>
|
||||
<button type="button" className="btn secondary" onClick={() => void load(tab, keyword)}>
|
||||
搜索
|
||||
</button>
|
||||
{tab === 'pending_review' && posts.length > 0 ? (
|
||||
<>
|
||||
<button type="button" className="btn secondary" onClick={toggleSelectAll}>
|
||||
{allSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={batchBusy || selected.size === 0}
|
||||
onClick={() => void handleBatchApprove()}
|
||||
>
|
||||
{batchBusy ? '处理中…' : `批量通过 (${selected.size})`}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? <p className="alert">{error}</p> : null}
|
||||
{posts.length === 0 ? <div className="card">暂无{tab === 'pending_review' ? '待审核' : ''}帖子</div> : null}
|
||||
{posts.map((post) => (
|
||||
<div key={post.id} className="card grid">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'start' }}>
|
||||
{tab === 'pending_review' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(post.id)}
|
||||
onChange={() => toggleSelect(post.id)}
|
||||
aria-label={`选择 ${post.title}`}
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<h3>{post.title}</h3>
|
||||
<p style={{ color: '#68716c' }}>
|
||||
{post.category.icon} {post.category.name} · @{post.author.slug}
|
||||
</p>
|
||||
{post.sla_warning ? <p className="warn">已超过 2 小时未审核</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{tab === 'pending_review' ? (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'start' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busyId === post.id}
|
||||
onClick={() => void handleReview(post.id, 'approve')}
|
||||
>
|
||||
通过
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
disabled={busyId === post.id}
|
||||
onClick={() => void handleReview(post.id, 'reject')}
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
<a
|
||||
className="btn secondary"
|
||||
href={plazaPreviewUrl(post.id)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
预览
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
className="btn secondary"
|
||||
href={plazaPreviewUrl(post.id)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
查看
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{post.summary ? <p>{post.summary}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_PLAZA_BASE?: string;
|
||||
readonly VITE_MINDSPACE_BASE?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user