Feat:增加清除缓存按钮,增加开关公告选项

This commit is contained in:
LeonspaceX
2025-12-07 18:34:11 +08:00
parent daef092653
commit 462650478b
5 changed files with 573 additions and 394 deletions

View File

@@ -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;

View File

@@ -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();
}; };

View File

@@ -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',
};
};

View File

@@ -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' }}>
localStorageCookies
</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;

View File

@@ -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;