diff --git a/back/main.py b/back/main.py
index 35e16af..f86dbfd 100644
--- a/back/main.py
+++ b/back/main.py
@@ -97,6 +97,15 @@ class ImgFile(db.Model):
name = db.Column(db.String(255), nullable=True)
identity_token = db.Column(db.String(36), nullable=True)
+class SiteNotice(db.Model):
+ __tablename__ = 'site_notice'
+ id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+ type = db.Column(db.String(10), default='md', nullable=False)
+ content = db.Column(db.Text, default='', nullable=False)
+ version = db.Column(db.Integer, default=0, nullable=False)
+ created_at = db.Column(db.DateTime, default=lambda: datetime.now())
+ updated_at = db.Column(db.DateTime, default=lambda: datetime.now(), onupdate=datetime.now)
+
# 初始化数据库函数
def init_db():
if not os.path.exists('./data'):
@@ -105,6 +114,14 @@ def init_db():
os.makedirs(IMG_DIR)
with app.app_context():
db.create_all()
+ try:
+ existing = SiteNotice.query.first()
+ if not existing:
+ n = SiteNotice(type='md', content='', version=0)
+ db.session.add(n)
+ db.session.commit()
+ except Exception:
+ db.session.rollback()
def load_config():
global NEED_AUDIT, FILE_SIZE_LIMIT_MB, FILE_FORMATS
@@ -180,6 +197,23 @@ def get_about():
"data": "# 默认关于页面\n关于页面未设置,请前往管理面板操作。"
})
+@app.route('/api/site_notice', methods=['GET'])
+def get_site_notice():
+ try:
+ notice = SiteNotice.query.order_by(SiteNotice.id.asc()).first()
+ if not notice:
+ notice = SiteNotice(type='md', content='', version=0)
+ db.session.add(notice)
+ db.session.commit()
+ data = {
+ "type": notice.type if notice.type in ['md', 'url'] else 'md',
+ "content": notice.content or '',
+ "version": int(notice.version or 0),
+ }
+ return jsonify({"code": 1000, "data": data})
+ except Exception as e:
+ return jsonify({"code": 2003, "data": str(e)})
+
@app.route('/api/get_id_token', methods=['GET'])
def get_id_token():
try:
diff --git a/front/src/App.tsx b/front/src/App.tsx
index 7a22fb3..9d4ff19 100644
--- a/front/src/App.tsx
+++ b/front/src/App.tsx
@@ -6,10 +6,12 @@ import About from './components/About';
import CreatePost from './components/CreatePost';
import PostCard from './components/PostCard';
import ImageViewer from './components/ImageViewer';
-import { fetchArticles, reset_identity_token, type Article } from './api';
+import { fetchArticles, reset_identity_token, getSiteNotice, type Article } from './api';
import { useLayout } from './context/LayoutContext';
import './App.css';
import { Eye24Regular, EyeOff24Regular, Copy24Regular } from '@fluentui/react-icons';
+import NoticeModal from './components/NoticeModal';
+import type { NoticeData } from './components/NoticeModal';
const Home: React.FC<{ onPreviewImage: (src: string, alt?: string) => void }> = ({ onPreviewImage }) => {
const { refreshTrigger } = useLayout();
@@ -152,6 +154,55 @@ const Home: React.FC<{ onPreviewImage: (src: string, alt?: string) => void }> =
const NotFound = () =>
404 Not Found
;
+const NoticeOverlay: React.FC = () => {
+ const { settings } = useLayout();
+ const [noticeData, setNoticeData] = useState(null);
+ const [showNotice, setShowNotice] = useState(false);
+
+ useEffect(() => {
+ if (!settings || settings.enableNotice !== true) {
+ setShowNotice(false);
+ return;
+ }
+ getSiteNotice().then(data => {
+ const savedVersion = localStorage.getItem('site_notice_version');
+ if (data.content && (!savedVersion || Number(savedVersion) < Number(data.version))) {
+ setNoticeData({
+ type: data.type,
+ content: data.content,
+ version: data.version,
+ });
+ setShowNotice(true);
+ }
+ }).catch(console.error);
+ }, [settings]);
+
+ if (!showNotice || !noticeData) return null;
+ return (
+
+ setShowNotice(false)}
+ onNeverShow={(version) => {
+ localStorage.setItem('site_notice_version', String(version));
+ setShowNotice(false);
+ }}
+ />
+
+ );
+};
+
function App() {
const [imageViewer, setImageViewer] = useState<{ open: boolean; src?: string; alt?: string }>({ open: false });
const openImageViewer = (src?: string, alt?: string) => {
@@ -254,6 +305,7 @@ function App() {
+
>
}
/>
diff --git a/front/src/api.ts b/front/src/api.ts
index 2b37abb..c260f38 100644
--- a/front/src/api.ts
+++ b/front/src/api.ts
@@ -4,6 +4,7 @@ export interface SiteSettings {
enableCodeIcon: boolean;
repoUrl: string;
favicon: string;
+ enableNotice?: boolean;
fileSizeLimit?: number;
fileFormats?: string[];
}
@@ -38,6 +39,7 @@ export const getSettings = async (): Promise => {
enableCodeIcon: data.enable_repo_button ?? true,
repoUrl: data.repo_link,
favicon: data.icon,
+ enableNotice: data.enable_notice ?? false,
fileSizeLimit: data.file_size_limit != null ? Number(data.file_size_limit) : undefined,
fileFormats,
};
@@ -74,6 +76,12 @@ export interface StaticsData {
images: number;
}
+export interface SiteNoticeData {
+ type: 'md' | 'url';
+ content: string;
+ version: number;
+}
+
export interface HotTopicItem {
name: string;
count: number;
@@ -141,6 +149,27 @@ export const get_id_token = async (): Promise => {
return token;
};
+export const getSiteNotice = async (): Promise => {
+ try {
+ const response = await fetch('/api/site_notice');
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ const json = await response.json();
+ if (json.code === 1000 && json.data) {
+ return {
+ type: (json.data.type === 'url' ? 'url' : 'md') as 'md' | 'url',
+ content: String(json.data.content ?? ''),
+ version: Number(json.data.version ?? 0),
+ };
+ }
+ throw new Error('Invalid response code or missing data');
+ } catch (error) {
+ console.error('Failed to fetch site notice:', error);
+ throw error;
+ }
+};
+
const notifyInvalidIdentity = () => {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('identity_invalid'));
diff --git a/front/src/components/NoticeModal.tsx b/front/src/components/NoticeModal.tsx
new file mode 100644
index 0000000..64b7db1
--- /dev/null
+++ b/front/src/components/NoticeModal.tsx
@@ -0,0 +1,93 @@
+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 (
+
+
}
+ appearance="transparent"
+ className={styles.closeButton}
+ onClick={onClose}
+ aria-label="关闭"
+ />
+
公告
+
+ {type === 'md' ? (
+
+ {content}
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default NoticeModal;