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 ( +
+