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

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

21
LICENSE.txt Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2025 libm
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

70
README-deployment.md Normal file
View File

@@ -0,0 +1,70 @@
# SPA 路由部署说明
## 问题描述
React Router 使用的是客户端路由,当用户直接访问 `/admin``/create` 等路由时,服务器会尝试查找对应的文件,但这些路径在服务器上并不存在,因此返回 404。
## 解决方案
### 1. OpenResty/Nginx 配置
在你的 OpenResty 配置中添加以下配置:
```nginx
server {
listen 80;
server_name your-domain.com;
root /path/to/your/dist;
index index.html;
# 处理静态资源
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA 路由回退 - 关键配置
location / {
try_files $uri $uri/ /index.html;
}
}
```
### 2. 关键配置说明
- `try_files $uri $uri/ /index.html;` 是核心配置
- 当访问任何路由时,服务器会:
1. 首先尝试查找对应的文件 (`$uri`)
2. 然后尝试查找对应的目录 (`$uri/`)
3. 最后回退到 `index.html`
### 3. 部署步骤
1. 构建项目:
```bash
pnpm build
```
2. 将 `dist` 目录的内容上传到服务器
3. 配置 OpenResty/Nginx 使用上述配置
4. 重启服务器:
```bash
nginx -s reload
# 或
systemctl reload nginx
```
### 4. 验证
部署后,以下访问方式都应该正常工作:
- 直接访问 `https://your-domain.com/admin`
- 直接访问 `https://your-domain.com/create`
- 通过侧边栏导航访问
### 5. 注意事项
- 确保静态资源路径正确
- 如果使用子路径部署,需要相应调整配置
- API 路由需要单独配置代理,避免被 SPA 回退规则影响

74
README.md Normal file
View File

@@ -0,0 +1,74 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
"# frontend"

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "sycamore_whisper_front",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@arco-design/web-react": "^2.66.5",
"@fluentui/react": "^8.124.0",
"@fluentui/react-components": "^9.72.1",
"@fluentui/react-icons": "^2.0.311",
"@uiw/react-md-editor": "^4.0.8",
"axios": "^1.12.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hot-toast": "^2.6.0",
"react-markdown": "^10.1.0",
"react-markdown-editor-lite": "^1.3.4",
"react-router-dom": "^7.9.3",
"react-toastify": "^11.0.5",
"remark-gfm": "^4.0.1",
"remark-ins": "^1.2.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}

6129
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1
public/_redirects Normal file
View File

@@ -0,0 +1 @@
/* /index.html 200

8
public/about.md Normal file
View File

@@ -0,0 +1,8 @@
# Hi~欢迎来到Sycamore_Whisper匿名投稿站
这里的内容来自开发环境下的/public/about.md请编辑此文件以便在这里显示自己的内容
如果你不了解Markdown文档的语法可以前往[这里](https://www.runoob.com/markdown/md-tutorial.html)简单学习
Made with ❤️ By Leonxie

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

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;

28
tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

17
vite.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
router: ['react-router-dom'],
},
},
},
},
})