统一 React 管理后台,修复评论换行与帖子置顶
- /admin/* 全部由 React SPA 渲染,替代旧版 HTML 后台页面 - 新增仪表盘、帖子/评论/用户管理、系统设置与 JSON API - 帖子详情页支持管理员置顶;评论换行显示修复 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import './styles/global.css';
|
||||
import { AuthProvider } from './hooks/useAuth';
|
||||
import { ThemeProvider } from './hooks/useTheme';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import AdminLayout from './layouts/AdminLayout';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import PageLoader from './components/PageLoader';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
@@ -16,6 +17,11 @@ const ComposePage = lazy(() => import('./pages/ComposePage'));
|
||||
const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage'));
|
||||
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
|
||||
const FavoritesPage = lazy(() => import('./pages/FavoritesPage'));
|
||||
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
|
||||
const AdminPostsPage = lazy(() => import('./pages/admin/AdminPostsPage'));
|
||||
const AdminCommentsPage = lazy(() => import('./pages/admin/AdminCommentsPage'));
|
||||
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage'));
|
||||
const AdminSettingsPage = lazy(() => import('./pages/admin/AdminSettingsPage'));
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -26,12 +32,21 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<Suspense fallback={<PageLoader />}><LoginPage /></Suspense>} />
|
||||
<Route path="/register" element={<Suspense fallback={<PageLoader />}><RegisterPage /></Suspense>} />
|
||||
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Suspense fallback={<PageLoader />}><AdminDashboardPage /></Suspense>} />
|
||||
<Route path="boards" element={<Suspense fallback={<PageLoader />}><BoardsManagePage /></Suspense>} />
|
||||
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
|
||||
<Route path="comments" element={<Suspense fallback={<PageLoader />}><AdminCommentsPage /></Suspense>} />
|
||||
<Route path="users" element={<Suspense fallback={<PageLoader />}><AdminUsersPage /></Suspense>} />
|
||||
<Route path="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} />
|
||||
</Route>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/post/:id" element={<PostDetailPage />} />
|
||||
<Route path="/post/:id/edit" element={<ComposePage />} />
|
||||
<Route path="/compose" element={<ComposePage />} />
|
||||
<Route path="/boards" element={<BoardsManagePage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/favorites" element={<FavoritesPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats } from './types';
|
||||
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings } from './types';
|
||||
|
||||
const BASE = '';
|
||||
|
||||
@@ -44,6 +44,38 @@ export const api = {
|
||||
updateBoard: (id: number, body: { name: string; description: string; sort_order: number }) =>
|
||||
request<{ board: Board }>(`/api/admin/boards/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
deleteBoard: (id: number) => request(`/api/admin/boards/${id}`, { method: 'DELETE' }),
|
||||
// 管理后台 API
|
||||
adminDashboard: () => request<AdminDashboard>('/api/admin/dashboard'),
|
||||
adminSettings: () => request<AdminSettings>('/api/admin/settings'),
|
||||
adminPosts: (params: { page?: number; keyword?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.page) q.set('page', String(params.page));
|
||||
if (params.keyword) q.set('keyword', params.keyword);
|
||||
const qs = q.toString();
|
||||
return request<{ posts: PostItem[]; total: number; page: number; total_pages: number }>(
|
||||
`/api/admin/posts${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
adminPinPost: (id: number, pinned: boolean) =>
|
||||
request<{ message: string; pinned: boolean }>(`/api/admin/posts/${id}/pin`, {
|
||||
method: 'POST', body: JSON.stringify({ pinned }),
|
||||
}),
|
||||
adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }),
|
||||
adminComments: (page = 1) =>
|
||||
request<{ comments: Comment[]; total: number; page: number; total_pages: number }>(
|
||||
`/api/admin/comments?page=${page}`,
|
||||
),
|
||||
adminDeleteComment: (id: number) => request(`/api/admin/comments/${id}`, { method: 'DELETE' }),
|
||||
adminUsers: (page = 1) =>
|
||||
request<{ users: User[]; total: number; page: number; total_pages: number }>(
|
||||
`/api/admin/users?page=${page}`,
|
||||
),
|
||||
adminBanUser: (id: number, banned: boolean) =>
|
||||
request<{ message: string; banned: boolean }>(`/api/admin/users/${id}/ban`, {
|
||||
method: 'POST', body: JSON.stringify({ banned }),
|
||||
}),
|
||||
adminBackup: () =>
|
||||
request<{ message: string; filename: string; download: string }>('/api/admin/backup', { method: 'POST' }),
|
||||
updateNickname: (nickname: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('nickname', nickname);
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface User {
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
role: 'user' | 'admin';
|
||||
banned?: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface Board {
|
||||
@@ -50,9 +52,33 @@ export interface Comment {
|
||||
content_hidden?: boolean;
|
||||
created_at: string;
|
||||
user?: User;
|
||||
post?: PostItem;
|
||||
reply_target?: Comment;
|
||||
}
|
||||
|
||||
export interface AdminDashboard {
|
||||
users: number;
|
||||
posts: number;
|
||||
boards: number;
|
||||
comments: number;
|
||||
online: number;
|
||||
recent_posts: PostItem[];
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
filter_path: string;
|
||||
data_dir: string;
|
||||
db_path: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
total: number;
|
||||
page: number;
|
||||
total_pages: number;
|
||||
items: T;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
title: string;
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function BoardGrid({ boards, loading = false, selectedId = 0, onS
|
||||
</p>
|
||||
{user?.role === 'admin' && (
|
||||
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
||||
<Button onClick={() => nav('/boards')}>
|
||||
<Button onClick={() => nav('/admin/boards')}>
|
||||
<Plus />
|
||||
创建板块
|
||||
</Button>
|
||||
|
||||
@@ -8,12 +8,11 @@ interface Props {
|
||||
/** 渲染评论正文(支持正文内 @ 高亮) */
|
||||
export default function CommentContent({ content, onMentionClick }: Props) {
|
||||
return (
|
||||
<div className="floor-body">
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlightMentions(content, onMentionClick),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="floor-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlightMentions(content, onMentionClick),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function FeedHeader({ boardId, keyword, boards, stats, postTotal
|
||||
<Button size="sm" onClick={() => nav('/login')}>登录参与</Button>
|
||||
)}
|
||||
{!authLoading && user?.role === 'admin' && (
|
||||
<Button size="sm" variant="outline" onClick={() => nav('/boards')}>
|
||||
<Button size="sm" variant="outline" onClick={() => nav('/admin/boards')}>
|
||||
<Settings />
|
||||
管理板块
|
||||
</Button>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import {
|
||||
Home, Settings, Star, LayoutDashboard,
|
||||
Home, Star, LayoutDashboard,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import type { Board } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { openAdminDashboard } from '../utils/admin';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
|
||||
@@ -17,7 +16,7 @@ export function isNeutralSidebarRoute(pathname: string): boolean {
|
||||
function resolveMenuKey(pathname: string, activeBoard: number): string | null {
|
||||
if (isNeutralSidebarRoute(pathname)) return null;
|
||||
if (pathname.startsWith('/favorites')) return 'favorites';
|
||||
if (pathname.startsWith('/boards')) return 'boards';
|
||||
if (pathname.startsWith('/admin')) return 'admin';
|
||||
return activeBoard === 0 ? 'all' : String(activeBoard);
|
||||
}
|
||||
|
||||
@@ -77,8 +76,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
<>
|
||||
<div className="sidebar-section" style={{ marginTop: 8 }}>管理</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navItem('boards', '管理板块', <Settings />, () => nav('/boards'))}
|
||||
{navItem('admin', '系统后台', <LayoutDashboard />, openAdminDashboard)}
|
||||
{navItem('admin', '管理后台', <LayoutDashboard />, () => nav('/admin/dashboard'))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
97
frontend/src/layouts/AdminLayout.tsx
Normal file
97
frontend/src/layouts/AdminLayout.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, FolderKanban, FileText, MessageSquare, Users, Settings, ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const NAV = [
|
||||
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
|
||||
{ to: '/admin/boards', label: '板块管理', icon: FolderKanban },
|
||||
{ to: '/admin/posts', label: '帖子管理', icon: FileText },
|
||||
{ to: '/admin/comments', label: '评论管理', icon: MessageSquare },
|
||||
{ to: '/admin/users', label: '用户管理', icon: Users },
|
||||
{ to: '/admin/settings', label: '系统设置', icon: Settings },
|
||||
];
|
||||
|
||||
/** React 管理后台布局,与前台 SPA 风格统一 */
|
||||
export default function AdminLayout() {
|
||||
const { user, loading } = useAuth();
|
||||
const nav = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) {
|
||||
nav('/login');
|
||||
return;
|
||||
}
|
||||
if (user.role !== 'admin') {
|
||||
notify.warning('需要管理员权限');
|
||||
nav('/');
|
||||
}
|
||||
}, [user, loading, nav]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-24"><Spinner size="lg" /></div>;
|
||||
}
|
||||
if (!user || user.role !== 'admin') return null;
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<header className="admin-topbar">
|
||||
<div className="admin-topbar-brand">
|
||||
<div className="admin-topbar-mark">姜</div>
|
||||
<div>
|
||||
<div className="admin-topbar-title">姜十三论坛</div>
|
||||
<div className="admin-topbar-sub">管理后台</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-topbar-actions">
|
||||
<button type="button" className="admin-link-btn" onClick={() => nav('/')}>
|
||||
<ArrowLeft size={16} />
|
||||
返回论坛
|
||||
</button>
|
||||
<span className="admin-topbar-user">{user.nickname}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="admin-body">
|
||||
<aside className="admin-sidebar">
|
||||
{NAV.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) => cn('admin-nav-item', isActive && 'active')}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</aside>
|
||||
<main className="admin-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 管理页通用权限守卫 */
|
||||
export function useAdminGuard() {
|
||||
const { user, loading } = useAuth();
|
||||
const nav = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) nav('/login');
|
||||
else if (user.role !== 'admin') {
|
||||
notify.warning('需要管理员权限');
|
||||
nav('/');
|
||||
}
|
||||
}, [user, loading, nav]);
|
||||
|
||||
return { user, loading, ready: !loading && !!user && user.role === 'admin' };
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { openAdminDashboard } from '../utils/admin';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useTheme, useMediaQuery } from '../hooks/useTheme';
|
||||
import { api } from '../api/client';
|
||||
@@ -162,8 +161,7 @@ export default function MainLayout() {
|
||||
{user.role === 'admin' && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => nav('/boards')}>管理板块</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={openAdminDashboard}>系统后台</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => nav('/admin/dashboard')}>管理后台</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
125
frontend/src/pages/admin/AdminCommentsPage.tsx
Normal file
125
frontend/src/pages/admin/AdminCommentsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
frontend/src/pages/admin/AdminDashboardPage.tsx
Normal file
88
frontend/src/pages/admin/AdminDashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
frontend/src/pages/admin/AdminPostsPage.tsx
Normal file
163
frontend/src/pages/admin/AdminPostsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
frontend/src/pages/admin/AdminSettingsPage.tsx
Normal file
70
frontend/src/pages/admin/AdminSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
frontend/src/pages/admin/AdminUsersPage.tsx
Normal file
110
frontend/src/pages/admin/AdminUsersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1659,7 +1659,6 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.waline-comment-bubble .quote-block {
|
||||
margin: 6px 0;
|
||||
@@ -2369,3 +2368,76 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
||||
0%, 100% { opacity: 0.35; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ========== React 管理后台 ========== */
|
||||
.admin-shell { min-height: 100vh; background: hsl(var(--background)); }
|
||||
.admin-topbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
height: 56px; padding: 0 20px; border-bottom: 1px solid var(--j13-border);
|
||||
background: hsl(var(--card));
|
||||
}
|
||||
.admin-topbar-brand { display: flex; align-items: center; gap: 10px; }
|
||||
.admin-topbar-mark {
|
||||
width: 32px; height: 32px; border-radius: 8px; background: var(--j13-green);
|
||||
color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700;
|
||||
}
|
||||
.admin-topbar-title { font-size: 14px; font-weight: 600; line-height: 1.2; }
|
||||
.admin-topbar-sub { font-size: 12px; color: hsl(var(--muted-foreground)); }
|
||||
.admin-topbar-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.admin-topbar-user { font-size: 13px; color: hsl(var(--muted-foreground)); }
|
||||
.admin-link-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px; font-size: 13px;
|
||||
color: var(--j13-green); background: none; border: none; cursor: pointer;
|
||||
}
|
||||
.admin-body { display: flex; min-height: calc(100vh - 56px); }
|
||||
.admin-sidebar {
|
||||
width: 200px; flex-shrink: 0; padding: 16px 10px; border-right: 1px solid var(--j13-border);
|
||||
background: hsl(var(--card));
|
||||
}
|
||||
.admin-nav-item {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||
padding: 9px 12px; margin-bottom: 2px; border-radius: 8px; font-size: 13px;
|
||||
color: hsl(var(--muted-foreground)); text-decoration: none; transition: background .15s, color .15s;
|
||||
}
|
||||
.admin-nav-item:hover { background: var(--j13-green-bg); color: var(--j13-green); }
|
||||
.admin-nav-item.active { background: var(--j13-green-bg); color: var(--j13-green); font-weight: 600; }
|
||||
.admin-main { flex: 1; padding: 24px; overflow: auto; }
|
||||
.admin-page-head { margin-bottom: 20px; }
|
||||
.admin-page-head h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
|
||||
.admin-page-head p { font-size: 13px; color: hsl(var(--muted-foreground)); }
|
||||
.admin-stat-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px; margin-bottom: 20px;
|
||||
}
|
||||
.admin-stat-card {
|
||||
padding: 16px; border-radius: 10px; border: 1px solid var(--j13-border); background: hsl(var(--card));
|
||||
}
|
||||
.admin-stat-value { font-size: 24px; font-weight: 700; color: var(--j13-green); }
|
||||
.admin-stat-label { font-size: 12px; color: hsl(var(--muted-foreground)); margin-top: 4px; }
|
||||
.admin-card {
|
||||
border: 1px solid var(--j13-border); border-radius: 10px; background: hsl(var(--card));
|
||||
overflow: hidden; margin-bottom: 16px;
|
||||
}
|
||||
.admin-card-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 16px; border-bottom: 1px solid var(--j13-border); font-weight: 600; font-size: 14px;
|
||||
}
|
||||
.admin-card-desc { padding: 12px 16px 0; font-size: 13px; color: hsl(var(--muted-foreground)); }
|
||||
.admin-card .admin-card-desc + button { margin: 12px 16px 16px; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.admin-table th, .admin-table td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--j13-border); }
|
||||
.admin-table th { font-weight: 600; color: hsl(var(--muted-foreground)); background: hsl(var(--muted) / 0.3); }
|
||||
.admin-table tr:last-child td { border-bottom: none; }
|
||||
.admin-empty { padding: 32px; text-align: center; color: hsl(var(--muted-foreground)); font-size: 13px; }
|
||||
.admin-text-link {
|
||||
background: none; border: none; padding: 0; color: var(--j13-green); cursor: pointer; font-size: inherit;
|
||||
}
|
||||
.admin-text-link:hover { text-decoration: underline; }
|
||||
.admin-search-bar { display: flex; gap: 8px; margin-bottom: 16px; max-width: 480px; }
|
||||
.admin-pagination {
|
||||
display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||
padding: 12px; border-top: 1px solid var(--j13-border); font-size: 13px;
|
||||
}
|
||||
.admin-dl { padding: 16px; display: grid; grid-template-columns: 120px 1fr; gap: 10px 16px; font-size: 13px; }
|
||||
.admin-dl dt { color: hsl(var(--muted-foreground)); }
|
||||
.admin-dl dd code { font-size: 12px; background: hsl(var(--muted)); padding: 2px 6px; border-radius: 4px; }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/** 服务端管理后台地址(非 SPA 路由) */
|
||||
/** 管理后台入口(React SPA 内路由) */
|
||||
export const ADMIN_DASHBOARD_URL = '/admin/dashboard';
|
||||
|
||||
/** 跳转到系统管理后台 */
|
||||
export function openAdminDashboard() {
|
||||
window.location.href = ADMIN_DASHBOARD_URL;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
/** @用户名 高亮(仅用于评论正文中用户主动输入的 @) */
|
||||
export function highlightMentions(text: string, _onClick?: (name: string) => void): string {
|
||||
/** 转义 HTML 并保留换行 */
|
||||
function escapeWithBreaks(text: string): string {
|
||||
return text
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// innerHTML 解析会吞掉换行,需转为 <br>
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
/** @用户名 高亮(仅用于评论正文中用户主动输入的 @) */
|
||||
export function highlightMentions(text: string, _onClick?: (name: string) => void): string {
|
||||
return escapeWithBreaks(text)
|
||||
.replace(/@([\w\u4e00-\u9fa5_-]+)/g, '<span class="mention">@$1</span>');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user