增加公告功能
This commit is contained in:
38
src/App.tsx
38
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<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
|
||||
22
src/api.ts
22
src/api.ts
@@ -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',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
94
src/components/NoticeModal.tsx
Normal file
94
src/components/NoticeModal.tsx
Normal 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;
|
||||
|
||||
Reference in New Issue
Block a user