增加公告功能
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 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';
|
||||||
@@ -17,6 +17,7 @@ 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';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [isDarkMode, setIsDarkMode] = React.useState(false);
|
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 [imageViewer, setImageViewer] = useState<{ open: boolean; src?: string; alt?: string }>({ open: false });
|
||||||
const THEME_PREF_KEY = 'ThemePref';
|
const THEME_PREF_KEY = 'ThemePref';
|
||||||
const userPrefRef = useRef<boolean>(false);
|
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) => {
|
const openImageViewer = (src?: string, alt?: string) => {
|
||||||
if (!src) return;
|
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(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const signal = controller.signal;
|
const signal = controller.signal;
|
||||||
@@ -230,6 +254,18 @@ 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} />
|
||||||
)}
|
)}
|
||||||
|
{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>
|
</FluentProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,23 @@ export const adminApiRequest = async (
|
|||||||
return response;
|
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 与文件名
|
* 创建备份并返回 ZIP 文件 Blob 与文件名
|
||||||
* GET /admin/get/backup -> ZIP
|
* GET /admin/get/backup -> ZIP
|
||||||
@@ -809,4 +826,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();
|
||||||
};
|
};
|
||||||
|
|||||||
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 };
|
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,
|
DialogActions,
|
||||||
Input,
|
Input,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Dropdown,
|
||||||
|
Option,
|
||||||
shorthands,
|
shorthands,
|
||||||
} from '@fluentui/react-components';
|
} from '@fluentui/react-components';
|
||||||
import type { TabValue } 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 AdminPostCard from './AdminPostCard';
|
||||||
import AdminModifyPost from './AdminModifyPost';
|
import AdminModifyPost from './AdminModifyPost';
|
||||||
import AdminManageComments from './AdminManageComments';
|
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';
|
import ImageViewer from './ImageViewer';
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
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 [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(() => {
|
React.useEffect(() => {
|
||||||
if (activeTab === 'systemSettings') {
|
if (activeTab === 'systemSettings') {
|
||||||
setLoadingAudit(true);
|
setLoadingAudit(true);
|
||||||
@@ -278,6 +291,25 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => setReportsLoading(false));
|
.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]);
|
}, [activeTab, picPage]);
|
||||||
|
|
||||||
@@ -668,13 +700,77 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
|||||||
<Tab value="postReview">投稿审核</Tab>
|
<Tab value="postReview">投稿审核</Tab>
|
||||||
<Tab value="complaintReview">投诉审核</Tab>
|
<Tab value="complaintReview">投诉审核</Tab>
|
||||||
<Tab value="imageManage">图片管理</Tab>
|
<Tab value="imageManage">图片管理</Tab>
|
||||||
|
<Tab value="noticeManage">公告管理</Tab>
|
||||||
<Tab value="systemSettings">系统设置</Tab>
|
<Tab value="systemSettings">系统设置</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 内容面板 */}
|
{/* 内容面板 */}
|
||||||
<div className={styles.contentPanel}>
|
<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>
|
<div>
|
||||||
<Text size={400} weight="semibold">系统设置</Text>
|
<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 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 { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import 'react-toastify/dist/ReactToastify.css';
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
import { uploadImage } from '../api';
|
import { uploadImage } from '../api';
|
||||||
@@ -134,7 +135,7 @@ const AdminModifyPost: React.FC<AdminModifyPostProps> = ({ postId, initialConten
|
|||||||
<MdEditor
|
<MdEditor
|
||||||
value={content}
|
value={content}
|
||||||
style={{ height: '500px' }}
|
style={{ height: '500px' }}
|
||||||
renderHTML={(text) => <ReactMarkdown remarkPlugins={[remarkGfm, remarkIns]}>{text}</ReactMarkdown>}
|
renderHTML={(text) => <ReactMarkdown remarkPlugins={[remarkGfm, remarkIns, remarkBreaks]}>{text}</ReactMarkdown>}
|
||||||
onChange={handleEditorChange}
|
onChange={handleEditorChange}
|
||||||
onImageUpload={handleImageUpload}
|
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