first commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
21
LICENSE.txt
Normal 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
70
README-deployment.md
Normal 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
74
README.md
Normal 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
23
eslint.config.js
Normal 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
13
index.html
Normal 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
43
package.json
Normal 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
6129
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
public/_redirects
Normal file
1
public/_redirects
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/* /index.html 200
|
||||||
8
public/about.md
Normal file
8
public/about.md
Normal 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
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
27
src/App.css
Normal file
27
src/App.css
Normal 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
125
src/App.tsx
Normal 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
732
src/admin_api.tsx
Normal 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
234
src/api.ts
Normal 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
1
src/assets/react.svg
Normal 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 |
99
src/components/AboutPage.tsx
Normal file
99
src/components/AboutPage.tsx
Normal 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;
|
||||||
886
src/components/AdminDashboard.tsx
Normal file
886
src/components/AdminDashboard.tsx
Normal 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;
|
||||||
179
src/components/AdminLogin.tsx
Normal file
179
src/components/AdminLogin.tsx
Normal 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;
|
||||||
259
src/components/AdminManageComments.tsx
Normal file
259
src/components/AdminManageComments.tsx
Normal 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;
|
||||||
|
}
|
||||||
152
src/components/AdminModifyPost.tsx
Normal file
152
src/components/AdminModifyPost.tsx
Normal 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;
|
||||||
46
src/components/AdminPage.tsx
Normal file
46
src/components/AdminPage.tsx
Normal 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;
|
||||||
165
src/components/AdminPostCard.tsx
Normal file
165
src/components/AdminPostCard.tsx
Normal 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;
|
||||||
233
src/components/CommentSection.tsx
Normal file
233
src/components/CommentSection.tsx
Normal 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;
|
||||||
130
src/components/CreatePost.tsx
Normal file
130
src/components/CreatePost.tsx
Normal 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
227
src/components/PostCard.tsx
Normal 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;
|
||||||
73
src/components/PostState.tsx
Normal file
73
src/components/PostState.tsx
Normal 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;
|
||||||
84
src/components/ReportPost.tsx
Normal file
84
src/components/ReportPost.tsx
Normal 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;
|
||||||
72
src/components/ReportState.tsx
Normal file
72
src/components/ReportState.tsx
Normal 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;
|
||||||
153
src/components/StatusDisplay.tsx
Normal file
153
src/components/StatusDisplay.tsx
Normal 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
12
src/config.ts
Normal 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
8
src/index.css
Normal 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
124
src/layouts/MainLayout.tsx
Normal 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;
|
||||||
26
src/layouts/components/Footer.tsx
Normal file
26
src/layouts/components/Footer.tsx
Normal 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;
|
||||||
70
src/layouts/components/Header.tsx
Normal file
70
src/layouts/components/Header.tsx
Normal 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;
|
||||||
115
src/layouts/components/Sidebar.tsx
Normal file
115
src/layouts/components/Sidebar.tsx
Normal 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
13
src/main.tsx
Normal 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
114
src/pages/InitPage.tsx
Normal 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
79
src/pages/NotFound.tsx
Normal 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
28
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal 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
17
vite.config.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user