first commit

This commit is contained in:
LeonspaceX
2025-10-18 17:34:11 +08:00
commit cb0fd04f59
43 changed files with 10922 additions and 0 deletions

27
src/App.css Normal file
View File

@@ -0,0 +1,27 @@
/* 滚动条样式 */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* 深色模式下的滚动条 */
.dark ::-webkit-scrollbar-thumb {
background: #4a4a4a;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #3a3a3a;
}

125
src/App.tsx Normal file
View File

@@ -0,0 +1,125 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-components';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import PostCard from './components/PostCard';
import MainLayout from './layouts/MainLayout';
import './App.css';
import { fetchArticles } from './api';
import CreatePost from './components/CreatePost';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import AboutPage from './components/AboutPage';
import PostState from './components/PostState';
import ReportState from './components/ReportState';
import AdminPage from './components/AdminPage';
import InitPage from './pages/InitPage';
import NotFound from './pages/NotFound';
function App() {
const [isDarkMode, setIsDarkMode] = React.useState(false);
const [articles, setArticles] = useState<Array<{
id: number;
content: string;
upvotes: number;
downvotes: number;
}>>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observer = useRef<IntersectionObserver>(null);
const lastArticleRef = useCallback((node: HTMLDivElement) => {
if (loading) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore) {
setPage(prevPage => prevPage + 1);
}
});
if (node) observer.current.observe(node);
}, [loading, hasMore]);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
const loadArticles = async () => {
if (!hasMore) return;
setLoading(true);
try {
const newArticles = await fetchArticles(page, signal);
if (newArticles.length === 0) {
setHasMore(false);
} else {
setArticles(prev => [...prev, ...newArticles]);
}
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Failed to load articles:', error);
}
} finally {
setLoading(false);
}
};
loadArticles();
return () => {
controller.abort();
};
}, [page, hasMore]);
return (
<FluentProvider theme={isDarkMode ? webDarkTheme : webLightTheme}>
<BrowserRouter>
<Routes>
<Route path="/" element={<MainLayout isDarkMode={isDarkMode} onToggleTheme={() => setIsDarkMode(!isDarkMode)} />}>
<Route
index
element={
<div style={{ width: '100%', height: 'calc(100vh - 64px)', overflowY: 'auto', padding: '20px' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', minHeight: '100%' }}>
{articles.map((article, index) => {
if (articles.length === index + 1 && hasMore) {
return (
<div ref={lastArticleRef} key={article.id}>
<PostCard
id={article.id}
content={article.content}
upvotes={article.upvotes}
downvotes={article.downvotes}
/>
</div>
);
} else {
return (
<PostCard
key={article.id}
id={article.id}
content={article.content}
upvotes={article.upvotes}
downvotes={article.downvotes}
/>
);
}
})}
{loading && <div>...</div>}
</div>
</div>
}
/>
<Route path="create" element={<CreatePost />} />
<Route path="/progress/review" element={<PostState />} />
<Route path="/progress/complaint" element={<ReportState />} />
<Route path="about" element={<AboutPage />} />
</Route>
<Route path="/init" element={<InitPage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
<ToastContainer />
</FluentProvider>
);
}
export default App;

732
src/admin_api.tsx Normal file
View File

@@ -0,0 +1,732 @@
import { API_CONFIG } from './config';
// 管理员认证相关的 API 接口
export interface AdminAuthResponse {
success: boolean;
message?: string;
}
// 管理员密码缓存键
const ADMIN_TOKEN_KEY = 'admin_token';
/**
* 获取存储的管理员令牌
*/
export const getAdminToken = (): string | null => {
return localStorage.getItem(ADMIN_TOKEN_KEY);
};
/**
* 存储管理员令牌
*/
export const setAdminToken = (token: string): void => {
localStorage.setItem(ADMIN_TOKEN_KEY, token);
};
/**
* 清除管理员令牌
*/
export const clearAdminToken = (): void => {
localStorage.removeItem(ADMIN_TOKEN_KEY);
};
/**
* 验证管理员密码
* @param password 管理员密码
* @returns Promise<AdminAuthResponse>
*/
export const verifyAdminPassword = async (password: string): Promise<AdminAuthResponse> => {
try {
const response = await fetch(`${API_CONFIG.BASE_URL}/admin/test`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${password}`,
'Content-Type': 'application/json',
},
});
if (response.status === 401 || response.status === 403) {
return {
success: false,
message: '密码错误,请重新输入'
};
}
if (response.ok) {
// 密码正确,存储到缓存
setAdminToken(password);
return {
success: true,
message: '登录成功'
};
}
// 其他错误状态
return {
success: false,
message: `服务器错误: ${response.status}`
};
} catch (error) {
console.error('Admin authentication error:', error);
return {
success: false,
message: '网络错误,请检查连接'
};
}
};
/**
* 检查管理员是否已登录
*/
export const isAdminLoggedIn = (): boolean => {
return getAdminToken() !== null;
};
/**
* 管理员退出登录
*/
export const adminLogout = (): void => {
clearAdminToken();
};
/**
* 创建带有管理员认证的请求头
*/
export const createAdminHeaders = (): HeadersInit => {
const token = getAdminToken();
return {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` })
};
};
/**
* 通用的管理员 API 请求函数
* @param endpoint API 端点
* @param options 请求选项
*/
export const adminApiRequest = async (
endpoint: string,
options: RequestInit = {}
): Promise<Response> => {
const token = getAdminToken();
if (!token) {
throw new Error('未登录或登录已过期');
}
// 动态处理 Content-Type当 body 是 FormData 时让浏览器自动设置 boundary
const baseHeaders: HeadersInit = createAdminHeaders();
if (typeof FormData !== 'undefined' && options.body instanceof FormData) {
// @ts-ignore
delete baseHeaders['Content-Type'];
}
const response = await fetch(`${API_CONFIG.BASE_URL}/admin${endpoint}`, {
...options,
headers: {
...baseHeaders,
...options.headers,
},
});
// 如果返回 401 或 403说明令牌无效清除缓存
if (response.status === 401 || response.status === 403) {
clearAdminToken();
throw new Error('登录已过期,请重新登录');
}
return response;
};
/**
* 创建备份并返回 ZIP 文件 Blob 与文件名
* GET /admin/get/backup -> ZIP
*/
export const getBackupZip = async (): Promise<{ blob: Blob; filename: string }> => {
const resp = await adminApiRequest('/get/backup', { method: 'GET' });
if (!resp.ok) {
throw new Error(`创建备份失败: ${resp.status}`);
}
const disposition = resp.headers.get('Content-Disposition') || '';
let filename = 'backup.zip';
const match = disposition.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i);
if (match) {
filename = decodeURIComponent(match[1] || match[2] || filename);
}
const blob = await resp.blob();
return { blob, filename };
};
/**
* 恢复备份
* POST /admin/recover (multipart/form-data: backup_file)
*/
export const recoverBackup = async (file: File): Promise<{ status: 'OK' }> => {
const form = new FormData();
// 后端要求的字段名file
form.append('file', file);
const resp = await adminApiRequest('/recover', {
method: 'POST',
body: form,
// 让 adminApiRequest 自动去掉 Content-Type
});
if (!resp.ok) {
let detail = '';
try {
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
} else {
detail = await resp.text();
}
} catch {}
throw new Error(`恢复备份失败: ${resp.status}${detail ? ` - ${detail}` : ''}`);
}
return resp.json();
};
/**
* 获取当前帖子审核模式
* GET /admin/get/need_audit -> { status: boolean }
*/
export const getAuditMode = async (): Promise<{ status: boolean }> => {
const resp = await adminApiRequest('/get/need_audit', {
method: 'GET',
});
if (!resp.ok) {
throw new Error(`获取审核模式失败: ${resp.status}`);
}
return resp.json();
};
/**
* 切换帖子审核模式
* POST /admin/need_audit { need_audit: boolean }
*/
export const setAuditMode = async (need_audit: boolean): Promise<{ status: 'OK' }> => {
const resp = await adminApiRequest('/need_audit', {
method: 'POST',
body: JSON.stringify({ need_audit }),
});
if (!resp.ok) {
throw new Error(`切换审核模式失败: ${resp.status}`);
}
return resp.json();
};
/**
* 图片链接项
*/
export interface PicLink {
filename: string;
url: string;
upload_time: string;
}
/**
* 获取图片链接列表
* GET /admin/get/pic_links?page=1 -> PicLink[]
*/
export const getPicLinks = async (page: number = 1): Promise<PicLink[]> => {
const resp = await adminApiRequest(`/get/pic_links?page=${encodeURIComponent(page)}`, {
method: 'GET',
});
if (!resp.ok) {
throw new Error(`获取图片链接失败: ${resp.status}`);
}
const data = await resp.json();
// 兼容字符串数组(如 ["/img/251012_xxx.png", ...])与对象数组的返回
return (Array.isArray(data) ? data : []).map((item: any) => {
// 字符串项:直接视为图片相对或绝对 URL
if (typeof item === 'string') {
const raw = item.trim();
const isAbsolute = /^https?:\/\//i.test(raw);
const path = isAbsolute ? raw : (raw.startsWith('/') ? raw : `/${raw}`);
const url = isAbsolute ? raw : `${API_CONFIG.BASE_URL}${path}`;
// 从路径派生 filename
let filename = '';
if (raw.startsWith('/img/')) {
filename = raw.slice('/img/'.length);
} else {
const idx = raw.lastIndexOf('/');
filename = idx >= 0 ? raw.slice(idx + 1) : raw;
}
try { filename = decodeURIComponent(filename); } catch {}
return { filename, url, upload_time: '' } as PicLink;
}
// 对象项:使用字段并进行回退与绝对化
const filename = String(item?.filename || '');
const upload_time = String(item?.upload_time || '');
const urlRaw = item?.url;
let url = typeof urlRaw === 'string' ? urlRaw.trim() : '';
if (!url && filename) {
url = `/img/${encodeURIComponent(filename)}`;
}
if (url && !/^https?:\/\//i.test(url)) {
const path = url.startsWith('/') ? url : `/${url}`;
url = `${API_CONFIG.BASE_URL}${path}`;
}
return { filename, url, upload_time } as PicLink;
});
};
/**
* 删除图片
* POST /admin/del_pic { filename }
*/
export const deletePic = async (filename: string): Promise<{ status: 'OK' }> => {
if (!filename) {
throw new Error('缺少图片文件名');
}
const resp = await adminApiRequest('/del_pic', {
method: 'POST',
body: JSON.stringify({ filename }),
});
if (!resp.ok) {
let detail = '';
try {
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
} else {
detail = await resp.text();
}
} catch {}
throw new Error(`删除图片失败: ${resp.status}${detail ? ` - ${detail}` : ''}`);
}
return resp.json();
};
/**
* 待处理举报项
*/
export interface PendingReport {
id: number;
submission_id: number;
title: string;
content: string;
status: string;
created_at: string;
}
/**
* 获取待处理举报列表
* GET /admin/get/pending_reports -> PendingReport[]
*/
export const getPendingReports = async (): Promise<PendingReport[]> => {
const resp = await adminApiRequest('/get/pending_reports', { method: 'GET' });
if (!resp.ok) {
throw new Error(`获取待处理举报失败: ${resp.status}`);
}
const data = await resp.json();
return (Array.isArray(data) ? data : []).map((item: any) => ({
id: Number(item?.id ?? 0),
submission_id: Number(item?.submission_id ?? 0),
title: String(item?.title ?? ''),
content: String(item?.content ?? ''),
status: String(item?.status ?? ''),
created_at: String(item?.created_at ?? ''),
}));
};
/**
* 批准举报
* POST /admin/approve_report { id }
*/
export const approveReport = async (id: number): Promise<{ status: 'OK' }> => {
if (!id && id !== 0) {
throw new Error('缺少举报 ID');
}
const resp = await adminApiRequest('/approve_report', {
method: 'POST',
body: JSON.stringify({ id }),
});
if (!resp.ok) {
let detail = '';
try {
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
} else {
detail = await resp.text();
}
} catch {}
throw new Error(`批准举报失败: ${resp.status}${detail ? ` - ${detail}` : ''}`);
}
return resp.json();
};
/**
* 拒绝举报
* POST /admin/reject_report { id }
*/
export const rejectReport = async (id: number): Promise<{ status: 'OK' }> => {
if (!id && id !== 0) {
throw new Error('缺少举报 ID');
}
const resp = await adminApiRequest('/reject_report', {
method: 'POST',
body: JSON.stringify({ id }),
});
if (!resp.ok) {
let detail = '';
try {
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
} else {
detail = await resp.text();
}
} catch {}
throw new Error(`拒绝举报失败: ${resp.status}${detail ? ` - ${detail}` : ''}`);
}
return resp.json();
};
/**
* 获取管理员视角的帖子详情(只需 content 字段)
* GET /admin/get/post_info?id=number -> { content: string, ... }
*/
export interface AdminPostInfo { content: string }
export const getAdminPostInfo = async (id: number): Promise<AdminPostInfo> => {
if (!id && id !== 0) {
throw new Error('缺少帖子 ID');
}
const resp = await adminApiRequest(`/get/post_info?id=${encodeURIComponent(id)}`, { method: 'GET' });
if (!resp.ok) {
let detail = '';
try {
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
} else {
detail = await resp.text();
}
} catch {}
throw new Error(`获取帖子详情失败: ${resp.status}${detail ? ` - ${detail}` : ''}`);
}
const data = await resp.json();
return { content: String(data?.content || '') };
};
/**
* 管理端帖子列表项(用于待审核/已拒绝)
*/
export interface AdminPostListItem {
id: number;
content: string;
create_time: string;
upvotes: number;
downvotes: number;
}
/**
* 获取待审核帖子列表
* GET /admin/get/pending_posts -> AdminPostListItem[]
*/
export const getPendingPosts = async (): Promise<AdminPostListItem[]> => {
const resp = await adminApiRequest('/get/pending_posts', { method: 'GET' });
if (!resp.ok) {
let detail = '';
try {
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
} else {
detail = await resp.text();
}
} catch {}
throw new Error(`获取待审核帖子失败: ${resp.status}${detail ? ` - ${detail}` : ''}`);
}
const data = await resp.json();
return Array.isArray(data) ? data as AdminPostListItem[] : [];
};
/**
* 获取已拒绝帖子列表
* GET /admin/get/reject_posts -> AdminPostListItem[]
*/
export const getRejectedPosts = async (): Promise<AdminPostListItem[]> => {
const resp = await adminApiRequest('/get/reject_posts', { method: 'GET' });
if (!resp.ok) {
let detail = '';
try {
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
} else {
detail = await resp.text();
}
} catch {}
throw new Error(`获取已拒绝帖子失败: ${resp.status}${detail ? ` - ${detail}` : ''}`);
}
const data = await resp.json();
return Array.isArray(data) ? data as AdminPostListItem[] : [];
};
/**
* 审核通过帖子
* POST /admin/approve { id }
*/
export const approvePost = async (id: number): Promise<{ status: 'OK' }> => {
if (!id && id !== 0) {
throw new Error('缺少帖子 ID');
}
const resp = await adminApiRequest('/approve', {
method: 'POST',
body: JSON.stringify({ id }),
});
if (!resp.ok) {
let detail = '';
try {
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
} else {
detail = await resp.text();
}
} catch {}
const msg = resp.status === 401 || resp.status === 403
? '身份验证失败,请重新登陆'
: resp.status === 404
? '帖子不存在'
: resp.status === 400
? '缺少帖子 ID'
: `审核通过失败: ${resp.status}${detail ? ` - ${detail}` : ''}`;
throw new Error(msg);
}
return resp.json();
};
/**
* 拒绝帖子
* POST /admin/disapprove { id }
*/
export const disapprovePost = async (id: number): Promise<{ status: 'OK' }> => {
if (!id && id !== 0) {
throw new Error('缺少帖子 ID');
}
const resp = await adminApiRequest('/disapprove', {
method: 'POST',
body: JSON.stringify({ id }),
});
if (!resp.ok) {
let detail = '';
try {
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
} else {
detail = await resp.text();
}
} catch {}
const msg = resp.status === 401 || resp.status === 403
? '身份验证失败,请重新登陆'
: resp.status === 404
? '帖子不存在'
: resp.status === 400
? '缺少帖子 ID'
: `拒绝帖子失败: ${resp.status}${detail ? ` - ${detail}` : ''}`;
throw new Error(msg);
}
return resp.json();
};
/**
* 重新审核帖子(将已通过设回待审核)
* POST /admin/reaudit { id }
*/
export const reauditPost = async (id: number): Promise<{ status: 'OK' }> => {
if (!id && id !== 0) {
throw new Error('缺少帖子 ID');
}
const resp = await adminApiRequest('/reaudit', {
method: 'POST',
body: JSON.stringify({ id }),
});
if (!resp.ok) {
let detail = '';
try {
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
} else {
detail = await resp.text();
}
} catch {}
const msg = resp.status === 401 || resp.status === 403
? '身份验证失败,请重新登陆'
: resp.status === 404
? '帖子不存在'
: resp.status === 400
? '缺少帖子 ID'
: `重新审核失败: ${resp.status}${detail ? ` - ${detail}` : ''}`;
throw new Error(msg);
}
return resp.json();
};
/**
* 删除帖子
* POST /admin/del_post { id }
*/
export const deletePost = async (id: number): Promise<{ status: 'OK' }> => {
if (!id && id !== 0) {
throw new Error('缺少帖子 ID');
}
const resp = await adminApiRequest('/del_post', {
method: 'POST',
body: JSON.stringify({ id }),
});
if (!resp.ok) {
let detail = '';
try {
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
} else {
detail = await resp.text();
}
} catch {}
const msg = resp.status === 401 || resp.status === 403
? '身份验证失败,请重新登陆'
: resp.status === 404
? '帖子不存在'
: resp.status === 400
? '缺少帖子 ID'
: `删除帖子失败: ${resp.status}${detail ? ` - ${detail}` : ''}`;
throw new Error(msg);
}
return resp.json();
};
/**
* 修改帖子内容
* POST /admin/modify_post { id, content }
*/
export const modifyPost = async (
id: number,
content: string
): Promise<{ status: 'OK' }> => {
if ((!id && id !== 0) || !content) {
throw new Error(!content ? '缺少帖子内容' : '缺少帖子 ID');
}
const resp = await adminApiRequest('/modify_post', {
method: 'POST',
body: JSON.stringify({ id, content }),
});
if (!resp.ok) {
let detail = '';
try {
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
} else {
detail = await resp.text();
}
} catch {}
const msg = resp.status === 401 || resp.status === 403
? '身份验证失败,请重新登陆'
: resp.status === 404
? '帖子不存在'
: resp.status === 400
? '缺少 ID 或 content'
: `修改帖子失败: ${resp.status}${detail ? ` - ${detail}` : ''}`;
throw new Error(msg);
}
return resp.json();
};
/**
* 删除评论
* POST /admin/del_comment { id }
*/
export const deleteComment = async (id: number): Promise<{ status: 'OK' }> => {
if (!id && id !== 0) {
throw new Error('缺少评论 ID');
}
const resp = await adminApiRequest('/del_comment', {
method: 'POST',
body: JSON.stringify({ id }),
});
if (!resp.ok) {
let detail = '';
try {
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
} else {
detail = await resp.text();
}
} catch {}
const msg = resp.status === 401 || resp.status === 403
? '身份验证失败,请重新登陆'
: resp.status === 404
? '评论不存在'
: resp.status === 400
? '缺少评论 ID'
: `删除评论失败: ${resp.status}${detail ? ` - ${detail}` : ''}`;
throw new Error(msg);
}
return resp.json();
};
/**
* 修改评论
* POST /admin/modify_comment { id, content, parent_comment_id, nickname }
*/
export const modifyComment = async (
id: number,
content: string,
parent_comment_id: number,
nickname: string
): Promise<{ status: 'OK' }> => {
const missingId = !id && id !== 0;
const missingParent = parent_comment_id === undefined || parent_comment_id === null || Number.isNaN(parent_comment_id);
if (missingId || !content || !nickname || missingParent) {
throw new Error('缺少必填字段');
}
const resp = await adminApiRequest('/modify_comment', {
method: 'POST',
body: JSON.stringify({ id, content, parent_comment_id, nickname }),
});
if (!resp.ok) {
let detail = '';
try {
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
} else {
detail = await resp.text();
}
} catch {}
const msg = resp.status === 401 || resp.status === 403
? '身份验证失败,请重新登陆'
: resp.status === 404
? '评论或父评论不存在'
: resp.status === 400
? '缺少必填字段'
: `修改评论失败: ${resp.status}${detail ? ` - ${detail}` : ''}`;
throw new Error(msg);
}
return resp.json();
};

234
src/api.ts Normal file
View File

@@ -0,0 +1,234 @@
import { API_CONFIG } from './config';
import { toast } from 'react-hot-toast';
export interface Article {
id: number;
content: string;
upvotes: number;
downvotes: number;
}
export const fetchArticles = async (page: number, signal?: AbortSignal): Promise<Article[]> => {
try {
const response = await fetch(`${API_CONFIG.BASE_URL}/get/10_info?page=${page}`, { signal });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Error fetching articles:', error);
}
throw error;
}
};
export const voteArticle = async (
id: number,
type: 'up' | 'down'
): Promise<void> => {
try {
const response = await fetch(`${API_CONFIG.BASE_URL}/${type}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id }),
});
const data = await response.json();
if (data.status !== 'OK') {
throw new Error(`Vote ${type} failed`);
}
} catch (error) {
toast.error(`点赞${type === 'up' ? '赞' : '踩'}失败`);
throw error;
}
};
interface SubmitPostResponse {
id: string;
status: "Pass" | "Pending" | "Deny";
message?: string;
}
export const submitPost = async (postData: { content: string }): Promise<SubmitPostResponse> => {
try {
const response = await fetch(`${API_CONFIG.BASE_URL}/post`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: postData.content }),
});
if (response.status === 403) {
return { status: 'Deny', message: '投稿中包含违禁词', id: 'null'};
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json() as SubmitPostResponse;
} catch (error) {
console.error('Error submitting post:', error);
throw error;
}
};
export const uploadImage = async (formData: FormData): Promise<{ status: 'OK' | 'Error'; url?: string; message?: string }> => {
try {
const response = await fetch(`${API_CONFIG.BASE_URL}/upload_pic`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.url) {
result.url = `${API_CONFIG.BASE_URL}${result.url}`;
}
return result;
} catch (error) {
console.error('Error uploading image:', error);
throw error;
}
};
interface ReportPostResponse {
id: number;
status: string;
}
export const reportPost = async (reportData: { id: number; title: string; content: string }): Promise<ReportPostResponse> => {
const response = await fetch(`${API_CONFIG.BASE_URL}/report`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(reportData),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return (await response.json()) as ReportPostResponse;
};
export async function getPostState(id: string): Promise<{ status: string }> {
const response = await fetch(`${API_CONFIG.BASE_URL}/get/post_state?id=${id}`);
if (!response.ok) {
throw new Error('Failed to fetch post state');
}
return response.json();
}
export async function getReportState(id: string): Promise<{ status: string }> {
const response = await fetch(`${API_CONFIG.BASE_URL}/get/report_state?id=${id}`);
if (!response.ok) {
throw new Error('Failed to fetch report state');
}
return response.json();
}
export interface Comment {
id: number;
nickname: string;
content: string;
parent_comment_id: number;
}
export const getComments = async (id: string | number): Promise<Comment[]> => {
try {
const response = await fetch(`${API_CONFIG.BASE_URL}/get/comment?id=${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching comments:', error);
throw error;
}
};
export interface PostCommentRequest {
content: string;
submission_id: number;
parent_comment_id: number;
nickname: string;
}
export interface PostCommentResponse {
id: number;
status: string;
}
export const postComment = async (commentData: PostCommentRequest): Promise<PostCommentResponse> => {
try {
const response = await fetch(`${API_CONFIG.BASE_URL}/comment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(commentData),
});
if (response.status === 403) {
throw new Error('评论包含违禁词');
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error posting comment:', error);
throw error;
}
};
// === Backend Initialization ===
export interface InitPayload {
adminToken: string;
uploadFolder: string;
allowedExtensions: string[];
maxFileSize: number;
bannedKeywords?: string[];
}
export const initBackend = async (payload: InitPayload): Promise<{ status: string; reason?: string }> => {
const body = {
ADMIN_TOKEN: payload.adminToken,
UPLOAD_FOLDER: payload.uploadFolder,
ALLOWED_EXTENSIONS: payload.allowedExtensions,
MAX_FILE_SIZE: payload.maxFileSize,
...(payload.bannedKeywords ? { BANNED_KEYWORDS: payload.bannedKeywords } : {}),
};
const response = await fetch(`${API_CONFIG.BASE_URL}/init`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({ status: 'Fail', reason: 'Invalid response' }));
if (response.status === 403) {
throw new Error(data?.reason || '后端已初始化');
}
if (!response.ok) {
throw new Error(data?.reason || `初始化失败,状态码 ${response.status}`);
}
return data as { status: string; reason?: string };
};

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,99 @@
import React, { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkIns from 'remark-ins';
import { toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { makeStyles, tokens } from '@fluentui/react-components';
const useStyles = makeStyles({
markdownContent: {
// Markdown样式优化
'& h1, & h2, & h3, & h4, & h5, & h6': {
marginTop: '1em',
marginBottom: '0.5em',
fontWeight: 'bold',
},
'& p': {
marginTop: '0.5em',
marginBottom: '0.5em',
lineHeight: '1.6',
},
'& ul, & ol': {
marginTop: '0.5em',
marginBottom: '0.5em',
paddingLeft: '2em',
},
'& li': {
marginTop: '0.25em',
marginBottom: '0.25em',
},
'& blockquote': {
margin: '1em 0',
paddingLeft: '1em',
borderLeft: `3px solid ${tokens.colorNeutralStroke1}`,
color: tokens.colorNeutralForeground2,
},
'& code': {
backgroundColor: tokens.colorNeutralBackground1,
padding: '2px 4px',
borderRadius: '3px',
fontFamily: 'monospace',
},
'& pre': {
backgroundColor: tokens.colorNeutralBackground1,
padding: '1em',
borderRadius: '5px',
overflowX: 'auto',
marginTop: '1em',
marginBottom: '1em',
},
'& table': {
borderCollapse: 'collapse',
width: '100%',
marginTop: '1em',
marginBottom: '1em',
},
'& th, & td': {
border: `1px solid ${tokens.colorNeutralStroke1}`,
padding: '8px',
textAlign: 'left',
},
'& th': {
backgroundColor: tokens.colorNeutralBackground1,
fontWeight: 'bold',
},
'& ins': {
textDecoration: 'underline',
backgroundColor: 'transparent',
},
},
});
const AboutPage: React.FC = () => {
const [markdown, setMarkdown] = useState('');
const styles = useStyles();
useEffect(() => {
fetch('/about.md')
.then(response => {
if (!response.ok) {
throw new Error('找不到about.md请检查文件是否存在。');
}
return response.text();
})
.then(text => setMarkdown(text))
.catch(error => {
console.error('Error fetching about.md:', error);
toast.error(error.message);
});
}, []);
return (
<div className={styles.markdownContent}>
<ReactMarkdown remarkPlugins={[remarkGfm, remarkIns]}>{markdown}</ReactMarkdown>
</div>
);
};
export default AboutPage;

View File

@@ -0,0 +1,886 @@
import React from 'react';
import {
makeStyles,
Button,
Text,
tokens,
Tab,
TabList,
Dialog,
DialogSurface,
DialogBody,
DialogTitle,
DialogContent,
DialogActions,
} from '@fluentui/react-components';
import type { TabValue } from '@fluentui/react-components';
import { getAuditMode, setAuditMode, getBackupZip, recoverBackup, getPicLinks, deletePic, type PicLink, getPendingReports, approveReport, rejectReport, type PendingReport, getAdminPostInfo, getPendingPosts, getRejectedPosts, type AdminPostListItem, approvePost, disapprovePost, reauditPost, deletePost } from '../admin_api';
import { Switch } from '@fluentui/react-components';
import { toast } from 'react-hot-toast';
import {
SignOut24Regular,
WeatherSunny24Regular,
WeatherMoon24Regular
} from '@fluentui/react-icons';
import { adminLogout } from '../admin_api';
import { SITE_TITLE } from '../config';
import icon from '/icon.png';
import AdminPostCard from './AdminPostCard';
import AdminModifyPost from './AdminModifyPost';
import AdminManageComments from './AdminManageComments';
import { fetchArticles, type Article } from '../api';
const useStyles = makeStyles({
root: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: tokens.colorNeutralBackground2,
overflow: 'hidden',
height: '100vh',
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: '30px',
backgroundColor: tokens.colorNeutralBackground1,
boxShadow: tokens.shadow4,
padding: tokens.spacingHorizontalL,
},
title: {
marginLeft: tokens.spacingHorizontalM,
display: 'flex',
alignItems: 'center',
gap: tokens.spacingHorizontalS,
},
icon: {
height: '32px',
width: '32px',
},
themeToggle: {
cursor: 'pointer',
},
content: {
flex: '1 1 auto',
backgroundColor: tokens.colorNeutralBackground2,
overflowY: 'auto',
},
tabs: {
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalL}`,
borderBottom: `1px solid ${tokens.colorNeutralStroke1}`,
backgroundColor: tokens.colorNeutralBackground2,
},
contentPanel: {
padding: tokens.spacingHorizontalL,
},
footer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '50px',
backgroundColor: tokens.colorNeutralBackground1,
borderTop: `1px solid ${tokens.colorNeutralStroke1}`,
},
logoutButton: {
display: 'flex',
alignItems: 'center',
gap: tokens.spacingHorizontalS,
},
modalOverlay: {
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(5px)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
},
});
interface AdminDashboardProps {
onLogout: () => void;
isDarkMode?: boolean;
onToggleTheme?: () => void;
}
const AdminDashboard: React.FC<AdminDashboardProps> = ({
onLogout,
isDarkMode = false,
onToggleTheme
}) => {
const styles = useStyles();
const [activeTab, setActiveTab] = React.useState<TabValue>('systemSettings');
const [postReviewSubTab, setPostReviewSubTab] = React.useState<TabValue>('pending');
const [needAudit, setNeedAudit] = React.useState<boolean | null>(null);
const [loadingAudit, setLoadingAudit] = React.useState<boolean>(false);
const [recovering, setRecovering] = React.useState<boolean>(false);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [selectedBackupFile, setSelectedBackupFile] = React.useState<File | null>(null);
const [confirmOpen, setConfirmOpen] = React.useState<boolean>(false);
// 图片管理状态
const [picPage, setPicPage] = React.useState<number>(1);
const [picLoading, setPicLoading] = React.useState<boolean>(false);
const [picList, setPicList] = React.useState<PicLink[]>([]);
const [deleteConfirm, setDeleteConfirm] = React.useState<{ open: boolean; filename?: string }>({ open: false });
// 举报管理状态
const [reportsLoading, setReportsLoading] = React.useState<boolean>(false);
const [pendingReports, setPendingReports] = React.useState<PendingReport[]>([]);
const [postContents, setPostContents] = React.useState<Record<number, string>>({});
// 投稿审核数据状态
const [approvedArticles, setApprovedArticles] = React.useState<Article[]>([]);
const [approvedLoading, setApprovedLoading] = React.useState<boolean>(false);
const [approvedPage, setApprovedPage] = React.useState<number>(1);
const [approvedHasMore, setApprovedHasMore] = React.useState<boolean>(true);
const approvedObserver = React.useRef<IntersectionObserver | null>(null);
const lastApprovedRef = React.useCallback((node: HTMLDivElement | null) => {
if (approvedLoading) return;
if (approvedObserver.current) approvedObserver.current.disconnect();
approvedObserver.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && approvedHasMore) {
setApprovedPage(prev => prev + 1);
}
});
if (node) approvedObserver.current.observe(node);
}, [approvedLoading, approvedHasMore]);
const [pendingPosts, setPendingPosts] = React.useState<AdminPostListItem[]>([]);
const [pendingPostsLoading, setPendingPostsLoading] = React.useState<boolean>(false);
const [rejectedPosts, setRejectedPosts] = React.useState<AdminPostListItem[]>([]);
const [rejectedPostsLoading, setRejectedPostsLoading] = React.useState<boolean>(false);
// 帖子删除二次确认
const [deletePostConfirm, setDeletePostConfirm] = React.useState<{ open: boolean; id?: number; list?: 'approved' | 'pending' | 'rejected' }>({ open: false });
// 修改帖子弹窗
const [modifyPostModal, setModifyPostModal] = React.useState<{ open: boolean; id?: number; initialContent?: string; list?: 'approved' | 'pending' }>({ open: false });
// 评论管理弹窗
const [manageCommentsModal, setManageCommentsModal] = React.useState<{ open: boolean; id?: number }>({ open: false });
React.useEffect(() => {
if (activeTab === 'systemSettings') {
setLoadingAudit(true);
getAuditMode()
.then(data => {
setNeedAudit(!!data.status);
})
.catch((err: any) => {
console.error(err);
const msg = String(err?.message || '');
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
toast.error('身份验证失败,请重新登陆');
} else {
toast.error('获取审核模式失败');
}
})
.finally(() => setLoadingAudit(false));
} else if (activeTab === 'imageManage') {
setPicLoading(true);
getPicLinks(picPage)
.then(list => setPicList(list))
.catch((err: any) => {
console.error(err);
const msg = String(err?.message || '获取图片链接失败');
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
toast.error('身份验证失败,请重新登陆');
} else {
toast.error('获取图片链接失败');
}
})
.finally(() => setPicLoading(false));
} else if (activeTab === 'complaintReview') {
setReportsLoading(true);
getPendingReports()
.then(list => setPendingReports(list))
.catch((err: any) => {
console.error(err);
const msg = String(err?.message || '获取待处理举报失败');
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
toast.error('身份验证失败,请重新登陆');
} else {
toast.error('获取待处理举报失败');
}
})
.finally(() => setReportsLoading(false));
}
}, [activeTab, picPage]);
// 当待处理举报列表更新时,基于 submission_id 拉取帖子内容
React.useEffect(() => {
if (activeTab !== 'complaintReview') return;
const ids = Array.from(new Set(pendingReports.map(r => r.submission_id).filter(id => typeof id === 'number' && id > 0)));
if (ids.length === 0) return;
const needFetch = ids.filter(id => !(id in postContents));
if (needFetch.length === 0) return;
Promise.all(needFetch.map(id =>
getAdminPostInfo(id)
.then(info => ({ id, content: info.content }))
.catch((e: any) => {
const msg = String(e?.message || '获取帖子详情失败');
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
toast.error('身份验证失败,请重新登陆');
} else if (msg.includes('404')) {
toast.error('帖子不存在');
} else if (msg.includes('400')) {
toast.error('缺少帖子 ID');
} else {
toast.error('获取帖子详情失败');
}
return { id, content: '' };
})
)).then(results => {
setPostContents(prev => {
const next = { ...prev };
for (const r of results) {
next[r.id] = r.content;
}
return next;
});
});
}, [activeTab, pendingReports]);
// 进入“已过审”子选项卡时重置无限滚动状态
React.useEffect(() => {
if (activeTab === 'postReview' && postReviewSubTab === 'approved') {
setApprovedArticles([]);
setApprovedPage(1);
setApprovedHasMore(true);
}
}, [activeTab, postReviewSubTab]);
// 投稿审核:根据子选项卡加载对应列表
React.useEffect(() => {
if (activeTab !== 'postReview') return;
if (postReviewSubTab === 'approved') {
const ac = new AbortController();
const signal = ac.signal;
const loadApproved = async () => {
if (!approvedHasMore) return;
setApprovedLoading(true);
try {
const newArticles = await fetchArticles(approvedPage, signal);
if (newArticles.length === 0) {
setApprovedHasMore(false);
} else {
setApprovedArticles(prev => [...prev, ...newArticles]);
}
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error(err);
toast.error('获取已过审帖子失败');
}
} finally {
setApprovedLoading(false);
}
};
loadApproved();
return () => ac.abort();
} else if (postReviewSubTab === 'pending') {
setPendingPostsLoading(true);
getPendingPosts()
.then(list => setPendingPosts(list))
.catch((err: any) => {
console.error(err);
const msg = String(err?.message || '获取待审核帖子失败');
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
toast.error('身份验证失败,请重新登陆');
} else {
toast.error('获取待审核帖子失败');
}
})
.finally(() => setPendingPostsLoading(false));
} else if (postReviewSubTab === 'rejected') {
setRejectedPostsLoading(true);
getRejectedPosts()
.then(list => setRejectedPosts(list))
.catch((err: any) => {
console.error(err);
const msg = String(err?.message || '获取已拒绝帖子失败');
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
toast.error('身份验证失败,请重新登陆');
} else {
toast.error('获取已拒绝帖子失败');
}
})
.finally(() => setRejectedPostsLoading(false));
}
}, [activeTab, postReviewSubTab, approvedPage]);
// 确认删除帖子
const handleConfirmDeletePost = async () => {
const id = deletePostConfirm.id;
const list = deletePostConfirm.list;
if (!id) {
setDeletePostConfirm({ open: false });
return;
}
try {
await deletePost(id);
toast.success(`已删除帖子 #${id}`);
if (list === 'approved') {
setApprovedArticles(prev => prev.filter(x => x.id !== id));
} else if (list === 'pending') {
setPendingPosts(prev => prev.filter(x => x.id !== id));
} else if (list === 'rejected') {
setRejectedPosts(prev => prev.filter(x => x.id !== id));
}
} catch (e: any) {
const msg = String(e?.message || '删除帖子失败');
toast.error(msg);
} finally {
setDeletePostConfirm({ open: false });
}
};
const handleToggleAudit = async (checked: boolean) => {
try {
await setAuditMode(checked);
setNeedAudit(checked);
toast.success(checked ? '已开启审核模式' : '已关闭审核模式');
} catch (e: any) {
const msg = String(e?.message || '切换审核模式失败');
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
toast.error('身份验证失败,请重新登陆');
} else {
toast.error('切换审核模式失败');
}
}
};
const handleCreateBackup = async () => {
try {
const { blob, filename } = await getBackupZip();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'backup.zip';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success('备份已生成并开始下载');
} catch (e: any) {
const msg = String(e?.message || '创建备份失败');
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
toast.error('身份验证失败,请重新登陆');
} else {
toast.error('创建备份失败');
}
}
};
const handleRecoverBackup = async (file: File | null) => {
if (!file) return;
setRecovering(true);
try {
await recoverBackup(file);
toast.success('恢复成功');
} catch (e: any) {
const msg = String(e?.message || '恢复失败');
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
toast.error('身份验证失败,请重新登陆');
} else if (msg.includes('400')) {
toast.error('请求错误:请检查文件是否为有效 ZIP 备份');
} else {
toast.error('服务器错误或恢复失败');
}
} finally {
setRecovering(false);
if (fileInputRef.current) fileInputRef.current.value = '';
setSelectedBackupFile(null);
}
};
const handleConfirmRecover = () => {
if (!selectedBackupFile) return;
const lower = selectedBackupFile.name.toLowerCase();
if (!lower.endsWith('.zip')) {
toast.error('请选择 ZIP 格式的备份文件');
return;
}
setConfirmOpen(false);
void handleRecoverBackup(selectedBackupFile);
};
const handleLogout = () => {
try {
adminLogout();
toast.success('已退出登录');
onLogout();
} catch (error) {
toast.error('退出登录失败');
console.error('Logout error:', error);
}
};
// 图片删除触发与确认
const requestDeletePic = (filename: string) => {
setDeleteConfirm({ open: true, filename });
};
// 举报审批操作
const handleApproveReport = async (id: number) => {
try {
await approveReport(id);
toast.success('已批准举报并删除违规帖子');
// 刷新列表
setReportsLoading(true);
const list = await getPendingReports();
setPendingReports(list);
} catch (e: any) {
const msg = String(e?.message || '批准举报失败');
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
toast.error('身份验证失败,请重新登陆');
} else if (msg.includes('404')) {
toast.error('举报记录不存在或已处理');
} else if (msg.includes('400')) {
toast.error('缺少举报 ID');
} else {
toast.error('批准举报失败');
}
} finally {
setReportsLoading(false);
}
};
const handleRejectReport = async (id: number) => {
try {
await rejectReport(id);
toast.success('已拒绝举报,帖子保持原状');
// 刷新列表
setReportsLoading(true);
const list = await getPendingReports();
setPendingReports(list);
} catch (e: any) {
const msg = String(e?.message || '拒绝举报失败');
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
toast.error('身份验证失败,请重新登陆');
} else if (msg.includes('404')) {
toast.error('举报记录不存在或已处理');
} else if (msg.includes('400')) {
toast.error('缺少举报 ID');
} else {
toast.error('拒绝举报失败');
}
} finally {
setReportsLoading(false);
}
};
const handleConfirmDeletePic = async () => {
const filename = deleteConfirm.filename;
if (!filename) {
setDeleteConfirm({ open: false });
return;
}
try {
await deletePic(filename);
toast.success('图片已删除');
// 刷新当前页
setPicLoading(true);
const list = await getPicLinks(picPage);
setPicList(list);
} catch (e: any) {
const msg = String(e?.message || '删除图片失败');
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
toast.error('身份验证失败,请重新登陆');
} else if (msg.includes('404')) {
toast.error('图片不存在或已被删除');
} else if (msg.includes('400')) {
toast.error('缺少图片文件名');
} else {
toast.error('删除图片失败');
}
} finally {
setDeleteConfirm({ open: false });
setPicLoading(false);
}
};
return (
<div className={styles.root}>
{/* 顶栏 - 采用 MainLayout 的 Header 样式 */}
<header className={styles.header}>
<Text size={500} weight="semibold" className={styles.title}>
<img src={icon} alt="logo" className={styles.icon} />
{`${SITE_TITLE} | 管理面板`}
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: tokens.spacingHorizontalM }}>
{onToggleTheme && (
<Button
appearance="transparent"
icon={isDarkMode ? <WeatherSunny24Regular /> : <WeatherMoon24Regular />}
onClick={onToggleTheme}
className={styles.themeToggle}
/>
)}
<Button
appearance="subtle"
className={styles.logoutButton}
onClick={handleLogout}
>
<SignOut24Regular />
退
</Button>
</div>
</header>
{/* 主内容区域 - 占据剩余空间 */}
<div className={styles.content}>
{/* 选项卡 */}
<div className={styles.tabs}>
<TabList
selectedValue={activeTab}
onTabSelect={(_, data) => setActiveTab(data.value)}
>
<Tab value="postReview">稿</Tab>
<Tab value="complaintReview"></Tab>
<Tab value="imageManage"></Tab>
<Tab value="systemSettings"></Tab>
</TabList>
</div>
{/* 内容面板 */}
<div className={styles.contentPanel}>
{activeTab === 'systemSettings' ? (
<div>
<Text size={400} weight="semibold"></Text>
<div style={{ marginTop: tokens.spacingVerticalM }}>
<Text size={300}></Text>
<div style={{ marginTop: tokens.spacingVerticalS }}>
<Switch
checked={!!needAudit}
disabled={loadingAudit || needAudit === null}
onChange={(_, data) => handleToggleAudit(!!data.checked)}
/>
<Text size={200} color="subtle" style={{ marginLeft: tokens.spacingHorizontalS }}>
{needAudit ? '开' : '关'}
</Text>
</div>
</div>
{/* 备份 */}
<div style={{ marginTop: tokens.spacingVerticalL }}>
<Text size={300}></Text>
<div style={{ marginTop: tokens.spacingVerticalS }}>
<Button appearance="primary" onClick={handleCreateBackup}></Button>
</div>
</div>
{/* 恢复 */}
<div style={{ marginTop: tokens.spacingVerticalL }}>
<Text size={300}></Text>
<div style={{ marginTop: tokens.spacingVerticalS, display: 'flex', gap: tokens.spacingHorizontalM, alignItems: 'center', flexWrap: 'wrap' }}>
<input
ref={fileInputRef}
type="file"
accept=".zip"
style={{ display: 'inline-block' }}
onChange={(e) => setSelectedBackupFile(e.target.files?.[0] || null)}
disabled={recovering}
/>
<Button appearance="secondary" onClick={() => setConfirmOpen(true)} disabled={!selectedBackupFile || recovering}>
</Button>
{selectedBackupFile && (
<Text size={200} color="subtle">{selectedBackupFile.name}</Text>
)}
</div>
<Text size={200} color="subtle" style={{ marginTop: tokens.spacingVerticalS, display: 'block' }}>
</Text>
</div>
{/* 确认对话框 */}
<Dialog open={confirmOpen} onOpenChange={(_, data) => setConfirmOpen(!!data.open)}>
<DialogSurface>
<DialogBody>
<DialogTitle></DialogTitle>
<DialogContent>
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={() => setConfirmOpen(false)}></Button>
<Button appearance="primary" onClick={handleConfirmRecover} disabled={!selectedBackupFile || recovering}></Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
</div>
) : activeTab === 'postReview' ? (
<div>
<Text size={400} weight="semibold">稿</Text>
<div style={{ marginTop: tokens.spacingVerticalM }}>
<TabList
selectedValue={postReviewSubTab}
onTabSelect={(_, data) => setPostReviewSubTab(data.value)}
>
<Tab value="approved"></Tab>
<Tab value="pending"></Tab>
<Tab value="rejected"></Tab>
</TabList>
</div>
<div style={{ marginTop: tokens.spacingVerticalM }}>
{postReviewSubTab === 'approved' ? (
approvedLoading && approvedArticles.length === 0 ? (
<Text size={200}>...</Text>
) : approvedArticles.length === 0 ? (
<Text size={200} color="subtle"></Text>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{approvedArticles.map((a, idx) => {
const isLast = approvedArticles.length === idx + 1 && approvedHasMore;
const card = (
<AdminPostCard
key={`approved-${a.id}`}
id={a.id}
content={a.content}
disableApprove
disableReject
onDismiss={async (id) => {
try {
await reauditPost(id);
toast.success(`已重新审核,帖子 #${id} 回到待审核`);
setApprovedArticles(prev => prev.filter(x => x.id !== id));
} catch (e: any) {
const msg = String(e?.message || '重新审核失败');
toast.error(msg);
}
}}
onEdit={(id) => setModifyPostModal({ open: true, id, initialContent: a.content, list: 'approved' })}
onManageComments={(id) => setManageCommentsModal({ open: true, id })}
onDelete={(id) => setDeletePostConfirm({ open: true, id, list: 'approved' })}
/>
);
return isLast ? (
<div ref={lastApprovedRef} key={`approved-wrap-${a.id}`}>{card}</div>
) : card;
})}
{approvedLoading && approvedArticles.length > 0 && (
<Text size={200} color="subtle">...</Text>
)}
</div>
)
) : postReviewSubTab === 'pending' ? (
pendingPostsLoading ? (
<Text size={200}>...</Text>
) : pendingPosts.length === 0 ? (
<Text size={200} color="subtle"></Text>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{pendingPosts.map(p => (
<AdminPostCard
key={`pending-${p.id}`}
id={p.id}
content={p.content}
disableDismiss
onApprove={async (id) => {
try {
await approvePost(id);
toast.success(`已通过帖子 #${id}`);
setPendingPosts(prev => prev.filter(x => x.id !== id));
} catch (e: any) {
const msg = String(e?.message || '审核通过失败');
toast.error(msg);
}
}}
onReject={async (id) => {
try {
await disapprovePost(id);
toast.success(`已拒绝帖子 #${id}`);
setPendingPosts(prev => prev.filter(x => x.id !== id));
} catch (e: any) {
const msg = String(e?.message || '拒绝帖子失败');
toast.error(msg);
}
}}
onEdit={(id) => setModifyPostModal({ open: true, id, initialContent: p.content, list: 'pending' })}
onManageComments={(id) => setManageCommentsModal({ open: true, id })}
onDelete={(id) => setDeletePostConfirm({ open: true, id, list: 'pending' })}
/>
))}
</div>
)
) : (
rejectedPostsLoading ? (
<Text size={200}>...</Text>
) : rejectedPosts.length === 0 ? (
<Text size={200} color="subtle"></Text>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{rejectedPosts.map(p => (
<AdminPostCard
key={`rejected-${p.id}`}
id={p.id}
content={p.content}
disableApprove
disableReject
disableDismiss
disableEdit
disableManageComments
onDelete={(id) => setDeletePostConfirm({ open: true, id, list: 'rejected' })}
/>
))}
</div>
)
)}
</div>
</div>
) : activeTab === 'imageManage' ? (
<div>
<Text size={400} weight="semibold"></Text>
<div style={{ marginTop: tokens.spacingVerticalM }}>
{picLoading ? (
<Text size={200}>...</Text>
) : picList.length === 0 ? (
<Text size={200} color="subtle"></Text>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: tokens.spacingHorizontalM }}>
{picList.map((item, idx) => (
<div key={`${picPage}-${item.filename || item.url || 'unknown'}-${item.upload_time || 'na'}-${idx}`} style={{ border: `1px solid ${tokens.colorNeutralStroke1}`, borderRadius: tokens.borderRadiusMedium, padding: tokens.spacingHorizontalS }}>
{item.url && item.url.trim() !== '' ? (
<img src={item.url} alt={item.filename || '图片'} style={{ width: '100%', height: '140px', objectFit: 'cover', borderRadius: tokens.borderRadiusSmall }} />
) : (
<div style={{ width: '100%', height: '140px', borderRadius: tokens.borderRadiusSmall, backgroundColor: tokens.colorNeutralBackground3, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text size={200} color="subtle"></Text>
</div>
)}
<div style={{ marginTop: tokens.spacingVerticalS, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Text size={200}>{item.filename}</Text>
<Text size={200} color="subtle" style={{ display: 'block' }}>{item.upload_time}</Text>
</div>
<Button appearance="secondary" onClick={() => requestDeletePic(item.filename)}></Button>
</div>
</div>
))}
</div>
)}
</div>
<div style={{ marginTop: tokens.spacingVerticalM, display: 'flex', gap: tokens.spacingHorizontalS }}>
<Button appearance="secondary" disabled={picPage <= 1} onClick={() => setPicPage(p => Math.max(1, p - 1))}></Button>
<Text size={200} color="subtle"> {picPage} </Text>
<Button appearance="secondary" onClick={() => setPicPage(p => p + 1)}></Button>
</div>
{/* 删除确认对话框 */}
<Dialog open={deleteConfirm.open} onOpenChange={(_, data) => setDeleteConfirm({ open: !!data.open, filename: deleteConfirm.filename })}>
<DialogSurface>
<DialogBody>
<DialogTitle></DialogTitle>
<DialogContent>
{deleteConfirm.filename}
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={() => setDeleteConfirm({ open: false })}></Button>
<Button appearance="primary" onClick={handleConfirmDeletePic}></Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
</div>
) : activeTab === 'complaintReview' ? (
<div>
<Text size={400} weight="semibold"></Text>
<div style={{ marginTop: tokens.spacingVerticalM }}>
{reportsLoading ? (
<Text size={200}>...</Text>
) : pendingReports.length === 0 ? (
<Text size={200} color="subtle"></Text>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM }}>
{pendingReports.map((r) => (
<div key={r.id} style={{ border: `1px solid ${tokens.colorNeutralStroke1}`, borderRadius: tokens.borderRadiusMedium, padding: tokens.spacingHorizontalM }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<Text size={300} weight="semibold">{r.title || `举报 #${r.id}`}</Text>
<Text size={200} color="subtle">{r.created_at}</Text>
</div>
<div style={{ marginTop: tokens.spacingVerticalS, whiteSpace: 'pre-wrap' }}>
<Text size={200} weight="semibold"></Text>
<Text size={200} style={{ display: 'block', marginTop: tokens.spacingVerticalS }}>
{postContents[r.submission_id] !== undefined
? (postContents[r.submission_id] || '(帖子内容为空)')
: '加载中...'}
</Text>
</div>
<div style={{ marginTop: tokens.spacingVerticalS, whiteSpace: 'pre-wrap' }}>
<Text size={200} weight="semibold"></Text>
<Text size={200} style={{ display: 'block', marginTop: tokens.spacingVerticalS }}>
{r.content || '(无举报内容)'}
</Text>
</div>
<div style={{ marginTop: tokens.spacingVerticalS, display: 'flex', gap: tokens.spacingHorizontalS }}>
<Button appearance="primary" onClick={() => handleApproveReport(r.id)}></Button>
<Button appearance="secondary" onClick={() => handleRejectReport(r.id)}></Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
) : (
<Text size={300}>TODO: {String(activeTab)}</Text>
)}
{/* 删除帖子二次确认弹窗(放在内容面板末尾,避免打断三元表达式) */}
<Dialog open={deletePostConfirm.open} onOpenChange={(_, data) => setDeletePostConfirm(prev => ({ ...prev, open: !!data.open }))}>
<DialogSurface>
<DialogBody>
<DialogTitle></DialogTitle>
<DialogContent>
#{deletePostConfirm.id}
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={() => setDeletePostConfirm({ open: false })}></Button>
<Button appearance="primary" onClick={handleConfirmDeletePost}></Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
{/* 修改帖子弹窗 */}
{modifyPostModal.open && modifyPostModal.id !== undefined && (
<div className={styles.modalOverlay}>
<AdminModifyPost
postId={modifyPostModal.id}
initialContent={modifyPostModal.initialContent || ''}
onClose={() => setModifyPostModal({ open: false })}
onSubmitSuccess={(newContent) => {
if (modifyPostModal.list === 'approved') {
setApprovedArticles(prev => prev.map(a => a.id === modifyPostModal.id ? { ...a, content: newContent } : a));
} else if (modifyPostModal.list === 'pending') {
setPendingPosts(prev => prev.map(p => p.id === modifyPostModal.id ? { ...p, content: newContent } : p));
}
}}
/>
</div>
)}
{manageCommentsModal.open && manageCommentsModal.id !== undefined && (
<div className={styles.modalOverlay}>
<AdminManageComments
postId={manageCommentsModal.id!}
onClose={() => setManageCommentsModal({ open: false })}
/>
</div>
)}
</div>
</div>
{/* 页脚 - 采用 MainLayout 的 Footer 样式 */}
<footer className={styles.footer}>
<Text size={200} color="subtle">
Powered By Sycamore_Whisper
</Text>
</footer>
</div>
);
};
export default AdminDashboard;

View File

@@ -0,0 +1,179 @@
import React, { useState } from 'react';
import {
makeStyles,
Button,
Input,
Text,
Card,
CardHeader,
CardPreview,
tokens,
Spinner,
Field,
} from '@fluentui/react-components';
import { LockClosed24Regular, Shield24Regular, ShieldLock24Regular} from '@fluentui/react-icons';
import { verifyAdminPassword } from '../admin_api';
import { toast } from 'react-hot-toast';
const useStyles = makeStyles({
container: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
backgroundColor: tokens.colorNeutralBackground1,
padding: tokens.spacingVerticalXL,
},
loginCard: {
width: '400px',
maxWidth: '90vw',
padding: tokens.spacingVerticalXL,
},
cardHeader: {
textAlign: 'center',
marginBottom: tokens.spacingVerticalL,
},
title: {
fontSize: tokens.fontSizeHero700,
fontWeight: tokens.fontWeightSemibold,
color: tokens.colorNeutralForeground1,
marginBottom: tokens.spacingVerticalS,
},
subtitle: {
fontSize: tokens.fontSizeBase300,
color: tokens.colorNeutralForeground2,
},
iconContainer: {
display: 'flex',
justifyContent: 'center',
marginBottom: tokens.spacingVerticalM,
},
icon: {
fontSize: '48px',
color: tokens.colorBrandForeground1,
},
form: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalM,
},
passwordField: {
width: '100%',
},
passwordIconContainer: {
display: 'flex',
justifyContent: 'center',
marginBottom: tokens.spacingVerticalM,
},
passwordIcon: {
fontSize: '48px',
color: tokens.colorBrandForeground1,
},
loginButton: {
width: '100%',
marginTop: tokens.spacingVerticalS,
},
loadingContainer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: tokens.spacingHorizontalS,
},
});
interface AdminLoginProps {
onLoginSuccess: () => void;
}
const AdminLogin: React.FC<AdminLoginProps> = ({ onLoginSuccess }) => {
const styles = useStyles();
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const handleLogin = async () => {
if (!password.trim()) {
toast.error('请输入管理员密码');
return;
}
setLoading(true);
try {
const result = await verifyAdminPassword(password);
if (result.success) {
toast.success(result.message || '登录成功');
onLoginSuccess();
} else {
toast.error(result.message || '登录失败');
setPassword(''); // 清空密码输入
}
} catch (error) {
toast.error('登录过程中发生错误');
console.error('Login error:', error);
} finally {
setLoading(false);
}
};
const handleKeyPress = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' && !loading) {
handleLogin();
}
};
return (
<div className={styles.container}>
<Card className={styles.loginCard}>
<CardHeader className={styles.cardHeader}>
<div className={styles.iconContainer}>
<Shield24Regular className={styles.icon} />
</div>
<Text className={styles.title}></Text>
<Text className={styles.subtitle}>访</Text>
</CardHeader>
<CardPreview>
<div className={styles.form}>
<div className={styles.passwordIconContainer}>
<ShieldLock24Regular className={styles.passwordIcon} />
</div>
<Field
label="管理员密码"
required
className={styles.passwordField}
>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="请输入管理员密码"
disabled={loading}
contentBefore={<LockClosed24Regular />}
/>
</Field>
<Button
appearance="primary"
size="large"
className={styles.loginButton}
onClick={handleLogin}
disabled={loading || !password.trim()}
>
{loading ? (
<div className={styles.loadingContainer}>
<Spinner size="tiny" />
<Text>...</Text>
</div>
) : (
'登录'
)}
</Button>
</div>
</CardPreview>
</Card>
</div>
);
};
export default AdminLogin;

View File

@@ -0,0 +1,259 @@
import React from 'react';
import { makeStyles, shorthands, tokens, Button, Input, Textarea, Dropdown, Option, Card, Text } from '@fluentui/react-components';
import { getComments, type Comment as CommentType } from '../api';
import { deleteComment, modifyComment } from '../admin_api';
import { toast } from 'react-toastify';
const useStyles = makeStyles({
modalContent: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 'min(860px, 96vw)',
backgroundColor: tokens.colorNeutralBackground1,
boxShadow: tokens.shadow64,
...shorthands.borderRadius(tokens.borderRadiusXLarge),
...shorthands.padding(tokens.spacingVerticalL, tokens.spacingHorizontalXL),
zIndex: 1001,
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalM,
maxHeight: '80vh',
},
titleRow: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
},
title: {
fontSize: tokens.fontSizeBase600,
fontWeight: tokens.fontWeightBold,
},
closeButton: {
position: 'absolute',
right: tokens.spacingHorizontalM,
top: tokens.spacingVerticalM,
},
commentsList: {
overflowY: 'auto',
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalM,
...shorthands.padding(tokens.spacingVerticalM),
},
commentCard: {
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusLarge),
...shorthands.border('1px', 'solid', tokens.colorNeutralStroke1),
boxShadow: tokens.shadow8,
marginBottom: tokens.spacingVerticalS,
padding: tokens.spacingHorizontalM,
width: '100%',
},
commentHeader: {
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
color: tokens.colorNeutralForeground1,
},
nickname: {
fontWeight: tokens.fontWeightSemibold,
},
commentMeta: {
color: tokens.colorNeutralForeground3,
fontSize: tokens.fontSizeBase300,
},
childComment: {
marginLeft: tokens.spacingHorizontalL,
borderLeft: `2px solid ${tokens.colorNeutralStroke2}`,
paddingLeft: tokens.spacingHorizontalM,
},
actionsRow: {
display: 'flex',
gap: tokens.spacingHorizontalS,
marginTop: tokens.spacingVerticalS,
},
editor: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalS,
},
fieldRow: {
display: 'flex',
gap: tokens.spacingHorizontalS,
},
fieldControl: {
flex: 1,
},
});
type AdminManageCommentsProps = {
postId: number;
onClose: () => void;
};
const AdminManageComments: React.FC<AdminManageCommentsProps> = ({ postId, onClose }) => {
const styles = useStyles();
const [loading, setLoading] = React.useState(false);
const [comments, setComments] = React.useState<CommentType[]>([]);
const [editingId, setEditingId] = React.useState<number | null>(null);
const [nickname, setNickname] = React.useState('');
const [content, setContent] = React.useState('');
const [parentId, setParentId] = React.useState<number>(0);
const depthMap = React.useMemo(() => {
const map = new Map<number, number>();
const idToParent = new Map<number, number>();
comments.forEach(c => idToParent.set(c.id, (c.parent_comment_id as any) ?? 0));
const calcDepth = (id: number) => {
if (map.has(id)) return map.get(id)!;
let d = 0;
let current = id;
const seen = new Set<number>();
while (true) {
seen.add(current);
const p = idToParent.get(current) ?? 0;
if (p === 0 || !idToParent.has(p) || seen.has(p)) break;
d += 1;
current = p;
}
map.set(id, d);
return d;
};
comments.forEach(c => calcDepth(c.id));
return map;
}, [comments]);
const loadComments = React.useCallback(async () => {
setLoading(true);
try {
const list = await getComments(postId);
setComments(list);
} catch (e: any) {
toast.error(`加载评论失败:${e?.message || e}`);
} finally {
setLoading(false);
}
}, [postId]);
React.useEffect(() => {
loadComments();
}, [loadComments]);
const startEdit = (c: CommentType) => {
setEditingId(c.id);
setNickname(c.nickname || '');
setContent(c.content || '');
setParentId((c.parent_comment_id as any) ?? 0);
};
const cancelEdit = () => {
setEditingId(null);
setNickname('');
setContent('');
setParentId(0);
};
const submitEdit = async () => {
if (editingId === null) return;
if (parentId === editingId) {
toast.error('父评论不能设置为自己');
return;
}
try {
await modifyComment(editingId, content, Number(parentId), nickname);
toast.success('修改评论成功');
setComments(prev => prev.map(c => c.id === editingId ? { ...c, content, nickname, parent_comment_id: Number(parentId) } : c));
cancelEdit();
} catch (e: any) {
toast.error(e?.message || '修改评论失败');
}
};
const handleDelete = async (id: number) => {
if (!id && id !== 0) return;
try {
await deleteComment(id);
toast.success('删除评论成功');
setComments(prev => prev.filter(c => c.id !== id));
} catch (e: any) {
toast.error(e?.message || '删除评论失败');
}
};
// 递归渲染函数工厂(用于显示树形结构)
const renderComments = React.useMemo(() => {
return renderCommentsFactory(comments, styles, startEdit, handleDelete);
}, [comments, styles]);
return (
<div className={styles.modalContent}>
<div className={styles.titleRow}>
<div className={styles.title}></div>
<Button appearance="subtle" className={styles.closeButton} onClick={onClose}></Button>
</div>
<div className={styles.commentMeta}> #{postId}</div>
{editingId !== null && (
<div className={styles.editor}>
<Text size={300} weight="semibold"> #{editingId}</Text>
<div className={styles.fieldRow}>
<Input className={styles.fieldControl} value={nickname} onChange={(_, d) => setNickname(d.value)} placeholder="用户名" />
<Dropdown
className={styles.fieldControl}
selectedOptions={[String(parentId)]}
onOptionSelect={(_, data) => setParentId(Number(data.optionValue))}
>
<Option value={String(0)}></Option>
{comments.filter(c => c.id !== editingId).map(c => {
const depth = depthMap.get(c.id) ?? 0;
const indent = ' '.repeat(Math.max(0, depth * 2));
return (
<Option key={c.id} value={String(c.id)}>{`${indent}#${c.id} - ${c.nickname}`}</Option>
);
})}
</Dropdown>
</div>
<Textarea value={content} onChange={(_, d) => setContent(d.value)} resize={'vertical'} placeholder="评论内容" />
<div className={styles.actionsRow}>
<Button appearance="primary" onClick={submitEdit}></Button>
<Button onClick={cancelEdit}></Button>
</div>
</div>
)}
<div className={styles.commentsList}>
{loading && <div>...</div>}
{!loading && comments.length === 0 && <div></div>}
{!loading && renderComments(0, 0)}
</div>
</div>
);
};
export default AdminManageComments;
function renderCommentsFactory(comments: CommentType[], styles: ReturnType<typeof useStyles>, startEdit: (c: CommentType) => void, handleDelete: (id: number) => void) {
const renderComments = (parentId: number = 0, level: number = 0): React.ReactNode => {
return comments
.filter(comment => (comment.parent_comment_id ?? 0) === parentId)
.map(comment => (
<div key={comment.id} className={level > 0 ? styles.childComment : ''}>
<Card className={styles.commentCard}>
<div className={styles.commentHeader}>
<Text className={styles.nickname}>{comment.nickname}</Text>
<Text size={200} className={styles.commentMeta}>#{comment.id} {comment.parent_comment_id ? `↪ 回复 #${comment.parent_comment_id}` : '· 顶级评论'}</Text>
</div>
<div style={{ whiteSpace: 'pre-wrap' }}>{comment.content}</div>
<div className={styles.actionsRow}>
<Button size="small" onClick={() => startEdit(comment)}></Button>
<Button size="small" appearance="subtle" onClick={() => handleDelete(comment.id)}></Button>
</div>
</Card>
{renderComments(comment.id, level + 1)}
</div>
));
};
return renderComments;
}

View File

@@ -0,0 +1,152 @@
import React from 'react';
import { makeStyles, Button, tokens, Text } from '@fluentui/react-components';
import { Dismiss24Regular } from '@fluentui/react-icons';
import MdEditor from 'react-markdown-editor-lite';
import 'react-markdown-editor-lite/lib/index.css';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkIns from 'remark-ins';
import { toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { uploadImage } from '../api';
import { modifyPost } from '../admin_api';
interface AdminModifyPostProps {
postId: number;
initialContent?: string;
onClose: () => void;
onSubmitSuccess?: (newContent: string) => void;
}
const useStyles = makeStyles({
modalContent: {
backgroundColor: tokens.colorNeutralBackground1,
padding: tokens.spacingHorizontalXXL,
borderRadius: tokens.borderRadiusXLarge,
boxShadow: tokens.shadow64,
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalM,
width: '800px',
maxWidth: '90vw',
position: 'relative',
},
closeButton: {
position: 'absolute',
top: tokens.spacingVerticalS,
right: tokens.spacingHorizontalS,
},
titleRow: {
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: tokens.spacingHorizontalS,
},
title: {
fontSize: tokens.fontSizeBase500,
fontWeight: tokens.fontWeightSemibold,
},
editor: {
border: `1px solid ${tokens.colorNeutralStroke1}`,
borderRadius: tokens.borderRadiusMedium,
overflow: 'hidden',
},
buttonGroup: {
display: 'flex',
justifyContent: 'flex-end',
gap: tokens.spacingHorizontalM,
},
});
const AdminModifyPost: React.FC<AdminModifyPostProps> = ({ postId, initialContent = '', onClose, onSubmitSuccess }) => {
const styles = useStyles();
const [content, setContent] = React.useState<string>(initialContent);
const [isSubmitting, setIsSubmitting] = React.useState<boolean>(false);
const handleImageUpload = async (file: File): Promise<string> => {
if (!['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'].includes(file.type)) {
toast.error('仅支持 .png, .jpg, .jpeg, .gif, .webp 格式的图片');
return '';
}
if (file.size > 10 * 1024 * 1024) {
toast.error('图片大小不能超过 10MB');
return '';
}
try {
const formData = new FormData();
formData.append('file', file);
const response = await uploadImage(formData);
if (response.status === 'OK' && response.url) {
return response.url;
} else {
toast.error('图片上传失败');
return '';
}
} catch (error) {
toast.error('图片上传出错');
console.error(error);
return '';
}
};
const handleEditorChange = ({ text }: { text: string }) => {
setContent(text);
};
const handleSubmit = async () => {
const text = content.trim();
if (!text) {
toast.error('文章内容不能为空');
return;
}
setIsSubmitting(true);
try {
const response = await modifyPost(postId, text);
if (response.status === 'OK') {
toast.success(`修改成功!帖子 #${postId}`);
onSubmitSuccess?.(text);
onClose();
} else {
toast.error('修改失败');
}
} catch (error: any) {
console.error(error);
const msg = String(error?.message || '修改失败,请稍后重试');
toast.error(msg);
} finally {
setIsSubmitting(false);
}
};
return (
<div className={styles.modalContent}>
<Button
icon={<Dismiss24Regular />}
appearance="transparent"
className={styles.closeButton}
onClick={onClose}
/>
<div className={styles.titleRow}>
<h2 className={styles.title}></h2>
<Text size={200} color="subtle"> #{postId}</Text>
</div>
<div className={styles.editor}>
<MdEditor
value={content}
style={{ height: '500px' }}
renderHTML={(text) => <ReactMarkdown remarkPlugins={[remarkGfm, remarkIns]}>{text}</ReactMarkdown>}
onChange={handleEditorChange}
onImageUpload={handleImageUpload}
/>
</div>
<div className={styles.buttonGroup}>
<Button appearance="secondary" onClick={onClose} disabled={isSubmitting}></Button>
<Button appearance="primary" onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? '提交中...' : '提交修改'}
</Button>
</div>
</div>
);
};
export default AdminModifyPost;

View File

@@ -0,0 +1,46 @@
import React, { useState, useEffect } from 'react';
import { isAdminLoggedIn } from '../admin_api';
import AdminLogin from './AdminLogin';
import AdminDashboard from './AdminDashboard';
import { Toaster } from 'react-hot-toast';
const AdminPage: React.FC = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// 检查是否已经登录
const checkLoginStatus = () => {
const loggedIn = isAdminLoggedIn();
setIsLoggedIn(loggedIn);
setIsLoading(false);
};
checkLoginStatus();
}, []);
const handleLoginSuccess = () => {
setIsLoggedIn(true);
};
const handleLogout = () => {
setIsLoggedIn(false);
};
if (isLoading) {
return null;
}
return (
<>
{isLoggedIn ? (
<AdminDashboard onLogout={handleLogout} />
) : (
<AdminLogin onLoginSuccess={handleLoginSuccess} />
)}
<Toaster position="top-center" />
</>
);
};
export default AdminPage;

View File

@@ -0,0 +1,165 @@
import React from 'react';
import {
makeStyles,
Card,
CardFooter,
Button,
tokens,
Text,
} from '@fluentui/react-components';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkIns from 'remark-ins';
import {
Checkmark24Regular,
Dismiss24Regular,
ArrowUndo24Regular,
Edit24Regular,
Comment24Regular,
Delete24Regular,
} from '@fluentui/react-icons';
const useStyles = makeStyles({
card: {
width: '100%',
maxWidth: '800px',
padding: tokens.spacingVerticalL,
marginBottom: tokens.spacingVerticalL,
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
},
content: {
paddingTop: tokens.spacingVerticalS,
paddingBottom: tokens.spacingVerticalS,
'& h1, & h2, & h3, & h4, & h5, & h6': {
marginTop: '1em',
marginBottom: '0.5em',
fontWeight: 'bold',
},
'& p': {
marginTop: '0.5em',
marginBottom: '0.5em',
lineHeight: '1.6',
},
'& ul, & ol': {
marginTop: '0.5em',
marginBottom: '0.5em',
paddingLeft: '2em',
},
'& li': {
marginTop: '0.25em',
marginBottom: '0.25em',
},
'& blockquote': {
margin: '1em 0',
paddingLeft: '1em',
borderLeft: `3px solid ${tokens.colorNeutralStroke1}`,
color: tokens.colorNeutralForeground2,
},
'& code': {
backgroundColor: tokens.colorNeutralBackground1,
padding: '2px 4px',
borderRadius: '3px',
fontFamily: 'monospace',
},
'& pre': {
backgroundColor: tokens.colorNeutralBackground1,
padding: '1em',
borderRadius: '5px',
overflowX: 'auto',
marginTop: '1em',
marginBottom: '1em',
},
'& table': {
borderCollapse: 'collapse',
width: '100%',
marginTop: '1em',
marginBottom: '1em',
},
'& th, & td': {
border: `1px solid ${tokens.colorNeutralStroke1}`,
padding: '8px',
textAlign: 'left',
},
'& th': {
backgroundColor: tokens.colorNeutralBackground1,
fontWeight: 'bold',
},
'& ins': {
textDecoration: 'underline',
backgroundColor: 'transparent',
},
},
actions: {
display: 'grid',
gridTemplateColumns: 'repeat(6, minmax(0, 1fr))',
alignItems: 'center',
justifyItems: 'center',
gap: '0 8px',
},
});
export interface AdminPostCardProps {
id: number;
content: string;
onApprove?: (id: number) => void;
onReject?: (id: number) => void;
onDismiss?: (id: number) => void; // 驳回
onEdit?: (id: number) => void;
onManageComments?: (id: number) => void;
onDelete?: (id: number) => void;
disableApprove?: boolean;
disableReject?: boolean;
disableDismiss?: boolean;
disableEdit?: boolean;
disableManageComments?: boolean;
disableDelete?: boolean;
}
const AdminPostCard: React.FC<AdminPostCardProps> = ({
id,
content,
onApprove,
onReject,
onDismiss,
onEdit,
onManageComments,
onDelete,
disableApprove,
disableReject,
disableDismiss,
disableEdit,
disableManageComments,
disableDelete,
}) => {
const styles = useStyles();
const markdownContent = content;
return (
<Card className={styles.card}>
<div className={styles.header}>
<Text size={300} weight="semibold"> #{id}</Text>
</div>
<div className={styles.content}>
<div style={{ whiteSpace: 'pre-wrap' }}>
<ReactMarkdown remarkPlugins={[remarkGfm, remarkIns]}>{markdownContent}</ReactMarkdown>
</div>
</div>
<CardFooter>
<div className={styles.actions}>
<Button appearance="transparent" icon={<Checkmark24Regular />} onClick={() => onApprove?.(id)} disabled={!!disableApprove} />
<Button appearance="transparent" icon={<Dismiss24Regular />} onClick={() => onReject?.(id)} disabled={!!disableReject} />
<Button appearance="transparent" icon={<ArrowUndo24Regular />} onClick={() => onDismiss?.(id)} disabled={!!disableDismiss} />
<Button appearance="transparent" icon={<Edit24Regular />} onClick={() => onEdit?.(id)} disabled={!!disableEdit} />
<Button appearance="transparent" icon={<Comment24Regular />} onClick={() => onManageComments?.(id)} disabled={!!disableManageComments} />
<Button appearance="transparent" icon={<Delete24Regular />} onClick={() => onDelete?.(id)} disabled={!!disableDelete} />
</div>
</CardFooter>
</Card>
);
};
export default AdminPostCard;

View File

@@ -0,0 +1,233 @@
import React, { useState, useEffect, useRef } from 'react';
import {
makeStyles,
Button,
Input,
Text,
tokens,
Card,
Tooltip,
Divider
} from '@fluentui/react-components';
import { Dismiss24Regular, ArrowReply24Regular } from '@fluentui/react-icons';
import { getComments, postComment } from '../api';
import type { Comment as CommentType } from '../api';
import { toast, Toaster } from 'react-hot-toast';
const useStyles = makeStyles({
container: {
padding: tokens.spacingVerticalM,
width: '100%',
},
commentInput: {
marginBottom: tokens.spacingVerticalS,
width: '100%',
height: '40px',
fontSize: '16px',
},
commentButton: {
marginBottom: tokens.spacingVerticalM,
},
commentList: {
marginTop: tokens.spacingVerticalM,
},
commentCard: {
marginBottom: tokens.spacingVerticalS,
padding: tokens.spacingHorizontalM,
width: '100%',
},
childComment: {
marginLeft: tokens.spacingHorizontalL,
borderLeft: `2px solid ${tokens.colorNeutralStroke1}`,
paddingLeft: tokens.spacingHorizontalM,
},
commentHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: tokens.spacingVerticalXS,
},
nickname: {
fontWeight: 'bold',
},
commentFooter: {
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
marginTop: tokens.spacingVerticalXS,
},
replyButton: {
cursor: 'pointer',
color: tokens.colorBrandForeground1,
display: 'flex',
alignItems: 'center',
gap: tokens.spacingHorizontalXS,
},
replyInfo: {
display: 'flex',
alignItems: 'center',
marginBottom: tokens.spacingVerticalXS,
},
cancelReply: {
marginLeft: tokens.spacingHorizontalS,
cursor: 'pointer',
},
inputContainer: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalS,
},
nicknameInput: {
width: '100%',
height: '40px',
fontSize: '16px',
},
});
interface CommentSectionProps {
postId: number;
}
const CommentSection: React.FC<CommentSectionProps> = ({ postId }) => {
const styles = useStyles();
const [comments, setComments] = useState<CommentType[]>([]);
const [content, setContent] = useState('');
const [nickname, setNickname] = useState('');
const [replyTo, setReplyTo] = useState<CommentType | null>(null);
const [loading, setLoading] = useState(false);
const commentCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
useEffect(() => {
fetchComments();
}, [postId]);
const fetchComments = async () => {
try {
setLoading(true);
const data = await getComments(postId);
setComments(data as CommentType[]);
} catch (error) {
toast.error('获取评论失败');
console.error('Error fetching comments:', error);
} finally {
setLoading(false);
}
};
const handleSubmitComment = async () => {
if (!content.trim() || !nickname.trim()) {
toast.error('评论内容和昵称不能为空');
return;
}
try {
setLoading(true);
await postComment({
submission_id: postId,
nickname,
content,
parent_comment_id: replyTo ? replyTo.id : 0,
});
toast.success('评论成功');
setContent('');
if (replyTo) setReplyTo(null);
fetchComments();
} catch (error: any) {
toast.error('评论失败');
console.error('Error posting comment:', error);
} finally {
setLoading(false);
}
};
const handleReply = (comment: CommentType) => {
setReplyTo(comment);
};
const cancelReply = () => {
setReplyTo(null);
};
const renderComments = (parentId: number = 0, level: number = 0) => {
return comments
.filter(comment => comment.parent_comment_id === parentId)
.map(comment => (
<div
key={comment.id}
className={level > 0 ? styles.childComment : ''}
ref={el => {
if (el) commentCardRefs.current.set(comment.id, el);
}}
>
<Card className={styles.commentCard}>
<div className={styles.commentHeader}>
<Text className={styles.nickname}>{comment.nickname}</Text>
</div>
<Text>{comment.content}</Text>
<div className={styles.commentFooter}>
<Tooltip content="回复" relationship="label">
<div
className={styles.replyButton}
onClick={() => handleReply(comment)}
>
<ArrowReply24Regular />
<Text size={200}></Text>
</div>
</Tooltip>
</div>
</Card>
{renderComments(comment.id, level + 1)}
</div>
));
};
return (
<div className={styles.container}>
<div className={styles.inputContainer}>
<Input
className={styles.nicknameInput}
placeholder="输入昵称"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
/>
{replyTo && (
<div className={styles.replyInfo}>
<Text>{replyTo.nickname}</Text>
<Dismiss24Regular
className={styles.cancelReply}
onClick={cancelReply}
/>
</div>
)}
<Input
className={styles.commentInput}
placeholder="输入评论"
value={content}
onChange={(e) => setContent(e.target.value)}
/>
<Button
className={styles.commentButton}
appearance="primary"
onClick={handleSubmitComment}
disabled={loading || !content.trim() || !nickname.trim()}
>
</Button>
</div>
<Divider />
<div className={styles.commentList}>
{loading && <Text>...</Text>}
{!loading && comments.length === 0 && <Text></Text>}
{renderComments()}
</div>
<Toaster position="top-center" />
</div>
);
};
export default CommentSection;

View File

@@ -0,0 +1,130 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkIns from 'remark-ins';
import MdEditor from 'react-markdown-editor-lite';
import 'react-markdown-editor-lite/lib/index.css';
import { uploadImage, submitPost } from '../api';
import { Button, makeStyles, tokens } from '@fluentui/react-components';
interface CreatePostProps {
onSubmitSuccess?: () => void;
}
const useStyles = makeStyles({
container: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalM,
padding: tokens.spacingHorizontalL,
maxWidth: '800px',
margin: '0 auto',
},
editor: {
border: `1px solid ${tokens.colorNeutralStroke1}`,
borderRadius: tokens.borderRadiusMedium,
overflow: 'hidden',
},
buttonGroup: {
display: 'flex',
justifyContent: 'space-between',
gap: tokens.spacingHorizontalM,
},
});
const CreatePost: React.FC<CreatePostProps> = ({ onSubmitSuccess }) => {
const [content, setContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const styles = useStyles();
useEffect(() => {
const savedDraft = localStorage.getItem('draft');
if (savedDraft) {
setContent(savedDraft);
toast.success('读取草稿成功!');
}
}, []);
const handleSaveDraft = () => {
localStorage.setItem('draft', content);
toast.success('保存成功!');
};
const handleImageUpload = async (file: File): Promise<string> => {
try {
const formData = new FormData();
formData.append('file', file);
const response = await uploadImage(formData);
if (response.status === 'OK' && response.url) {
return response.url;
} else {
toast.error('图片上传失败,文件大小过大或格式不正确!');
return '';
}
} catch (error) {
toast.error('图片上传出错');
console.error(error);
return '';
}
};
const handleEditorChange = ({ text }: { text: string }) => {
setContent(text);
};
const handleSubmit = async () => {
if (!content.trim()) {
toast.error('文章内容不能为空');
return;
}
setIsSubmitting(true);
try {
const response = await submitPost({ content });
if (response.status === 'Pass') {
toast.success(`提交成功id=${response.id}${response.message ? `, ${response.message}` : ''}`);
localStorage.removeItem('draft');
onSubmitSuccess?.();
} else if (response.status === 'Pending') {
toast.info(`等待审核id=${response.id}${response.message ? `, ${response.message}` : ''}`);
localStorage.removeItem('draft');
onSubmitSuccess?.();
} else if (response.status === 'Deny') {
toast.error(response.message || '投稿中包含违禁词');
}
} catch (error) {
toast.error('投稿提交失败,请稍后重试');
console.error(error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className={styles.container}>
<div className={styles.editor}>
<MdEditor
value={content}
style={{ height: '500px' }}
renderHTML={(text) => <ReactMarkdown remarkPlugins={[remarkGfm, remarkIns]}>{text}</ReactMarkdown>}
onChange={handleEditorChange}
onImageUpload={handleImageUpload}
/>
</div>
<div className={styles.buttonGroup}>
<Button appearance="primary" onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? '提交中...' : '提交'}
</Button>
<Button appearance="secondary" onClick={handleSaveDraft}>
稿
</Button>
</div>
</div>
);
};
export default CreatePost;

227
src/components/PostCard.tsx Normal file
View File

@@ -0,0 +1,227 @@
import {
makeStyles,
Card,
CardFooter,
Button,
tokens,
} from '@fluentui/react-components';
import React from 'react';
import { voteArticle } from '../api';
import { toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkIns from 'remark-ins';
import {
ArrowUp24Regular,
ArrowDown24Regular,
Comment24Regular,
Warning24Regular,
} from '@fluentui/react-icons';
import ReportPost from './ReportPost';
import CommentSection from './CommentSection';
const useStyles = makeStyles({
card: {
width: '100%',
maxWidth: '800px',
padding: tokens.spacingVerticalL,
marginBottom: tokens.spacingVerticalL,
},
content: {
paddingTop: tokens.spacingVerticalS,
paddingBottom: tokens.spacingVerticalS,
// Markdown样式优化
'& h1, & h2, & h3, & h4, & h5, & h6': {
marginTop: '1em',
marginBottom: '0.5em',
fontWeight: 'bold',
},
'& p': {
marginTop: '0.5em',
marginBottom: '0.5em',
lineHeight: '1.6',
},
'& ul, & ol': {
marginTop: '0.5em',
marginBottom: '0.5em',
paddingLeft: '2em',
},
'& li': {
marginTop: '0.25em',
marginBottom: '0.25em',
},
'& blockquote': {
margin: '1em 0',
paddingLeft: '1em',
borderLeft: `3px solid ${tokens.colorNeutralStroke1}`,
color: tokens.colorNeutralForeground2,
},
'& code': {
backgroundColor: tokens.colorNeutralBackground1,
padding: '2px 4px',
borderRadius: '3px',
fontFamily: 'monospace',
},
'& pre': {
backgroundColor: tokens.colorNeutralBackground1,
padding: '1em',
borderRadius: '5px',
overflowX: 'auto',
marginTop: '1em',
marginBottom: '1em',
},
'& table': {
borderCollapse: 'collapse',
width: '100%',
marginTop: '1em',
marginBottom: '1em',
},
'& th, & td': {
border: `1px solid ${tokens.colorNeutralStroke1}`,
padding: '8px',
textAlign: 'left',
},
'& th': {
backgroundColor: tokens.colorNeutralBackground1,
fontWeight: 'bold',
},
'& ins': {
textDecoration: 'underline',
backgroundColor: 'transparent',
},
},
actions: {
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
alignItems: 'center',
justifyItems: 'center',
gap: '0 8px',
},
expandButton: {
display: 'flex',
justifyContent: 'flex-end',
},
modalOverlay: {
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(5px)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
},
commentSection: {
marginTop: tokens.spacingVerticalM,
borderTop: `1px solid ${tokens.colorNeutralStroke1}`,
paddingTop: tokens.spacingVerticalM,
},
});
interface PostCardProps {
id: number;
content: string;
upvotes: number;
downvotes: number;
}
const PostCard = ({
id,
content,
upvotes,
downvotes
}: PostCardProps) => {
const styles = useStyles();
const markdownContent = content;
React.useEffect(() => {
setVotes({ upvotes, downvotes });
}, [upvotes, downvotes]);
const [votes, setVotes] = React.useState({ upvotes, downvotes });
const [hasVoted, setHasVoted] = React.useState(false);
const [showReportModal, setShowReportModal] = React.useState(false);
const [showComments, setShowComments] = React.useState(false);
return (
<Card className={styles.card}>
<div className={styles.content}>
<div style={{ whiteSpace: 'pre-wrap' }}>
<ReactMarkdown remarkPlugins={[remarkGfm, remarkIns]}>{markdownContent}</ReactMarkdown>
</div>
</div>
<CardFooter>
<div className={styles.actions}>
<Button
icon={<ArrowUp24Regular />}
appearance="transparent"
onClick={async () => {
if (hasVoted) {
toast.info('你已经点过一次了哦~');
return;
}
try {
await voteArticle(id, 'up');
setVotes(prev => ({ ...prev, upvotes: prev.upvotes + 1 }));
setHasVoted(true);
} catch (error) {
console.error('Failed to upvote:', error);
toast.error('投票失败,请稍后重试');
}
}}
>
{votes.upvotes}
</Button>
<Button
icon={<ArrowDown24Regular />}
appearance="transparent"
onClick={async () => {
if (hasVoted) {
toast.info('你已经点过一次了哦~');
return;
}
try {
await voteArticle(id, 'down');
setVotes(prev => ({ ...prev, downvotes: prev.downvotes + 1 }));
setHasVoted(true);
} catch (error) {
console.error('Failed to downvote:', error);
toast.error('投票失败,请稍后重试');
}
}}
>
{votes.downvotes}
</Button>
<Button
icon={<Comment24Regular />}
appearance='transparent'
onClick={() => setShowComments(!showComments)}
/>
<Button
icon={<Warning24Regular />}
appearance="transparent"
onClick={() => setShowReportModal(true)}
/>
</div>
</CardFooter>
{showComments && (
<div className={styles.commentSection}>
<CommentSection postId={id} />
</div>
)}
{showReportModal && (
<div className={styles.modalOverlay}>
<ReportPost postId={id} onClose={() => setShowReportModal(false)} />
</div>
)}
</Card>
);
};
export default PostCard;

View File

@@ -0,0 +1,73 @@
import React, { useState } from 'react';
import { makeStyles, Button, Input, Text, tokens } from '@fluentui/react-components';
import { getPostState } from '../api';
const useStyles = makeStyles({
container: {
padding: tokens.spacingVerticalXL,
maxWidth: '400px',
margin: '0 auto',
textAlign: 'center',
},
input: {
marginBottom: tokens.spacingVerticalM,
width: '100%',
height: '40px',
fontSize: '16px',
},
button: {
marginBottom: tokens.spacingVerticalM,
display: 'block',
width: '100%',
},
status: {
marginTop: tokens.spacingVerticalM,
fontWeight: 'bold',
},
});
const statusStyles: Record<string, { color: string }> = {
Approved: { color: tokens.colorPaletteGreenForeground1 },
Pending: { color: tokens.colorPaletteYellowForeground1 },
Rejected: { color: tokens.colorPaletteRedForeground1 },
'Deleted or Not Found': { color: tokens.colorNeutralForeground3 },
};
const PostState: React.FC = () => {
const styles = useStyles();
const [id, setId] = useState('');
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const fetchPostState = async () => {
try {
setError(null);
const result = await getPostState(id);
setStatus(result.status);
} catch (err) {
setError('获取投稿状态失败请检查ID是否正确');
}
};
return (
<div className={styles.container}>
<Input
className={styles.input}
placeholder="输入投稿ID"
value={id}
onChange={(e) => setId(e.target.value)}
/>
<Button className={styles.button} appearance="primary" onClick={fetchPostState} disabled={!id}>
</Button>
{status && (
<Text className={styles.status} style={statusStyles[status] || {}}>
稿{status === 'Approved' ? '通过' : status === 'Pending' ? '待审核' : status === 'Rejected' ? '拒绝' : '不存在'}
</Text>
)}
{error && <Text style={{ color: 'red' }}>{error}</Text>}
</div>
);
};
export default PostState;

View File

@@ -0,0 +1,84 @@
import { makeStyles, Button, Input, Textarea, tokens } from '@fluentui/react-components';
import { Dismiss24Regular } from '@fluentui/react-icons';
import React from 'react';
import { reportPost } from '../api';
import { toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
const useStyles = makeStyles({
modalContent: {
backgroundColor: tokens.colorNeutralBackground1,
padding: tokens.spacingHorizontalXXL,
borderRadius: tokens.borderRadiusXLarge,
boxShadow: tokens.shadow64,
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalM,
width: '400px',
position: 'relative',
},
closeButton: {
position: 'absolute',
top: tokens.spacingVerticalS,
right: tokens.spacingHorizontalS,
},
title: {
fontSize: tokens.fontSizeBase500,
fontWeight: tokens.fontWeightSemibold,
marginBottom: tokens.spacingVerticalS,
},
});
interface ReportPostProps {
onClose: () => void;
postId: number;
}
const ReportPost: React.FC<ReportPostProps> = ({ onClose, postId }) => {
const styles = useStyles();
const [title, setTitle] = React.useState('');
const [content, setContent] = React.useState('');
const handleSubmit = async () => {
try {
const response = await reportPost({ id: postId, title, content });
toast.success(`投诉成功id=${response.id}`);
onClose();
} catch (error) {
console.error('Failed to report post:', error);
if (error instanceof Error) {
toast.error(`投诉失败:${error.message}`);
} else {
toast.error('投诉失败,请稍后重试');
}
}
};
return (
<div className={styles.modalContent}>
<Button
icon={<Dismiss24Regular />}
appearance="transparent"
className={styles.closeButton}
onClick={onClose}
/>
<h2 className={styles.title}></h2>
<Input
placeholder="简述投诉类型"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Textarea
placeholder="投诉具体内容"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={5}
/>
<Button appearance="primary" onClick={handleSubmit}>
</Button>
</div>
);
};
export default ReportPost;

View File

@@ -0,0 +1,72 @@
import React, { useState } from 'react';
import { makeStyles, Button, Input, Text, tokens } from '@fluentui/react-components';
import { getReportState } from '../api';
const useStyles = makeStyles({
container: {
padding: tokens.spacingVerticalXL,
maxWidth: '400px',
margin: '0 auto',
textAlign: 'center',
},
input: {
marginBottom: tokens.spacingVerticalM,
width: '100%',
height: '40px',
fontSize: '16px',
},
button: {
marginBottom: tokens.spacingVerticalM,
display: 'block',
width: '100%',
},
status: {
marginTop: tokens.spacingVerticalM,
fontWeight: 'bold',
},
});
const statusStyles: Record<string, { color: string }> = {
Approved: { color: tokens.colorPaletteGreenForeground1 },
Pending: { color: tokens.colorPaletteYellowForeground1 },
Rejected: { color: tokens.colorPaletteRedForeground1 },
};
const ReportState: React.FC = () => {
const styles = useStyles();
const [id, setId] = useState('');
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const fetchReportState = async () => {
try {
setError(null);
const result = await getReportState(id);
setStatus(result.status);
} catch (err) {
setError('获取投诉状态失败请检查ID是否正确');
}
};
return (
<div className={styles.container}>
<Input
className={styles.input}
placeholder="输入投诉ID"
value={id}
onChange={(e) => setId(e.target.value)}
/>
<Button className={styles.button} appearance="primary" onClick={fetchReportState} disabled={!id}>
</Button>
{status && (
<Text className={styles.status} style={statusStyles[status] || {}}>
{status === 'Approved' ? '已通过' : status === 'Pending' ? '待处理' : '已拒绝'}
</Text>
)}
{error && <Text style={{ color: 'red' }}>{error}</Text>}
</div>
);
};
export default ReportState;

View File

@@ -0,0 +1,153 @@
import React, { useState, useEffect } from 'react';
import {
makeStyles,
tokens,
Card,
CardHeader,
CardPreview,
Text,
Spinner,
Badge,
} from '@fluentui/react-components';
import { CheckmarkCircle20Filled, DismissCircle20Filled } from '@fluentui/react-icons';
import API_CONFIG from '../config';
import { useNavigate, useLocation } from 'react-router-dom';
const useStyles = makeStyles({
container: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalM,
},
card: {
width: '100%',
},
statusText: {
display: 'flex',
alignItems: 'center',
gap: tokens.spacingHorizontalS,
},
online: {
color: tokens.colorStatusSuccessForeground1,
},
offline: {
color: tokens.colorStatusDangerForeground1,
},
});
interface StaticsData {
posts: number;
comments: number;
images: number;
}
const StatusDisplay: React.FC = () => {
const styles = useStyles();
const [isApiOnline, setIsApiOnline] = useState<boolean | null>(null);
const [statics, setStatics] = useState<StaticsData | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
const fetchStatus = async () => {
try {
// Check API online status
const teapotResponse = await fetch(`${API_CONFIG.BASE_URL}/test`);
if (teapotResponse.status === 200) {
setIsApiOnline(true);
} else if (teapotResponse.status === 503) {
setIsApiOnline(false);
if (location.pathname !== '/init') {
navigate('/init');
}
} else {
setIsApiOnline(false);
}
// Fetch statics data
const staticsResponse = await fetch(`${API_CONFIG.BASE_URL}/get/statics`);
if (staticsResponse.status === 503) {
if (location.pathname !== '/init') {
navigate('/init');
}
setStatics(null);
} else if (staticsResponse.ok) {
const data: StaticsData = await staticsResponse.json();
setStatics(data);
} else {
setStatics(null);
}
} catch (error) {
console.error('Error fetching API status or statics:', error);
setIsApiOnline(false);
setStatics(null);
} finally {
setLoading(false);
}
};
fetchStatus();
const interval = setInterval(fetchStatus, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, [navigate, location.pathname]);
return (
<div className={styles.container}>
<Card className={styles.card}>
<CardHeader
header={
<Text weight="semibold"></Text>
}
/>
<CardPreview>
{loading ? (
<Spinner size="tiny" label="加载中..." />
) : (
<div style={{ padding: tokens.spacingHorizontalL }}>
<div className={styles.statusText}>
{isApiOnline ? (
<>
<CheckmarkCircle20Filled className={styles.online} />
<Text className={styles.online}>线</Text>
</>
) : (
<>
<DismissCircle20Filled className={styles.offline} />
<Text className={styles.offline}>线</Text>
</>
)}
</div>
</div>
)}
</CardPreview>
</Card>
<Card className={styles.card}>
<CardHeader
header={
<Text weight="semibold"></Text>
}
/>
<CardPreview>
{loading ? (
<Spinner size="tiny" label="加载中..." />
) : statics ? (
<div style={{ padding: tokens.spacingHorizontalL }}>
<Text>稿: <Badge appearance="outline">{statics.posts}</Badge></Text><br />
<Text>: <Badge appearance="outline">{statics.comments}</Badge></Text><br />
<Text>: <Badge appearance="outline">{statics.images}</Badge></Text>
</div>
) : (
<div style={{ padding: tokens.spacingHorizontalL }}>
<Text></Text>
</div>
)}
</CardPreview>
</Card>
</div>
);
};
export default StatusDisplay;

12
src/config.ts Normal file
View File

@@ -0,0 +1,12 @@
// 后端API配置
export const API_CONFIG = {
BASE_URL: 'http://127.0.0.1:5000' // 此处填写API BaseURL如果前端使用https后端最好也使用https
};
export const SITE_TITLE = 'Sycamore_Whisper'; // 此处填写站点标题
export default API_CONFIG;
// 接下来,请修改默认站点图标
// 请将/public/icon.png替换为你自己的图标文件
// 前端初始化完成!恭喜!

8
src/index.css Normal file
View File

@@ -0,0 +1,8 @@
body {
margin: 0;
padding: 0;
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}

124
src/layouts/MainLayout.tsx Normal file
View File

@@ -0,0 +1,124 @@
import { makeStyles, tokens, shorthands } from '@fluentui/react-components';
import { Outlet } from 'react-router-dom';
import Header from './components/Header';
import Sidebar from './components/Sidebar';
import Footer from './components/Footer';
import StatusDisplay from '../components/StatusDisplay';
import { useEffect, useState } from 'react';
const useStyles = makeStyles({
root: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: tokens.colorNeutralBackground2,
overflow: 'hidden',
height: '100vh',
},
container: {
display: 'flex',
flex: '1 1 auto',
overflow: 'hidden',
height: 'calc(100vh - 64px)',
position: 'relative',
},
sidebar: {
width: '240px',
flexShrink: 0,
borderRight: `1px solid ${tokens.colorNeutralStroke1}`,
display: 'flex',
flexDirection: 'column',
backgroundColor: tokens.colorNeutralBackground1,
'@media (max-width: 768px)': {
display: 'none',
},
},
content: {
flex: '1 1 auto',
padding: '20px',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
height: '100%',
width: '100%',
minHeight: 0,
},
rightPanel: {
width: '240px',
flexShrink: 0,
borderLeft: `1px solid ${tokens.colorNeutralStroke1}`,
padding: '20px',
'@media (max-width: 768px)': {
display: 'none',
},
},
mobileOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.35)',
backdropFilter: 'blur(2px)',
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'flex-start',
zIndex: 10,
'@media (min-width: 769px)': {
display: 'none',
},
},
mobileSidebarPanel: {
width: 'min(90vw, 320px)',
backgroundColor: tokens.colorNeutralBackground1,
boxShadow: tokens.shadow28,
...shorthands.borderRadius(tokens.borderRadiusLarge),
...shorthands.padding(tokens.spacingVerticalM, tokens.spacingHorizontalM),
},
});
interface MainLayoutProps {
isDarkMode: boolean;
onToggleTheme: () => void;
}
export const MainLayout = ({ isDarkMode, onToggleTheme }: MainLayoutProps) => {
const styles = useStyles();
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
useEffect(() => {
console.log("QwQ感谢你使用Scyamore_Whisper项目~");
}, []);
return (
<div className={styles.root}>
<Header isDarkMode={isDarkMode} onToggleTheme={onToggleTheme} onToggleSidebar={() => setMobileSidebarOpen((o) => !o)} />
<div className={styles.container}>
<div className={styles.sidebar}>
<Sidebar />
</div>
<main className={styles.content}>
<Outlet />
</main>
<div className={styles.rightPanel}>
<StatusDisplay />
</div>
{mobileSidebarOpen && (
<div className={styles.mobileOverlay} onClick={() => setMobileSidebarOpen(false)}>
<div className={styles.mobileSidebarPanel} onClick={(e) => e.stopPropagation()}>
<Sidebar />
</div>
</div>
)}
</div>
<Footer />
</div>
);
};
export default MainLayout;

View File

@@ -0,0 +1,26 @@
import { makeStyles, Text, tokens } from '@fluentui/react-components';
const useStyles = makeStyles({
footer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '50px',
backgroundColor: tokens.colorNeutralBackground1,
borderTop: `1px solid ${tokens.colorNeutralStroke1}`,
},
});
const Footer = () => {
const styles = useStyles();
return (
<footer className={styles.footer}>
<Text size={200} color="subtle">
Powered By Sycamore_Whisper
</Text>
</footer>
);
};
export default Footer;

View File

@@ -0,0 +1,70 @@
import { makeStyles, Text, tokens, Button } from '@fluentui/react-components';
import { WeatherSunny24Regular, WeatherMoon24Regular } from '@fluentui/react-icons';
import icon from '/icon.png';
import { SITE_TITLE } from '../../config';
const useStyles = makeStyles({
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: '30px',
backgroundColor: tokens.colorNeutralBackground1,
boxShadow: tokens.shadow4,
padding: tokens.spacingHorizontalL,
},
title: {
marginLeft: tokens.spacingHorizontalM,
display: 'flex',
alignItems: 'center',
gap: tokens.spacingHorizontalS,
},
icon: {
height: '32px',
width: '32px',
},
themeToggle: {
cursor: 'pointer',
},
mobileMenuButton: {
display: 'none',
'@media (max-width: 768px)': {
display: 'inline-flex',
},
},
});
interface HeaderProps {
isDarkMode: boolean;
onToggleTheme: () => void;
onToggleSidebar?: () => void;
}
const Header = ({ isDarkMode, onToggleTheme, onToggleSidebar }: HeaderProps) => {
const styles = useStyles();
return (
<header className={styles.header}>
<Text size={500} weight="semibold" className={styles.title}>
<img src={icon} alt="logo" className={styles.icon} />
{SITE_TITLE}
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: tokens.spacingHorizontalS }}>
<Button
appearance="transparent"
onClick={onToggleSidebar}
className={styles.mobileMenuButton}
></Button>
<Button
appearance="transparent"
icon={isDarkMode ? <WeatherSunny24Regular /> : <WeatherMoon24Regular />}
onClick={onToggleTheme}
className={styles.themeToggle}
/>
</div>
</header>
);
};
export default Header;

View File

@@ -0,0 +1,115 @@
import { makeStyles, tokens } from '@fluentui/react-components';
import { Home24Regular, Add24Regular, History24Regular, Info24Regular, DocumentSearch24Regular, PeopleSearch24Regular, ChevronDown24Regular, ChevronRight24Regular } from '@fluentui/react-icons';
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
const useStyles = makeStyles({
sidebar: {
padding: tokens.spacingVerticalM,
},
menuItem: {
display: 'flex',
alignItems: 'center',
padding: tokens.spacingVerticalS + ' ' + tokens.spacingHorizontalM,
color: tokens.colorNeutralForeground1,
textDecoration: 'none',
borderRadius: tokens.borderRadiusMedium,
gap: tokens.spacingHorizontalS,
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover,
},
},
activeMenuItem: {
backgroundColor: tokens.colorNeutralBackground1Selected,
color: tokens.colorBrandForeground1,
':hover': {
backgroundColor: tokens.colorNeutralBackground1Selected,
},
},
});
const menuItems = [
{ path: '/', icon: Home24Regular, label: '主页' },
{ path: '/create', icon: Add24Regular, label: '发布新帖' },
{
path: '/progress',
icon: History24Regular,
label: '进度查询',
subItems: [
{ path: '/progress/review', icon: DocumentSearch24Regular, label: '投稿审核' },
{ path: '/progress/complaint', icon: PeopleSearch24Regular, label: '投诉受理' }
]
},
{ path: '/about', icon: Info24Regular, label: '关于' },
];
const Sidebar = () => {
const [expandedItems, setExpandedItems] = React.useState<Record<string, boolean>>({});
const styles = useStyles();
const location = useLocation();
return (
<nav className={styles.sidebar}>
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.path ||
(item.subItems && item.subItems.some(subItem => location.pathname === subItem.path));
const isExpanded = expandedItems[item.path];
return (
<div key={item.path}>
{item.subItems ? (
<div
className={`${styles.menuItem} ${isActive ? styles.activeMenuItem : ''}`}
onClick={() => setExpandedItems(prev => ({
...prev,
[item.path]: !prev[item.path]
}))}
style={{ cursor: 'pointer' }}
>
<Icon />
{item.label}
{item.subItems && (
isExpanded ?
<ChevronDown24Regular style={{ marginLeft: 'auto' }} /> :
<ChevronRight24Regular style={{ marginLeft: 'auto' }} />
)}
</div>
) : (
<Link
to={item.path}
className={`${styles.menuItem} ${isActive ? styles.activeMenuItem : ''}`}
>
<Icon />
{item.label}
</Link>
)}
{item.subItems && isExpanded && (
<div style={{ marginLeft: tokens.spacingHorizontalL }}>
{item.subItems.map((subItem) => {
const SubIcon = subItem.icon;
const isSubActive = location.pathname === subItem.path;
return (
<Link
key={subItem.path}
to={subItem.path}
className={`${styles.menuItem} ${isSubActive ? styles.activeMenuItem : ''}`}
style={{ paddingLeft: tokens.spacingHorizontalXXL }}
>
<SubIcon />
{subItem.label}
</Link>
);
})}
</div>
)}
</div>
);
})}
</nav>
);
};
export default Sidebar;

13
src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { SITE_TITLE } from './config'
document.title = SITE_TITLE;
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

114
src/pages/InitPage.tsx Normal file
View File

@@ -0,0 +1,114 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { makeStyles, tokens, Card, CardHeader, CardPreview, Text, Input, Button, Field, Textarea, Title2 } from '@fluentui/react-components';
import { initBackend } from '../api';
import type { InitPayload } from '../api';
import { toast } from 'react-toastify';
const useStyles = makeStyles({
page: {
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: tokens.colorNeutralBackground3,
padding: tokens.spacingHorizontalXL,
},
card: {
width: 'min(720px, 92vw)',
borderRadius: tokens.borderRadiusLarge,
},
content: {
paddingLeft: tokens.spacingHorizontalXL,
paddingRight: '96px',
paddingTop: tokens.spacingVerticalL,
paddingBottom: tokens.spacingVerticalL,
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalXL,
maxWidth: '640px',
margin: '0 auto',
boxSizing: 'border-box',
},
});
const InitPage: React.FC = () => {
const styles = useStyles();
const navigate = useNavigate();
const [adminToken, setAdminToken] = useState('');
const [uploadFolder, setUploadFolder] = useState('img');
const [allowedExtensions, setAllowedExtensions] = useState('png,jpg,jpeg,gif,webp');
const [maxFileSizeMB, setMaxFileSizeMB] = useState(10); // 以MB为单位默认10MB
const [bannedKeywords, setBannedKeywords] = useState('');
const [initializing, setInitializing] = useState(false);
const onInit = async () => {
setInitializing(true);
try {
const payload: InitPayload = {
adminToken,
uploadFolder,
allowedExtensions: allowedExtensions
.split(',')
.map(s => s.trim())
.filter(Boolean),
maxFileSize: Math.round(Number(maxFileSizeMB) * 1024 * 1024),
bannedKeywords: bannedKeywords
? bannedKeywords.split(',').map(s => s.trim()).filter(Boolean)
: undefined,
};
const res = await initBackend(payload);
if (res.status === 'OK') {
toast.success('初始化成功');
navigate('/');
} else {
toast.error(res.reason || '初始化失败');
}
} catch (err: any) {
toast.error(err?.message || '初始化失败');
} finally {
setInitializing(false);
}
};
return (
<div className={styles.page}>
<Card className={styles.card}>
<CardHeader header={<Title2> 😉 </Title2>} />
<CardPreview>
<div className={styles.content}>
<Text weight="semibold">🎊 使 config.py修改配置</Text>
<Field label="管理员令牌">
<Input value={adminToken} onChange={(_, v) => setAdminToken(v?.value || '')} placeholder="请输入管理员令牌" />
</Field>
<Field label="上传目录">
<Input value={uploadFolder} onChange={(_, v) => setUploadFolder(v?.value || '')} placeholder="例如img" />
</Field>
<Field label="允许扩展名 (逗号分隔)">
<Input value={allowedExtensions} onChange={(_, v) => setAllowedExtensions(v?.value || '')} placeholder="png,jpg,jpeg,gif,webp" />
</Field>
<Field label="最大文件大小 (MB)">
<Input
type="number"
value={String(maxFileSizeMB)}
onChange={(_, v) => setMaxFileSizeMB(Number(v?.value || maxFileSizeMB))}
placeholder="例如10"
/>
</Field>
<Field label="违禁词 (可选,逗号分隔)">
<Textarea value={bannedKeywords} onChange={(_, v) => setBannedKeywords(v?.value || '')} resize="vertical" placeholder="例如spam,广告,违禁词" />
</Field>
<Button style={{ marginTop: tokens.spacingVerticalXL }} appearance="primary" onClick={onInit} disabled={initializing}>
{initializing ? '正在初始化...' : '开始初始化'}
</Button>
</div>
</CardPreview>
</Card>
</div>
);
};
export default InitPage;

79
src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,79 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { makeStyles, tokens, Card, CardHeader, CardPreview, Text, Button, Title1, Subtitle1 } from '@fluentui/react-components';
import { ArrowLeft24Regular, Home24Regular } from '@fluentui/react-icons';
const useStyles = makeStyles({
page: {
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: tokens.colorNeutralBackground3,
padding: tokens.spacingHorizontalXL,
},
card: {
width: 'min(720px, 92vw)',
borderRadius: tokens.borderRadiusLarge,
overflow: 'hidden',
boxShadow: tokens.shadow8,
},
header: {
paddingLeft: tokens.spacingHorizontalXL,
paddingRight: tokens.spacingHorizontalXL,
paddingTop: tokens.spacingVerticalM,
paddingBottom: tokens.spacingVerticalM,
},
preview: {
height: '140px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: `linear-gradient(135deg, ${tokens.colorBrandBackground} 0%, ${tokens.colorBrandBackground2} 50%, ${tokens.colorPaletteBlueBackground2} 100%)`,
color: tokens.colorNeutralForegroundOnBrand,
},
content: {
paddingLeft: tokens.spacingHorizontalXL,
paddingRight: tokens.spacingHorizontalXL,
paddingTop: tokens.spacingVerticalL,
paddingBottom: tokens.spacingVerticalL,
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalM,
},
actions: {
display: 'flex',
gap: tokens.spacingHorizontalM,
marginTop: tokens.spacingVerticalL,
},
});
const NotFound: React.FC = () => {
const styles = useStyles();
const navigate = useNavigate();
return (
<div className={styles.page}>
<Card className={styles.card}>
<CardHeader className={styles.header} header={<Title1 style={{ margin: 0 }}>😕 404 </Title1>} />
<CardPreview>
<div className={styles.preview} />
</CardPreview>
<div className={styles.content}>
<Subtitle1>访</Subtitle1>
<Text>使</Text>
<div className={styles.actions}>
<Button appearance="primary" icon={<ArrowLeft24Regular />} onClick={() => navigate(-1)}>
</Button>
<Button appearance="secondary" icon={<Home24Regular />} onClick={() => navigate('/') }>
</Button>
</div>
</div>
</Card>
</div>
);
};
export default NotFound;