Feat:增加清除缓存按钮,增加开关公告选项
This commit is contained in:
54
src/App.tsx
54
src/App.tsx
@@ -6,7 +6,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
|||||||
import PostCard from './components/PostCard';
|
import PostCard from './components/PostCard';
|
||||||
import MainLayout from './layouts/MainLayout';
|
import MainLayout from './layouts/MainLayout';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { fetchArticles } from './api';
|
import { fetchArticles, getNotice } from './api';
|
||||||
import CreatePost from './components/CreatePost';
|
import CreatePost from './components/CreatePost';
|
||||||
import { ToastContainer, toast } from 'react-toastify';
|
import { ToastContainer, toast } from 'react-toastify';
|
||||||
import 'react-toastify/dist/ReactToastify.css';
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
@@ -18,6 +18,8 @@ import AdminPage from './components/AdminPage';
|
|||||||
import InitPage from './pages/InitPage';
|
import InitPage from './pages/InitPage';
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
import ImageViewer from './components/ImageViewer';
|
import ImageViewer from './components/ImageViewer';
|
||||||
|
import NoticeModal from './components/NoticeModal';
|
||||||
|
import type { NoticeData } from './components/NoticeModal';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [isDarkMode, setIsDarkMode] = React.useState(() => {
|
const [isDarkMode, setIsDarkMode] = React.useState(() => {
|
||||||
@@ -50,6 +52,30 @@ function App() {
|
|||||||
const lastRefreshAtRef = useRef<number>(0);
|
const lastRefreshAtRef = useRef<number>(0);
|
||||||
const REFRESH_COOLDOWN_MS = 5000; // 刷新冷却时间
|
const REFRESH_COOLDOWN_MS = 5000; // 刷新冷却时间
|
||||||
const [imageViewer, setImageViewer] = useState<{ open: boolean; src?: string; alt?: string }>({ open: false });
|
const [imageViewer, setImageViewer] = useState<{ open: boolean; src?: string; alt?: string }>({ open: false });
|
||||||
|
const [noticeData, setNoticeData] = useState<NoticeData | null>(null);
|
||||||
|
const [showNotice, setShowNotice] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getNotice().then(data => {
|
||||||
|
// Check display status
|
||||||
|
if (data.display === 'false') {
|
||||||
|
setShowNotice(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedVersion = localStorage.getItem('notice_version');
|
||||||
|
// 只有当有内容且版本号大于本地存储的版本时才显示
|
||||||
|
if (data.content && (!savedVersion || Number(savedVersion) < Number(data.version))) {
|
||||||
|
setNoticeData({
|
||||||
|
type: data.type,
|
||||||
|
content: data.content,
|
||||||
|
version: Number(data.version),
|
||||||
|
display: data.display
|
||||||
|
});
|
||||||
|
setShowNotice(true);
|
||||||
|
}
|
||||||
|
}).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const openImageViewer = (src?: string, alt?: string) => {
|
const openImageViewer = (src?: string, alt?: string) => {
|
||||||
if (!src) return;
|
if (!src) return;
|
||||||
@@ -209,6 +235,29 @@ function App() {
|
|||||||
{imageViewer.open && imageViewer.src && (
|
{imageViewer.open && imageViewer.src && (
|
||||||
<ImageViewer src={imageViewer.src!} alt={imageViewer.alt} onClose={closeImageViewer} />
|
<ImageViewer src={imageViewer.src!} alt={imageViewer.alt} onClose={closeImageViewer} />
|
||||||
)}
|
)}
|
||||||
|
{showNotice && noticeData && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
zIndex: 2000,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<NoticeModal
|
||||||
|
data={noticeData}
|
||||||
|
onClose={() => setShowNotice(false)}
|
||||||
|
onNeverShow={(version) => {
|
||||||
|
localStorage.setItem('notice_version', String(version));
|
||||||
|
setShowNotice(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</FluentProvider>
|
</FluentProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -218,3 +267,6 @@ export default App;
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -185,17 +185,30 @@ export const adminApiRequest = async (
|
|||||||
* 修改公告(后端自动递增版本)
|
* 修改公告(后端自动递增版本)
|
||||||
* POST /admin/modify_notice { type: 'md' | 'url', content: string }
|
* POST /admin/modify_notice { type: 'md' | 'url', content: string }
|
||||||
*/
|
*/
|
||||||
export const adminModifyNotice = async (
|
export const adminModifyNotice = async (type: 'md'|'url', content: string, version: number): Promise<void> => {
|
||||||
payload: { type: 'md' | 'url'; content: string }
|
|
||||||
): Promise<{ status: 'OK'; version?: number }> => {
|
|
||||||
const resp = await adminApiRequest('/modify_notice', {
|
const resp = await adminApiRequest('/modify_notice', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({ type, content, version }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
throw new Error(`修改公告失败: ${resp.status}`);
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.reason || `修改公告失败: ${resp.status}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换公告开启状态
|
||||||
|
* POST /admin/notice_switch { value: "true" | "false" }
|
||||||
|
*/
|
||||||
|
export const adminNoticeSwitch = async (value: boolean): Promise<void> => {
|
||||||
|
const resp = await adminApiRequest('/notice_switch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ value: value ? "true" : "false" }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.reason || `切换公告状态失败: ${resp.status}`);
|
||||||
}
|
}
|
||||||
return resp.json();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -826,4 +839,4 @@ export const setBannedKeywordsList = async (keywords: string[]): Promise<{ statu
|
|||||||
throw new Error(`保存违禁词失败: ${resp.status}${detail ? ` - ${detail}` : ''}`);
|
throw new Error(`保存违禁词失败: ${resp.status}${detail ? ` - ${detail}` : ''}`);
|
||||||
}
|
}
|
||||||
return resp.json();
|
return resp.json();
|
||||||
};
|
};
|
||||||
571
src/api.ts
571
src/api.ts
@@ -1,284 +1,287 @@
|
|||||||
import { API_CONFIG } from './config';
|
import { API_CONFIG } from './config';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
export interface Article {
|
export interface Article {
|
||||||
id: number;
|
id: number;
|
||||||
content: string;
|
content: string;
|
||||||
upvotes: number;
|
upvotes: number;
|
||||||
downvotes: number;
|
downvotes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchArticles = async (page: number, signal?: AbortSignal): Promise<Article[]> => {
|
export const fetchArticles = async (page: number, signal?: AbortSignal): Promise<Article[]> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_CONFIG.BASE_URL}/get/10_info?page=${page}`, { signal });
|
const response = await fetch(`${API_CONFIG.BASE_URL}/get/10_info?page=${page}`, { signal });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name !== 'AbortError') {
|
if (error instanceof Error && error.name !== 'AbortError') {
|
||||||
console.error('Error fetching articles:', error);
|
console.error('Error fetching articles:', error);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const voteArticle = async (
|
export const voteArticle = async (
|
||||||
id: number,
|
id: number,
|
||||||
type: 'up' | 'down'
|
type: 'up' | 'down'
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_CONFIG.BASE_URL}/${type}`, {
|
const response = await fetch(`${API_CONFIG.BASE_URL}/${type}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ id }),
|
body: JSON.stringify({ id }),
|
||||||
});
|
});
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
const data = await response.json().catch(() => null);
|
const data = await response.json().catch(() => null);
|
||||||
if (data?.reason === 'Rate Limit Exceeded') {
|
if (data?.reason === 'Rate Limit Exceeded') {
|
||||||
toast.error('Rate Limit Exceeded');
|
toast.error('Rate Limit Exceeded');
|
||||||
throw new Error('Rate Limit Exceeded');
|
throw new Error('Rate Limit Exceeded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.status !== 'OK') {
|
if (data.status !== 'OK') {
|
||||||
throw new Error(`Vote ${type} failed`);
|
throw new Error(`Vote ${type} failed`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(`点${type === 'up' ? '赞' : '踩'}失败`);
|
toast.error(`点${type === 'up' ? '赞' : '踩'}失败`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SubmitPostResponse {
|
interface SubmitPostResponse {
|
||||||
id: string;
|
id: string;
|
||||||
status: "Pass" | "Pending" | "Deny";
|
status: "Pass" | "Pending" | "Deny";
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const submitPost = async (postData: { content: string }): Promise<SubmitPostResponse> => {
|
export const submitPost = async (postData: { content: string }): Promise<SubmitPostResponse> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_CONFIG.BASE_URL}/post`, {
|
const response = await fetch(`${API_CONFIG.BASE_URL}/post`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ content: postData.content }),
|
body: JSON.stringify({ content: postData.content }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
const data = await response.json().catch(() => null);
|
const data = await response.json().catch(() => null);
|
||||||
if (data?.reason === 'Rate Limit Exceeded') {
|
if (data?.reason === 'Rate Limit Exceeded') {
|
||||||
toast.error('Rate Limit Exceeded');
|
toast.error('Rate Limit Exceeded');
|
||||||
throw new Error('Rate Limit Exceeded');
|
throw new Error('Rate Limit Exceeded');
|
||||||
}
|
}
|
||||||
return { status: 'Deny', message: '投稿中包含违禁词', id: 'null'};
|
return { status: 'Deny', message: '投稿中包含违禁词', id: 'null'};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json() as SubmitPostResponse;
|
return await response.json() as SubmitPostResponse;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error submitting post:', error);
|
console.error('Error submitting post:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadImage = async (formData: FormData): Promise<{ status: 'OK' | 'Error'; url?: string; message?: string }> => {
|
export const uploadImage = async (formData: FormData): Promise<{ status: 'OK' | 'Error'; url?: string; message?: string }> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_CONFIG.BASE_URL}/upload_pic`, {
|
const response = await fetch(`${API_CONFIG.BASE_URL}/upload_pic`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
const data = await response.json().catch(() => null);
|
const data = await response.json().catch(() => null);
|
||||||
if (data?.reason === 'Rate Limit Exceeded') {
|
if (data?.reason === 'Rate Limit Exceeded') {
|
||||||
toast.error('Rate Limit Exceeded');
|
toast.error('Rate Limit Exceeded');
|
||||||
throw new Error('Rate Limit Exceeded');
|
throw new Error('Rate Limit Exceeded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.url) {
|
if (result.url) {
|
||||||
result.url = `${API_CONFIG.BASE_URL}${result.url}`;
|
result.url = `${API_CONFIG.BASE_URL}${result.url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading image:', error);
|
console.error('Error uploading image:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ReportPostResponse {
|
interface ReportPostResponse {
|
||||||
id: number;
|
id: number;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const reportPost = async (reportData: { id: number; title: string; content: string }): Promise<ReportPostResponse> => {
|
export const reportPost = async (reportData: { id: number; title: string; content: string }): Promise<ReportPostResponse> => {
|
||||||
const response = await fetch(`${API_CONFIG.BASE_URL}/report`, {
|
const response = await fetch(`${API_CONFIG.BASE_URL}/report`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(reportData),
|
body: JSON.stringify(reportData),
|
||||||
});
|
});
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
const data = await response.json().catch(() => null);
|
const data = await response.json().catch(() => null);
|
||||||
if (data?.reason === 'Rate Limit Exceeded') {
|
if (data?.reason === 'Rate Limit Exceeded') {
|
||||||
toast.error('Rate Limit Exceeded');
|
toast.error('Rate Limit Exceeded');
|
||||||
throw new Error('Rate Limit Exceeded');
|
throw new Error('Rate Limit Exceeded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await response.json()) as ReportPostResponse;
|
return (await response.json()) as ReportPostResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getPostState(id: string): Promise<{ status: string }> {
|
export async function getPostState(id: string): Promise<{ status: string }> {
|
||||||
const response = await fetch(`${API_CONFIG.BASE_URL}/get/post_state?id=${id}`);
|
const response = await fetch(`${API_CONFIG.BASE_URL}/get/post_state?id=${id}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch post state');
|
throw new Error('Failed to fetch post state');
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getReportState(id: string): Promise<{ status: string }> {
|
export async function getReportState(id: string): Promise<{ status: string }> {
|
||||||
const response = await fetch(`${API_CONFIG.BASE_URL}/get/report_state?id=${id}`);
|
const response = await fetch(`${API_CONFIG.BASE_URL}/get/report_state?id=${id}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch report state');
|
throw new Error('Failed to fetch report state');
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
id: number;
|
id: number;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
content: string;
|
content: string;
|
||||||
parent_comment_id: number;
|
parent_comment_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getComments = async (id: string | number): Promise<Comment[]> => {
|
export const getComments = async (id: string | number): Promise<Comment[]> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_CONFIG.BASE_URL}/get/comment?id=${id}`);
|
const response = await fetch(`${API_CONFIG.BASE_URL}/get/comment?id=${id}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching comments:', error);
|
console.error('Error fetching comments:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface PostCommentRequest {
|
export interface PostCommentRequest {
|
||||||
content: string;
|
content: string;
|
||||||
submission_id: number;
|
submission_id: number;
|
||||||
parent_comment_id: number;
|
parent_comment_id: number;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PostCommentResponse {
|
export interface PostCommentResponse {
|
||||||
id: number;
|
id: number;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const postComment = async (commentData: PostCommentRequest): Promise<PostCommentResponse> => {
|
export const postComment = async (commentData: PostCommentRequest): Promise<PostCommentResponse> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_CONFIG.BASE_URL}/comment`, {
|
const response = await fetch(`${API_CONFIG.BASE_URL}/comment`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(commentData),
|
body: JSON.stringify(commentData),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
const data = await response.json().catch(() => null);
|
const data = await response.json().catch(() => null);
|
||||||
if (data?.reason === 'Rate Limit Exceeded') {
|
if (data?.reason === 'Rate Limit Exceeded') {
|
||||||
toast.error('Rate Limit Exceeded');
|
toast.error('Rate Limit Exceeded');
|
||||||
throw new Error('Rate Limit Exceeded');
|
throw new Error('Rate Limit Exceeded');
|
||||||
}
|
}
|
||||||
throw new Error('评论包含违禁词');
|
throw new Error('评论包含违禁词');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error posting comment:', error);
|
console.error('Error posting comment:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// === Backend Initialization ===
|
// === Backend Initialization ===
|
||||||
export interface InitPayload {
|
export interface InitPayload {
|
||||||
adminToken: string;
|
adminToken: string;
|
||||||
uploadFolder: string;
|
uploadFolder: string;
|
||||||
allowedExtensions: string[];
|
allowedExtensions: string[];
|
||||||
maxFileSize: number;
|
maxFileSize: number;
|
||||||
bannedKeywords?: string[];
|
bannedKeywords?: string[];
|
||||||
rateLimit: number; // 次/分钟,0为无限制
|
rateLimit: number; // 次/分钟,0为无限制
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initBackend = async (payload: InitPayload): Promise<{ status: string; reason?: string }> => {
|
export const initBackend = async (payload: InitPayload): Promise<{ status: string; reason?: string }> => {
|
||||||
const body = {
|
const body = {
|
||||||
ADMIN_TOKEN: payload.adminToken,
|
ADMIN_TOKEN: payload.adminToken,
|
||||||
UPLOAD_FOLDER: payload.uploadFolder,
|
UPLOAD_FOLDER: payload.uploadFolder,
|
||||||
ALLOWED_EXTENSIONS: payload.allowedExtensions,
|
ALLOWED_EXTENSIONS: payload.allowedExtensions,
|
||||||
MAX_FILE_SIZE: payload.maxFileSize,
|
MAX_FILE_SIZE: payload.maxFileSize,
|
||||||
...(payload.bannedKeywords ? { BANNED_KEYWORDS: payload.bannedKeywords } : {}),
|
...(payload.bannedKeywords ? { BANNED_KEYWORDS: payload.bannedKeywords } : {}),
|
||||||
RATE_LIMIT: payload.rateLimit,
|
RATE_LIMIT: payload.rateLimit,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(`${API_CONFIG.BASE_URL}/init`, {
|
const response = await fetch(`${API_CONFIG.BASE_URL}/init`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json().catch(() => ({ status: 'Fail', reason: 'Invalid response' }));
|
const data = await response.json().catch(() => ({ status: 'Fail', reason: 'Invalid response' }));
|
||||||
|
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
throw new Error(data?.reason || '后端已初始化');
|
throw new Error(data?.reason || '后端已初始化');
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data?.reason || `初始化失败,状态码 ${response.status}`);
|
throw new Error(data?.reason || `初始化失败,状态码 ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data as { status: string; reason?: string };
|
return data as { status: string; reason?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
// === Notice ===
|
// === Notice ===
|
||||||
export interface NoticeResponse {
|
export interface NoticeResponse {
|
||||||
type: 'md' | 'url';
|
type: 'md' | 'url';
|
||||||
content: string;
|
content: string;
|
||||||
version: string | number;
|
version: string | number;
|
||||||
}
|
display?: string;
|
||||||
|
}
|
||||||
export const getNotice = async (): Promise<NoticeResponse> => {
|
|
||||||
const response = await fetch(`${API_CONFIG.BASE_URL}/get/notice`, { method: 'GET' });
|
export const getNotice = async (): Promise<NoticeResponse> => {
|
||||||
if (!response.ok) {
|
const response = await fetch(`${API_CONFIG.BASE_URL}/get/notice`, { method: 'GET' });
|
||||||
throw new Error(`获取公告失败: ${response.status}`);
|
if (!response.ok) {
|
||||||
}
|
throw new Error(`获取公告失败: ${response.status}`);
|
||||||
const data = await response.json();
|
}
|
||||||
return {
|
const data = await response.json();
|
||||||
type: (data?.type === 'url' ? 'url' : 'md') as 'md' | 'url',
|
return {
|
||||||
content: String(data?.content ?? ''),
|
type: (data?.type === 'url' ? 'url' : 'md') as 'md' | 'url',
|
||||||
version: data?.version ?? '0',
|
content: String(data?.content ?? ''),
|
||||||
};
|
version: data?.version ?? '0',
|
||||||
};
|
display: data?.display ?? 'true',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
} from '@fluentui/react-components';
|
} from '@fluentui/react-components';
|
||||||
import type { TabValue } 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,
|
import { getAuditMode, setAuditMode, getBackupZip, recoverBackup, getPicLinks, deletePic, type PicLink, getPendingReports, approveReport, rejectReport, type PendingReport, getAdminPostInfo, getPendingPosts, getRejectedPosts, type AdminPostListItem, approvePost, disapprovePost, reauditPost, deletePost,
|
||||||
getBannedKeywords, setBannedKeywordsList } from '../admin_api';
|
getBannedKeywords, setBannedKeywordsList, adminNoticeSwitch } from '../admin_api';
|
||||||
import { Switch } from '@fluentui/react-components';
|
import { Switch } from '@fluentui/react-components';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import {
|
import {
|
||||||
@@ -182,6 +182,7 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
|||||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
const [selectedBackupFile, setSelectedBackupFile] = React.useState<File | null>(null);
|
const [selectedBackupFile, setSelectedBackupFile] = React.useState<File | null>(null);
|
||||||
const [confirmOpen, setConfirmOpen] = React.useState<boolean>(false);
|
const [confirmOpen, setConfirmOpen] = React.useState<boolean>(false);
|
||||||
|
const [clearCacheConfirmOpen, setClearCacheConfirmOpen] = React.useState<boolean>(false);
|
||||||
// 图片管理状态
|
// 图片管理状态
|
||||||
const [picPage, setPicPage] = React.useState<number>(1);
|
const [picPage, setPicPage] = React.useState<number>(1);
|
||||||
const [picLoading, setPicLoading] = React.useState<boolean>(false);
|
const [picLoading, setPicLoading] = React.useState<boolean>(false);
|
||||||
@@ -233,6 +234,7 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
|||||||
const [noticeVersion, setNoticeVersion] = React.useState<number>(0);
|
const [noticeVersion, setNoticeVersion] = React.useState<number>(0);
|
||||||
const [noticeType, setNoticeType] = React.useState<'md' | 'url'>('md');
|
const [noticeType, setNoticeType] = React.useState<'md' | 'url'>('md');
|
||||||
const [noticeContent, setNoticeContent] = React.useState<string>('');
|
const [noticeContent, setNoticeContent] = React.useState<string>('');
|
||||||
|
const [noticeDisplay, setNoticeDisplay] = React.useState<boolean>(true);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (activeTab === 'systemSettings') {
|
if (activeTab === 'systemSettings') {
|
||||||
@@ -298,21 +300,47 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
|||||||
setNoticeLoading(true);
|
setNoticeLoading(true);
|
||||||
getNotice()
|
getNotice()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const ver = Number(data.version ?? 0) || 0;
|
setNoticeVersion(Number(data.version ?? 0) || 0);
|
||||||
setNoticeVersion(ver);
|
|
||||||
setNoticeType((data.type === 'url' ? 'url' : 'md'));
|
setNoticeType((data.type === 'url' ? 'url' : 'md'));
|
||||||
setNoticeContent(String(data.content ?? ''));
|
setNoticeContent(String(data.content ?? ''));
|
||||||
|
setNoticeDisplay(data.display !== 'false');
|
||||||
})
|
})
|
||||||
.catch((err: any) => {
|
.catch((err: any) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
const msg = String(err?.message || '获取公告失败');
|
toast.error('获取公告失败');
|
||||||
|
})
|
||||||
|
.finally(() => setNoticeLoading(false));
|
||||||
|
} else if (activeTab === 'postReview') {
|
||||||
|
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('登录已过期')) {
|
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
|
||||||
toast.error('身份验证失败,请重新登陆');
|
toast.error('身份验证失败,请重新登陆');
|
||||||
} else {
|
} else {
|
||||||
toast.error('获取公告失败');
|
toast.error('获取审核模式失败');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => setNoticeLoading(false));
|
.finally(() => setLoadingAudit(false));
|
||||||
|
|
||||||
|
// 加载违禁词
|
||||||
|
setBannedLoading(true);
|
||||||
|
getBannedKeywords()
|
||||||
|
.then((list) => setBannedKeywords(Array.isArray(list) ? 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(() => setBannedLoading(false));
|
||||||
}
|
}
|
||||||
}, [activeTab, picPage]);
|
}, [activeTab, picPage]);
|
||||||
|
|
||||||
@@ -569,6 +597,38 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
|||||||
void handleRecoverBackup(selectedBackupFile);
|
void handleRecoverBackup(selectedBackupFile);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClearCache = async () => {
|
||||||
|
// Clear localStorage
|
||||||
|
localStorage.clear();
|
||||||
|
|
||||||
|
// Clear sessionStorage
|
||||||
|
sessionStorage.clear();
|
||||||
|
|
||||||
|
// Clear cookies
|
||||||
|
document.cookie.split(";").forEach((c) => {
|
||||||
|
document.cookie = c
|
||||||
|
.replace(/^ +/, "")
|
||||||
|
.replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear caches
|
||||||
|
if ('caches' in window) {
|
||||||
|
try {
|
||||||
|
const names = await caches.keys();
|
||||||
|
await Promise.all(names.map(name => caches.delete(name)));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to clear caches:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('缓存已清理');
|
||||||
|
setClearCacheConfirmOpen(false);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
try {
|
try {
|
||||||
adminLogout();
|
adminLogout();
|
||||||
@@ -713,6 +773,25 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
|||||||
{activeTab === 'noticeManage' ? (
|
{activeTab === 'noticeManage' ? (
|
||||||
<div>
|
<div>
|
||||||
<Text size={400} weight="semibold">公告管理</Text>
|
<Text size={400} weight="semibold">公告管理</Text>
|
||||||
|
|
||||||
|
<div style={{ marginTop: tokens.spacingVerticalM }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: tokens.spacingVerticalM }}>
|
||||||
|
<Switch
|
||||||
|
checked={noticeDisplay}
|
||||||
|
onChange={async (_, data) => {
|
||||||
|
try {
|
||||||
|
await adminNoticeSwitch(!!data.checked);
|
||||||
|
setNoticeDisplay(!!data.checked);
|
||||||
|
toast.success(`公告已${data.checked ? '开启' : '关闭'}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || '切换状态失败');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
label={noticeDisplay ? "公告已开启" : "公告已关闭"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
||||||
<Text size={200} color="subtle">当前公告版本:{noticeLoading ? '加载中...' : noticeVersion}</Text>
|
<Text size={200} color="subtle">当前公告版本:{noticeLoading ? '加载中...' : noticeVersion}</Text>
|
||||||
</div>
|
</div>
|
||||||
@@ -752,8 +831,7 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
|||||||
<div style={{ marginTop: tokens.spacingVerticalM }}>
|
<div style={{ marginTop: tokens.spacingVerticalM }}>
|
||||||
<Button appearance="primary" onClick={async () => {
|
<Button appearance="primary" onClick={async () => {
|
||||||
try {
|
try {
|
||||||
const payload = { type: noticeType, content: noticeContent };
|
await adminModifyNotice(noticeType, noticeContent, noticeVersion + 1);
|
||||||
await adminModifyNotice(payload);
|
|
||||||
toast.success('公告已修改');
|
toast.success('公告已修改');
|
||||||
setNoticeLoading(true);
|
setNoticeLoading(true);
|
||||||
const data = await getNotice();
|
const data = await getNotice();
|
||||||
@@ -877,6 +955,19 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 开发者工具 */}
|
||||||
|
<div style={{ marginTop: tokens.spacingVerticalL }}>
|
||||||
|
<Text size={300}>开发者工具</Text>
|
||||||
|
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
||||||
|
<Button appearance="secondary" onClick={() => setClearCacheConfirmOpen(true)}>
|
||||||
|
清除缓存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Text size={200} color="subtle" style={{ marginTop: tokens.spacingVerticalS, display: 'block' }}>
|
||||||
|
将清理 localStorage、Cookies 及静态资源缓存。
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 确认对话框 */}
|
{/* 确认对话框 */}
|
||||||
<Dialog open={confirmOpen} onOpenChange={(_, data) => setConfirmOpen(!!data.open)}>
|
<Dialog open={confirmOpen} onOpenChange={(_, data) => setConfirmOpen(!!data.open)}>
|
||||||
<DialogSurface>
|
<DialogSurface>
|
||||||
@@ -892,6 +983,22 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
|||||||
</DialogBody>
|
</DialogBody>
|
||||||
</DialogSurface>
|
</DialogSurface>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 清除缓存确认对话框 */}
|
||||||
|
<Dialog open={clearCacheConfirmOpen} onOpenChange={(_, data) => setClearCacheConfirmOpen(!!data.open)}>
|
||||||
|
<DialogSurface>
|
||||||
|
<DialogBody>
|
||||||
|
<DialogTitle>确认清除缓存</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
确定要清除所有缓存吗?这将包括登录状态和本地设置。
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button appearance="secondary" onClick={() => setClearCacheConfirmOpen(false)}>取消</Button>
|
||||||
|
<Button appearance="primary" onClick={handleClearCache}>确认</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogSurface>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
) : activeTab === 'postReview' ? (
|
) : activeTab === 'postReview' ? (
|
||||||
<div>
|
<div>
|
||||||
@@ -1167,3 +1274,5 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default AdminDashboard;
|
export default AdminDashboard;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +1,96 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { makeStyles, Button, tokens, Text } from '@fluentui/react-components';
|
import { makeStyles, Button, tokens, Text } from '@fluentui/react-components';
|
||||||
import { Dismiss24Regular } from '@fluentui/react-icons';
|
import { Dismiss24Regular } from '@fluentui/react-icons';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import remarkIns from 'remark-ins';
|
import remarkIns from 'remark-ins';
|
||||||
import remarkBreaks from 'remark-breaks';
|
import remarkBreaks from 'remark-breaks';
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
modalContent: {
|
modalContent: {
|
||||||
backgroundColor: tokens.colorNeutralBackground1,
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
padding: tokens.spacingHorizontalXXL,
|
padding: tokens.spacingHorizontalXXL,
|
||||||
borderRadius: tokens.borderRadiusXLarge,
|
borderRadius: tokens.borderRadiusXLarge,
|
||||||
boxShadow: tokens.shadow64,
|
boxShadow: tokens.shadow64,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: tokens.spacingVerticalM,
|
gap: tokens.spacingVerticalM,
|
||||||
width: '520px',
|
width: '520px',
|
||||||
maxWidth: '90vw',
|
maxWidth: '90vw',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
closeButton: {
|
closeButton: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: tokens.spacingVerticalS,
|
top: tokens.spacingVerticalS,
|
||||||
right: tokens.spacingHorizontalS,
|
right: tokens.spacingHorizontalS,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: tokens.fontSizeBase500,
|
fontSize: tokens.fontSizeBase500,
|
||||||
fontWeight: tokens.fontWeightSemibold,
|
fontWeight: tokens.fontWeightSemibold,
|
||||||
marginBottom: tokens.spacingVerticalS,
|
marginBottom: tokens.spacingVerticalS,
|
||||||
},
|
},
|
||||||
contentBox: {
|
contentBox: {
|
||||||
maxHeight: '60vh',
|
maxHeight: '60vh',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
},
|
},
|
||||||
iframe: {
|
iframe: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '360px',
|
height: '360px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: tokens.borderRadiusMedium,
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
backgroundColor: tokens.colorNeutralBackground2,
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
gap: tokens.spacingHorizontalS,
|
gap: tokens.spacingHorizontalS,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface NoticeData {
|
export interface NoticeData {
|
||||||
type: 'md' | 'url';
|
type: 'md' | 'url';
|
||||||
content: string;
|
content: string;
|
||||||
version: number;
|
version: number;
|
||||||
}
|
display?: string;
|
||||||
|
}
|
||||||
interface NoticeModalProps {
|
|
||||||
data: NoticeData;
|
interface NoticeModalProps {
|
||||||
onClose: () => void;
|
data: NoticeData;
|
||||||
onNeverShow: (version: number) => void;
|
onClose: () => void;
|
||||||
}
|
onNeverShow: (version: number) => void;
|
||||||
|
}
|
||||||
const NoticeModal: React.FC<NoticeModalProps> = ({ data, onClose, onNeverShow }) => {
|
|
||||||
const styles = useStyles();
|
const NoticeModal: React.FC<NoticeModalProps> = ({ data, onClose, onNeverShow }) => {
|
||||||
const { type, content, version } = data;
|
const styles = useStyles();
|
||||||
|
const { type, content, version } = data;
|
||||||
return (
|
|
||||||
<div className={styles.modalContent} role="dialog" aria-modal="true" aria-label="公告">
|
return (
|
||||||
<Button
|
<div className={styles.modalContent} role="dialog" aria-modal="true" aria-label="公告">
|
||||||
icon={<Dismiss24Regular />}
|
<Button
|
||||||
appearance="transparent"
|
icon={<Dismiss24Regular />}
|
||||||
className={styles.closeButton}
|
appearance="transparent"
|
||||||
onClick={onClose}
|
className={styles.closeButton}
|
||||||
aria-label="关闭"
|
onClick={onClose}
|
||||||
/>
|
aria-label="关闭"
|
||||||
<Text as="h2" className={styles.title}>公告</Text>
|
/>
|
||||||
<div className={styles.contentBox}>
|
<Text as="h2" className={styles.title}>公告</Text>
|
||||||
{type === 'md' ? (
|
<div className={styles.contentBox}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkIns, remarkBreaks]}>
|
{type === 'md' ? (
|
||||||
{content}
|
<ReactMarkdown remarkPlugins={[remarkGfm, remarkIns, remarkBreaks]}>
|
||||||
</ReactMarkdown>
|
{content}
|
||||||
) : (
|
</ReactMarkdown>
|
||||||
<iframe className={styles.iframe} src={content} title={`公告-${version}`} />
|
) : (
|
||||||
)}
|
<iframe className={styles.iframe} src={content} title={`公告-${version}`} />
|
||||||
</div>
|
)}
|
||||||
<div className={styles.actions}>
|
</div>
|
||||||
<Button appearance="primary" onClick={onClose}>确定</Button>
|
<div className={styles.actions}>
|
||||||
<Button appearance="subtle" onClick={() => onNeverShow(version)}>不再显示</Button>
|
<Button appearance="primary" onClick={onClose}>确定</Button>
|
||||||
</div>
|
<Button appearance="subtle" onClick={() => onNeverShow(version)}>不再显示</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
};
|
);
|
||||||
|
};
|
||||||
export default NoticeModal;
|
|
||||||
|
export default NoticeModal;
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user