统一 React 管理后台,修复评论换行与帖子置顶

- /admin/* 全部由 React SPA 渲染,替代旧版 HTML 后台页面
- 新增仪表盘、帖子/评论/用户管理、系统设置与 JSON API
- 帖子详情页支持管理员置顶;评论换行显示修复

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 23:06:44 +08:00
parent 9230f7272d
commit d0555de28e
63 changed files with 1289 additions and 284 deletions

View File

@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ArrowLeft, Plus } from 'lucide-react';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@@ -25,7 +25,7 @@ import {
} from '@/components/ui/form';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import { useAdminGuard } from '../layouts/AdminLayout';
import type { Board } from '../api/types';
const boardSchema = z.object({
@@ -38,7 +38,7 @@ type BoardFormValues = z.infer<typeof boardSchema>;
export default function BoardsManagePage() {
const nav = useNavigate();
const { user, loading: authLoading } = useAuth();
const { ready } = useAdminGuard();
const [boards, setBoards] = useState<Board[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
@@ -59,11 +59,8 @@ export default function BoardsManagePage() {
};
useEffect(() => {
if (authLoading) return;
if (!user) { nav('/login'); return; }
if (user.role !== 'admin') { nav('/'); notify.warning('需要管理员权限'); return; }
load();
}, [user, authLoading, nav]);
if (ready) load();
}, [ready]);
const openCreate = () => {
setEditing(null);
@@ -112,31 +109,26 @@ export default function BoardsManagePage() {
}
};
if (authLoading) {
if (!ready) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!user || user.role !== 'admin') return null;
return (
<div className="page-wrap">
<div className="page-inner-wide">
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
<ArrowLeft />
返回
</Button>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 20 }}>
<div className="admin-page">
<div className="admin-page-head">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h1 className="page-title">板块管理</h1>
<p className="page-desc">创建、编辑或删除论坛板块,用户发帖前需先有板块</p>
<h1>板块管理</h1>
<p>创建、编辑或删除论坛板块,用户发帖前需先有板块</p>
</div>
<Button onClick={openCreate}>
<Plus />
新建板块
</Button>
</div>
</div>
<div className="section-card" style={{ padding: 0, overflow: 'hidden' }}>
<div className="admin-card">
{loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : (
@@ -197,7 +189,6 @@ export default function BoardsManagePage() {
)}
</>
)}
</div>
</div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>

View File

@@ -91,7 +91,7 @@ export default function ComposePage() {
<h2>暂无可发帖板块</h2>
<p>需要管理员先创建板块后才能发布内容</p>
{user.role === 'admin' ? (
<button type="button" className="compose-primary-btn" onClick={() => nav('/boards')}>
<button type="button" className="compose-primary-btn" onClick={() => nav('/admin/boards')}>
去创建板块
</button>
) : (

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, ThumbsUp, Star, Pencil } from 'lucide-react';
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
@@ -162,6 +162,18 @@ export default function PostDetailPage() {
const authorInitial = post.user?.nickname?.[0] || '?';
const tags = post.tags?.split(/[,,]/).map(t => t.trim()).filter(Boolean) ?? [];
const canEdit = user && (user.role === 'admin' || user.id === post.user_id);
const isAdmin = user?.role === 'admin';
const handlePin = async () => {
if (!post) return;
try {
const r = await api.adminPinPost(postId, !post.pinned);
setPost(p => p ? { ...p, pinned: r.pinned } : p);
notify.success(r.message);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
return (
<div className="page-wrap post-detail-page" ref={pageRef}>
@@ -217,6 +229,12 @@ export default function PostDetailPage() {
编辑
</Button>
)}
{isAdmin && (
<Button variant="outline" size="sm" onClick={handlePin}>
<Pin />
{post.pinned ? '取消置顶' : '置顶'}
</Button>
)}
</div>
</div>

View File

@@ -12,7 +12,6 @@ import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { useAuth } from '../hooks/useAuth';
import { api } from '../api/client';
import { openAdminDashboard } from '../utils/admin';
const nickSchema = z.object({
nickname: z.string().min(1, '请输入昵称').max(64),
@@ -134,11 +133,11 @@ export default function ProfilePage() {
管理板块、用户、帖子及系统设置
</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => nav('/boards')}>
<Button variant="outline" onClick={() => nav('/admin/boards')}>
<Settings />
管理板块
</Button>
<Button onClick={openAdminDashboard}>
<Button onClick={() => nav('/admin/dashboard')}>
<LayoutDashboard />
进入系统后台
</Button>

View File

@@ -0,0 +1,125 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { Comment } from '../../api/types';
export default function AdminCommentsPage() {
const nav = useNavigate();
const { ready } = useAdminGuard();
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const load = (p = page) => {
setLoading(true);
api.adminComments(p)
.then(d => {
setComments(d.comments ?? []);
setPage(d.page);
setTotalPages(d.total_pages);
})
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
};
useEffect(() => {
if (ready) load(1);
}, [ready]);
const remove = async (id: number) => {
try {
await api.adminDeleteComment(id);
notify.success('评论已删除');
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '删除失败');
}
};
if (!ready) return null;
return (
<div className="admin-page">
<div className="admin-page-head">
<h1>评论管理</h1>
<p>查看与删除楼层评论</p>
</div>
<div className="admin-card">
{loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : (
<>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th>楼层</th>
<th>帖子</th>
<th>作者</th>
<th>内容</th>
<th>私密</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{comments.map(c => (
<tr key={c.id}>
<td>{c.id}</td>
<td>#{c.floor}</td>
<td>
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${c.post_id}`)}>
{c.post?.title ?? `#${c.post_id}`}
</button>
</td>
<td>{c.user?.nickname || c.guest_nick || '游客'}</td>
<td className="max-w-[200px] truncate">{c.content}</td>
<td>{c.is_private ? <Badge variant="secondary">是</Badge> : '—'}</td>
<td>{new Date(c.created_at).toLocaleString('zh-CN')}</td>
<td>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost" className="text-destructive">删除</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
<AlertDialogDescription>此操作不可恢复。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => remove(c.id)}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</td>
</tr>
))}
</tbody>
</table>
{comments.length === 0 && <div className="admin-empty">暂无评论</div>}
{totalPages > 1 && (
<div className="admin-pagination">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>上一页</Button>
<span>第 {page} / {totalPages} 页</span>
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>下一页</Button>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Spinner } from '@/components/ui/spinner';
import { Badge } from '@/components/ui/badge';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { AdminDashboard } from '../../api/types';
export default function AdminDashboardPage() {
const nav = useNavigate();
const { ready } = useAdminGuard();
const [data, setData] = useState<AdminDashboard | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!ready) return;
api.adminDashboard()
.then(setData)
.finally(() => setLoading(false));
}, [ready]);
if (!ready || loading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!data) return null;
const stats = [
{ label: '注册用户', value: data.users, cls: 'admin-stat-users' },
{ label: '帖子总数', value: data.posts, cls: 'admin-stat-posts' },
{ label: '板块数量', value: data.boards, cls: 'admin-stat-boards' },
{ label: '评论总数', value: data.comments, cls: 'admin-stat-comments' },
{ label: '当前在线', value: data.online, cls: 'admin-stat-online' },
];
return (
<div className="admin-page">
<div className="admin-page-head">
<h1>仪表盘</h1>
<p>论坛运行概览与最新帖子</p>
</div>
<div className="admin-stat-grid">
{stats.map(s => (
<div key={s.label} className={`admin-stat-card ${s.cls}`}>
<div className="admin-stat-value">{s.value}</div>
<div className="admin-stat-label">{s.label}</div>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-card-head">
<span>最新帖子</span>
<button type="button" className="admin-text-link" onClick={() => nav('/admin/posts')}>
查看全部 →
</button>
</div>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>作者</th>
<th>置顶</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{data.recent_posts.map(p => (
<tr key={p.id}>
<td>{p.id}</td>
<td>
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${p.id}`)}>
{p.title}
</button>
</td>
<td>{p.user?.nickname ?? '—'}</td>
<td>{p.pinned ? <Badge variant="orange">是</Badge> : '—'}</td>
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
</tr>
))}
</tbody>
</table>
{data.recent_posts.length === 0 && <div className="admin-empty">暂无帖子</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,163 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { PostItem } from '../../api/types';
export default function AdminPostsPage() {
const nav = useNavigate();
const { ready } = useAdminGuard();
const [posts, setPosts] = useState<PostItem[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [keyword, setKeyword] = useState('');
const [search, setSearch] = useState('');
const load = (p = page, kw = search) => {
setLoading(true);
api.adminPosts({ page: p, keyword: kw })
.then(d => {
setPosts(d.posts ?? []);
setPage(d.page);
setTotalPages(d.total_pages);
})
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
};
useEffect(() => {
if (ready) load(1, search);
}, [ready, search]);
const togglePin = async (post: PostItem) => {
try {
const r = await api.adminPinPost(post.id, !post.pinned);
notify.success(r.message);
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const remove = async (id: number) => {
try {
await api.adminDeletePost(id);
notify.success('帖子已删除');
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '删除失败');
}
};
if (!ready) return null;
return (
<div className="admin-page">
<div className="admin-page-head">
<h1>帖子管理</h1>
<p>置顶、删除帖子,搜索标题关键词</p>
</div>
<form
className="admin-search-bar"
onSubmit={e => { e.preventDefault(); setSearch(keyword.trim()); }}
>
<Input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="搜索帖子标题…"
/>
<Button type="submit"><Search size={16} />搜索</Button>
{search && (
<Button type="button" variant="outline" onClick={() => { setKeyword(''); setSearch(''); }}>
清除
</Button>
)}
</form>
<div className="admin-card">
{loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : (
<>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>板块</th>
<th>作者</th>
<th>置顶</th>
<th>点赞</th>
<th>浏览</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{posts.map(p => (
<tr key={p.id}>
<td>{p.id}</td>
<td className="max-w-[220px] truncate">
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${p.id}`)}>
{p.title}
</button>
</td>
<td>{p.board?.name ?? '—'}</td>
<td>{p.user?.nickname ?? '—'}</td>
<td>{p.pinned ? <Badge variant="orange">是</Badge> : '—'}</td>
<td>{p.like_count}</td>
<td>{p.view_count}</td>
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
<td>
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
{p.pinned ? '取消置顶' : '置顶'}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost" className="text-destructive">删除</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确定删除该帖子?</AlertDialogTitle>
<AlertDialogDescription>相关评论也将一并删除,不可恢复。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => remove(p.id)}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</td>
</tr>
))}
</tbody>
</table>
{posts.length === 0 && <div className="admin-empty">没有找到帖子</div>}
{totalPages > 1 && (
<div className="admin-pagination">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>上一页</Button>
<span>第 {page} / {totalPages} 页</span>
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>下一页</Button>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { Database } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { AdminSettings } from '../../api/types';
export default function AdminSettingsPage() {
const { ready } = useAdminGuard();
const [settings, setSettings] = useState<AdminSettings | null>(null);
const [loading, setLoading] = useState(true);
const [backing, setBacking] = useState(false);
useEffect(() => {
if (!ready) return;
api.adminSettings()
.then(setSettings)
.finally(() => setLoading(false));
}, [ready]);
const handleBackup = async () => {
setBacking(true);
try {
const r = await api.adminBackup();
notify.success(r.message);
window.location.href = r.download;
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '备份失败');
} finally {
setBacking(false);
}
};
if (!ready || loading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!settings) return null;
return (
<div className="admin-page">
<div className="admin-page-head">
<h1>系统设置</h1>
<p>数据目录、敏感词配置与数据库备份</p>
</div>
<div className="admin-card">
<div className="admin-card-head">运行信息</div>
<dl className="admin-dl">
<dt>数据目录</dt><dd><code>{settings.data_dir}</code></dd>
<dt>数据库路径</dt><dd><code>{settings.db_path}</code></dd>
<dt>敏感词配置</dt><dd><code>{settings.filter_path}</code></dd>
<dt>监听端口</dt><dd>{settings.port}</dd>
</dl>
</div>
<div className="admin-card">
<div className="admin-card-head">数据备份</div>
<p className="admin-card-desc">
导出当前 SQLite 数据库副本,文件名格式为 <code>jiang13_backup_YYYYMMDD_HHMMSS.db</code>
</p>
<Button onClick={handleBackup} loading={backing}>
<Database size={16} />
立即备份并下载
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { User } from '../../api/types';
export default function AdminUsersPage() {
const { ready } = useAdminGuard();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const load = (p = page) => {
setLoading(true);
api.adminUsers(p)
.then(d => {
setUsers(d.users ?? []);
setPage(d.page);
setTotalPages(d.total_pages);
})
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
};
useEffect(() => {
if (ready) load(1);
}, [ready]);
const toggleBan = async (user: User) => {
if (user.role === 'admin') {
notify.warning('不能禁言管理员');
return;
}
try {
const r = await api.adminBanUser(user.id, !user.banned);
notify.success(r.message);
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
if (!ready) return null;
return (
<div className="admin-page">
<div className="admin-page-head">
<h1>用户管理</h1>
<p>查看注册用户,禁言或解除禁言</p>
</div>
<div className="admin-card">
{loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : (
<>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>昵称</th>
<th>角色</th>
<th>状态</th>
<th>注册时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td>{u.id}</td>
<td>{u.username}</td>
<td>{u.nickname}</td>
<td>
{u.role === 'admin'
? <Badge variant="orange">管理员</Badge>
: <Badge variant="secondary">用户</Badge>}
</td>
<td>{u.banned ? <Badge variant="destructive">已禁言</Badge> : '正常'}</td>
<td>{u.created_at ? new Date(u.created_at).toLocaleString('zh-CN') : '—'}</td>
<td>
{u.role !== 'admin' && (
<Button size="sm" variant="outline" onClick={() => toggleBan(u)}>
{u.banned ? '解除禁言' : '禁言'}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
{users.length === 0 && <div className="admin-empty">暂无用户</div>}
{totalPages > 1 && (
<div className="admin-pagination">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>上一页</Button>
<span>第 {page} / {totalPages} 页</span>
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>下一页</Button>
</div>
)}
</>
)}
</div>
</div>
);
}