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:
John
2026-06-15 15:04:43 -07:00
commit 2e14873f2d
272 changed files with 64133 additions and 0 deletions
+25
View File
@@ -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>
);
}
+133
View File
@@ -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 } }>;
}
+33
View File
@@ -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>
);
}
+55
View File
@@ -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_rolereviewer / 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;
}
+90
View File
@@ -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;
}
+9
View File
@@ -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;
}
+13
View File
@@ -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>,
);
+56
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}
+209
View File
@@ -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>
);
}
+10
View File
@@ -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;
}