增加公告功能

This commit is contained in:
LeonspaceX
2025-12-05 18:18:51 +08:00
parent 0d5f2c4131
commit ec8ea94d50
6 changed files with 271 additions and 7 deletions

View File

@@ -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<boolean>(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 && (
<ImageViewer src={imageViewer.src!} alt={imageViewer.alt} onClose={closeImageViewer} />
)}
{noticeModal.open && (
<div style={{ 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 }}>
<NoticeModal
data={{ type: noticeModal.type!, content: noticeModal.content || '', version: noticeModal.version || 0 }}
onClose={() => setNoticeModal(prev => ({ ...prev, open: false }))}
onNeverShow={(version) => {
try { localStorage.setItem('notice_ver', String(version)); } catch {}
setNoticeModal(prev => ({ ...prev, open: false }));
}}
/>
</div>
)}
</FluentProvider>
);
}

View File

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

View File

@@ -261,4 +261,24 @@ export const initBackend = async (payload: InitPayload): Promise<{ status: strin
}
return data as { status: string; reason?: string };
};
};
// === Notice ===
export interface NoticeResponse {
type: 'md' | 'url';
content: string;
version: string | number;
}
export const getNotice = async (): Promise<NoticeResponse> => {
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',
};
};

View File

@@ -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<AdminDashboardProps> = ({
// 图片预览器状态
const [imageViewer, setImageViewer] = React.useState<{ open: boolean; src?: string; alt?: string }>({ open: false });
// 公告管理状态
const [noticeLoading, setNoticeLoading] = React.useState<boolean>(false);
const [noticeVersion, setNoticeVersion] = React.useState<number>(0);
const [noticeType, setNoticeType] = React.useState<'md' | 'url'>('md');
const [noticeContent, setNoticeContent] = React.useState<string>('');
React.useEffect(() => {
if (activeTab === 'systemSettings') {
setLoadingAudit(true);
@@ -278,6 +291,25 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
}
})
.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<AdminDashboardProps> = ({
<Tab value="postReview">稿</Tab>
<Tab value="complaintReview"></Tab>
<Tab value="imageManage"></Tab>
<Tab value="noticeManage"></Tab>
<Tab value="systemSettings"></Tab>
</TabList>
</div>
{/* 内容面板 */}
<div className={styles.contentPanel}>
{activeTab === 'systemSettings' ? (
{activeTab === 'noticeManage' ? (
<div>
<Text size={400} weight="semibold"></Text>
<div style={{ marginTop: tokens.spacingVerticalS }}>
<Text size={200} color="subtle">{noticeLoading ? '加载中...' : noticeVersion}</Text>
</div>
<div style={{ marginTop: tokens.spacingVerticalM, display: 'flex', gap: tokens.spacingHorizontalM, alignItems: 'center', flexWrap: 'wrap' }}>
<Text size={300}></Text>
<Dropdown
selectedOptions={[noticeType]}
onOptionSelect={(_, data) => {
const v = String(data.optionValue) as 'md' | 'url';
setNoticeType(v);
}}
>
<Option value="md">Markdown</Option>
<Option value="url">URL</Option>
</Dropdown>
</div>
<div style={{ marginTop: tokens.spacingVerticalM }}>
{noticeType === 'md' ? (
<div style={{ border: `1px solid ${tokens.colorNeutralStroke1}`, borderRadius: tokens.borderRadiusMedium, overflow: 'hidden' }}>
<MdEditor
value={noticeContent}
style={{ height: '360px' }}
renderHTML={(text) => (
<ReactMarkdown remarkPlugins={[remarkGfm, remarkIns, remarkBreaks]}>{text}</ReactMarkdown>
)}
onChange={({ text }: { text: string }) => setNoticeContent(text)}
/>
</div>
) : (
<Input
placeholder="输入要嵌入的网页地址URL"
value={noticeContent}
onChange={(e) => setNoticeContent((e.target as HTMLInputElement).value)}
/>
)}
</div>
<div style={{ marginTop: tokens.spacingVerticalM }}>
<Button appearance="primary" onClick={async () => {
try {
const payload = { type: noticeType, content: noticeContent };
await adminModifyNotice(payload);
toast.success('公告已修改');
setNoticeLoading(true);
const data = await getNotice();
setNoticeVersion(Number(data.version ?? 0) || 0);
setNoticeType((data.type === 'url' ? 'url' : 'md'));
setNoticeContent(String(data.content ?? ''));
} catch (e: any) {
const msg = String(e?.message || '修改公告失败');
if (msg.includes('401') || msg.includes('403') || msg.includes('登录已过期')) {
toast.error('身份验证失败,请重新登陆');
} else {
toast.error('修改公告失败');
}
} finally {
setNoticeLoading(false);
}
}}></Button>
</div>
</div>
) : activeTab === 'systemSettings' ? (
<div>
<Text size={400} weight="semibold"></Text>
@@ -1067,4 +1163,4 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
);
};
export default AdminDashboard;
export default AdminDashboard;

View File

@@ -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<AdminModifyPostProps> = ({ postId, initialConten
<MdEditor
value={content}
style={{ height: '500px' }}
renderHTML={(text) => <ReactMarkdown remarkPlugins={[remarkGfm, remarkIns]}>{text}</ReactMarkdown>}
renderHTML={(text) => <ReactMarkdown remarkPlugins={[remarkGfm, remarkIns, remarkBreaks]}>{text}</ReactMarkdown>}
onChange={handleEditorChange}
onImageUpload={handleImageUpload}
/>

View File

@@ -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<NoticeModalProps> = ({ data, onClose, onNeverShow }) => {
const styles = useStyles();
const { type, content, version } = data;
return (
<div className={styles.modalContent} role="dialog" aria-modal="true" aria-label="公告">
<Button
icon={<Dismiss24Regular />}
appearance="transparent"
className={styles.closeButton}
onClick={onClose}
aria-label="关闭"
/>
<Text as="h2" className={styles.title}></Text>
<div className={styles.contentBox}>
{type === 'md' ? (
<ReactMarkdown remarkPlugins={[remarkGfm, remarkIns, remarkBreaks]}>
{content}
</ReactMarkdown>
) : (
<iframe className={styles.iframe} src={content} title={`公告-${version}`} />
)}
</div>
<div className={styles.actions}>
<Button appearance="primary" onClick={onClose}></Button>
<Button appearance="subtle" onClick={() => onNeverShow(version)}></Button>
</div>
</div>
);
};
export default NoticeModal;