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
+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>
);
}