Add site notice and timestamp display updates

This commit is contained in:
LeonspaceX
2026-01-30 18:42:41 +08:00
parent abc8d58bf4
commit 183032aa90
4 changed files with 209 additions and 1 deletions

View File

@@ -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:

View File

@@ -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 = () => <h1>404 Not Found</h1>;
const NoticeOverlay: React.FC = () => {
const { settings } = useLayout();
const [noticeData, setNoticeData] = useState<NoticeData | null>(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 (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 2000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<NoticeModal
data={noticeData}
onClose={() => setShowNotice(false)}
onNeverShow={(version) => {
localStorage.setItem('site_notice_version', String(version));
setShowNotice(false);
}}
/>
</div>
);
};
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() {
</DialogBody>
</DialogSurface>
</Dialog>
<NoticeOverlay />
</>
}
/>

View File

@@ -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<SiteSettings> => {
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<string> => {
return token;
};
export const getSiteNotice = async (): Promise<SiteNoticeData> => {
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'));

View File

@@ -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<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;