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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user