From ec8ea94d50ea3b3216241301bfb9d451ae25612f Mon Sep 17 00:00:00 2001 From: LeonspaceX Date: Fri, 5 Dec 2025 18:18:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=85=AC=E5=91=8A=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 38 ++++++++++- src/admin_api.tsx | 19 +++++- src/api.ts | 22 ++++++- src/components/AdminDashboard.tsx | 102 ++++++++++++++++++++++++++++- src/components/AdminModifyPost.tsx | 3 +- src/components/NoticeModal.tsx | 94 ++++++++++++++++++++++++++ 6 files changed, 271 insertions(+), 7 deletions(-) create mode 100644 src/components/NoticeModal.tsx diff --git a/src/App.tsx b/src/App.tsx index 6c354a5..04a455e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,7 @@ 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 { fetchArticles, getNotice } from './api'; import CreatePost from './components/CreatePost'; import { ToastContainer, toast } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; @@ -17,6 +17,7 @@ import AdminPage from './components/AdminPage'; import InitPage from './pages/InitPage'; import NotFound from './pages/NotFound'; import ImageViewer from './components/ImageViewer'; +import NoticeModal from './components/NoticeModal'; function App() { const [isDarkMode, setIsDarkMode] = React.useState(false); @@ -38,6 +39,7 @@ function App() { const [imageViewer, setImageViewer] = useState<{ open: boolean; src?: string; alt?: string }>({ open: false }); const THEME_PREF_KEY = 'ThemePref'; const userPrefRef = useRef(false); + const [noticeModal, setNoticeModal] = useState<{ open: boolean; type?: 'md' | 'url'; content?: string; version?: number }>({ open: false }); const openImageViewer = (src?: string, alt?: string) => { if (!src) return; @@ -124,6 +126,28 @@ function App() { }; }, []); + // 加载公告并根据版本控制显示 + useEffect(() => { + const loadNotice = async () => { + try { + const data = await getNotice(); + const ver = Number(data.version ?? 0) || 0; + if (ver === 0) return; // 版本为 0 不显示 + let storedVer = 0; + try { + const v = localStorage.getItem('notice_ver'); + storedVer = v ? Number(v) || 0 : 0; + } catch {} + if (!storedVer || ver > storedVer) { + setNoticeModal({ open: true, type: data.type === 'url' ? 'url' : 'md', content: String(data.content ?? ''), version: ver }); + } + } catch { + // 获取失败则不显示公告 + } + }; + loadNotice(); + }, []); + useEffect(() => { const controller = new AbortController(); const signal = controller.signal; @@ -230,6 +254,18 @@ function App() { {imageViewer.open && imageViewer.src && ( )} + {noticeModal.open && ( +
+ setNoticeModal(prev => ({ ...prev, open: false }))} + onNeverShow={(version) => { + try { localStorage.setItem('notice_ver', String(version)); } catch {} + setNoticeModal(prev => ({ ...prev, open: false })); + }} + /> +
+ )} ); } diff --git a/src/admin_api.tsx b/src/admin_api.tsx index aaac5c6..cd733ba 100644 --- a/src/admin_api.tsx +++ b/src/admin_api.tsx @@ -181,6 +181,23 @@ export const adminApiRequest = async ( return response; }; +/** + * 修改公告(后端自动递增版本) + * POST /admin/modify_notice { type: 'md' | 'url', content: string } + */ +export const adminModifyNotice = async ( + payload: { type: 'md' | 'url'; content: string } +): Promise<{ status: 'OK'; version?: number }> => { + const resp = await adminApiRequest('/modify_notice', { + method: 'POST', + body: JSON.stringify(payload), + }); + if (!resp.ok) { + throw new Error(`修改公告失败: ${resp.status}`); + } + return resp.json(); +}; + /** * 创建备份并返回 ZIP 文件 Blob 与文件名 * GET /admin/get/backup -> ZIP @@ -809,4 +826,4 @@ export const setBannedKeywordsList = async (keywords: string[]): Promise<{ statu throw new Error(`保存违禁词失败: ${resp.status}${detail ? ` - ${detail}` : ''}`); } return resp.json(); -}; \ No newline at end of file +}; diff --git a/src/api.ts b/src/api.ts index f87ce2b..4ddd406 100644 --- a/src/api.ts +++ b/src/api.ts @@ -261,4 +261,24 @@ export const initBackend = async (payload: InitPayload): Promise<{ status: strin } return data as { status: string; reason?: string }; -}; \ No newline at end of file +}; + +// === Notice === +export interface NoticeResponse { + type: 'md' | 'url'; + content: string; + version: string | number; +} + +export const getNotice = async (): Promise => { + const response = await fetch(`${API_CONFIG.BASE_URL}/get/notice`, { method: 'GET' }); + if (!response.ok) { + throw new Error(`获取公告失败: ${response.status}`); + } + const data = await response.json(); + return { + type: (data?.type === 'url' ? 'url' : 'md') as 'md' | 'url', + content: String(data?.content ?? ''), + version: data?.version ?? '0', + }; +}; diff --git a/src/components/AdminDashboard.tsx b/src/components/AdminDashboard.tsx index 85d2835..dbfa02f 100644 --- a/src/components/AdminDashboard.tsx +++ b/src/components/AdminDashboard.tsx @@ -14,6 +14,8 @@ import { DialogActions, Input, Tooltip, + Dropdown, + Option, shorthands, } from '@fluentui/react-components'; import type { TabValue } from '@fluentui/react-components'; @@ -39,7 +41,12 @@ import icon from '/icon.png'; import AdminPostCard from './AdminPostCard'; import AdminModifyPost from './AdminModifyPost'; import AdminManageComments from './AdminManageComments'; -import { fetchArticles, type Article } from '../api'; +import { fetchArticles, type Article, getNotice } from '../api'; +import { adminModifyNotice } from '../admin_api'; +import MdEditor from 'react-markdown-editor-lite'; +import 'react-markdown-editor-lite/lib/index.css'; +import remarkIns from 'remark-ins'; +import remarkBreaks from 'remark-breaks'; import ImageViewer from './ImageViewer'; const useStyles = makeStyles({ @@ -218,6 +225,12 @@ const AdminDashboard: React.FC = ({ // 图片预览器状态 const [imageViewer, setImageViewer] = React.useState<{ open: boolean; src?: string; alt?: string }>({ open: false }); + // 公告管理状态 + const [noticeLoading, setNoticeLoading] = React.useState(false); + const [noticeVersion, setNoticeVersion] = React.useState(0); + const [noticeType, setNoticeType] = React.useState<'md' | 'url'>('md'); + const [noticeContent, setNoticeContent] = React.useState(''); + React.useEffect(() => { if (activeTab === 'systemSettings') { setLoadingAudit(true); @@ -278,6 +291,25 @@ const AdminDashboard: React.FC = ({ } }) .finally(() => setReportsLoading(false)); + } else if (activeTab === 'noticeManage') { + setNoticeLoading(true); + getNotice() + .then((data) => { + const ver = Number(data.version ?? 0) || 0; + setNoticeVersion(ver); + setNoticeType((data.type === 'url' ? 'url' : 'md')); + setNoticeContent(String(data.content ?? '')); + }) + .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(() => setNoticeLoading(false)); } }, [activeTab, picPage]); @@ -668,13 +700,77 @@ const AdminDashboard: React.FC = ({ 投稿审核 投诉审核 图片管理 + 公告管理 系统设置 {/* 内容面板 */}
- {activeTab === 'systemSettings' ? ( + {activeTab === 'noticeManage' ? ( +
+ 公告管理 +
+ 当前公告版本:{noticeLoading ? '加载中...' : noticeVersion} +
+
+ 类型 + { + const v = String(data.optionValue) as 'md' | 'url'; + setNoticeType(v); + }} + > + + + +
+
+ {noticeType === 'md' ? ( +
+ ( + {text} + )} + onChange={({ text }: { text: string }) => setNoticeContent(text)} + /> +
+ ) : ( + setNoticeContent((e.target as HTMLInputElement).value)} + /> + )} +
+
+ +
+
+ ) : activeTab === 'systemSettings' ? (
系统设置 @@ -1067,4 +1163,4 @@ const AdminDashboard: React.FC = ({ ); }; -export default AdminDashboard; \ No newline at end of file +export default AdminDashboard; diff --git a/src/components/AdminModifyPost.tsx b/src/components/AdminModifyPost.tsx index fa1e288..cf45619 100644 --- a/src/components/AdminModifyPost.tsx +++ b/src/components/AdminModifyPost.tsx @@ -6,6 +6,7 @@ import 'react-markdown-editor-lite/lib/index.css'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkIns from 'remark-ins'; +import remarkBreaks from 'remark-breaks'; import { toast } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import { uploadImage } from '../api'; @@ -134,7 +135,7 @@ const AdminModifyPost: React.FC = ({ postId, initialConten {text}} + renderHTML={(text) => {text}} onChange={handleEditorChange} onImageUpload={handleImageUpload} /> diff --git a/src/components/NoticeModal.tsx b/src/components/NoticeModal.tsx new file mode 100644 index 0000000..a58b376 --- /dev/null +++ b/src/components/NoticeModal.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { makeStyles, Button, tokens, Text } from '@fluentui/react-components'; +import { Dismiss24Regular } from '@fluentui/react-icons'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import remarkIns from 'remark-ins'; +import remarkBreaks from 'remark-breaks'; + +const useStyles = makeStyles({ + modalContent: { + backgroundColor: tokens.colorNeutralBackground1, + padding: tokens.spacingHorizontalXXL, + borderRadius: tokens.borderRadiusXLarge, + boxShadow: tokens.shadow64, + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + width: '520px', + maxWidth: '90vw', + position: 'relative', + }, + closeButton: { + position: 'absolute', + top: tokens.spacingVerticalS, + right: tokens.spacingHorizontalS, + }, + title: { + fontSize: tokens.fontSizeBase500, + fontWeight: tokens.fontWeightSemibold, + marginBottom: tokens.spacingVerticalS, + }, + contentBox: { + maxHeight: '60vh', + overflowY: 'auto', + }, + iframe: { + width: '100%', + height: '360px', + border: 'none', + borderRadius: tokens.borderRadiusMedium, + backgroundColor: tokens.colorNeutralBackground2, + }, + actions: { + display: 'flex', + justifyContent: 'flex-end', + gap: tokens.spacingHorizontalS, + }, +}); + +export interface NoticeData { + type: 'md' | 'url'; + content: string; + version: number; +} + +interface NoticeModalProps { + data: NoticeData; + onClose: () => void; + onNeverShow: (version: number) => void; +} + +const NoticeModal: React.FC = ({ data, onClose, onNeverShow }) => { + const styles = useStyles(); + const { type, content, version } = data; + + return ( +
+