feat: add notices feature with API, database model, and UI integration

This commit is contained in:
shiyu
2026-05-09 21:40:15 +08:00
parent a745c5975a
commit 56b48b28a1
10 changed files with 304 additions and 44 deletions

View File

@@ -6,6 +6,7 @@ from domain.backup import api as backup
from domain.config import api as config from domain.config import api as config
from domain.email import api as email from domain.email import api as email
from domain.offline_downloads import api as offline_downloads from domain.offline_downloads import api as offline_downloads
from domain.notices import api as notices
from domain.plugins import api as plugins from domain.plugins import api as plugins
from domain.processors import api as processors from domain.processors import api as processors
from domain.share import api as share from domain.share import api as share
@@ -41,6 +42,7 @@ def include_routers(app: FastAPI):
app.include_router(webdav_api.router) app.include_router(webdav_api.router)
app.include_router(s3_api.router) app.include_router(s3_api.router)
app.include_router(offline_downloads.router) app.include_router(offline_downloads.router)
app.include_router(notices.router)
app.include_router(email.router) app.include_router(email.router)
app.include_router(audit.router) app.include_router(audit.router)
app.include_router(permission.router) app.include_router(permission.router)

View File

@@ -0,0 +1,3 @@
from .service import NoticeService, notice_sync_service
__all__ = ["NoticeService", "notice_sync_service"]

36
domain/notices/api.py Normal file
View File

@@ -0,0 +1,36 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from api.response import success
from domain.auth import User, get_current_active_user
from .service import NoticeService
router = APIRouter(prefix="/api/notices", tags=["notices"])
@router.get("")
async def list_notices(
current_user: Annotated[User, Depends(get_current_active_user)],
page: int = Query(1, ge=1),
):
data = await NoticeService.list_notices(page=page)
return data.model_dump()
@router.get("/popup")
async def get_popup_notice(
current_user: Annotated[User, Depends(get_current_active_user)],
):
item = await NoticeService.get_popup_notice()
return success(item.model_dump() if item else None)
@router.post("/{notice_id}/dismiss")
async def dismiss_popup_notice(
notice_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
):
await NoticeService.dismiss_popup(notice_id)
return success()

177
domain/notices/service.py Normal file
View File

@@ -0,0 +1,177 @@
import asyncio
import logging
from datetime import datetime, timezone
from typing import Any
import httpx
from domain.config import VERSION
from models.database import Notice
from .types import NoticeItem, NoticeListResponse
logger = logging.getLogger(__name__)
REMOTE_NOTICES_URL = "https://foxel.cc/api/notices"
SYNC_INTERVAL_SECONDS = 60 * 60 * 24
PAGE_SIZE = 20
def _normalize_version(version: str) -> str:
return (version or "").strip().removeprefix("v").removeprefix("V")
def _parse_remote_time(value: Any) -> datetime:
if isinstance(value, (int, float)):
timestamp = float(value)
if timestamp > 10_000_000_000:
timestamp = timestamp / 1000
return datetime.fromtimestamp(timestamp, timezone.utc)
if isinstance(value, str):
text = value.strip()
if not text:
return datetime.now(timezone.utc)
try:
if text.isdigit():
return _parse_remote_time(int(text))
return datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
return datetime.now(timezone.utc)
return datetime.now(timezone.utc)
class NoticeService:
@classmethod
async def list_notices(cls, page: int = 1, page_size: int = PAGE_SIZE) -> NoticeListResponse:
page = max(1, page)
page_size = max(1, min(page_size, 100))
query = Notice.all().order_by("-created_at", "-id")
total = await query.count()
notices = await query.offset((page - 1) * page_size).limit(page_size)
return NoticeListResponse(
items=[cls._to_item(item) for item in notices],
page=page,
pageSize=page_size,
total=total,
)
@classmethod
async def get_popup_notice(cls) -> NoticeItem | None:
notice = await Notice.filter(is_popup=True, popup_dismissed=False).order_by("-created_at", "-id").first()
if not notice:
return None
return cls._to_item(notice)
@classmethod
async def dismiss_popup(cls, notice_id: int) -> None:
await Notice.filter(id=notice_id).update(popup_dismissed=True, is_popup=False)
@classmethod
async def sync_remote_notices(cls) -> None:
items = await cls._fetch_remote_notices()
if not items:
return
popup_remote_ids: list[int] = []
for raw in items:
remote_id = raw.get("id")
if remote_id is None:
continue
try:
remote_id = int(remote_id)
except (TypeError, ValueError):
continue
is_popup = bool(raw.get("isPopup"))
if is_popup:
popup_remote_ids.append(remote_id)
notice = await Notice.get_or_none(remote_id=remote_id)
popup_dismissed = notice.popup_dismissed if notice else False
await Notice.update_or_create(
remote_id=remote_id,
defaults={
"title": str(raw.get("title") or "")[:255],
"content_md": str(raw.get("contentMd") or ""),
"is_popup": is_popup and not popup_dismissed,
"created_at": _parse_remote_time(raw.get("createdAt")),
},
)
await cls._keep_only_latest_popup(popup_remote_ids)
@classmethod
async def _keep_only_latest_popup(cls, popup_remote_ids: list[int]) -> None:
latest = await Notice.filter(remote_id__in=popup_remote_ids, popup_dismissed=False).order_by(
"-created_at", "-id"
).first()
if not latest:
return
await Notice.filter(is_popup=True).exclude(id=latest.id).update(is_popup=False)
@classmethod
async def _fetch_remote_notices(cls) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
page = 1
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
while True:
resp = await client.get(
REMOTE_NOTICES_URL,
params={"version": _normalize_version(VERSION), "page": page},
)
resp.raise_for_status()
data = resp.json()
items = data.get("items") if isinstance(data, dict) else None
if not isinstance(items, list):
break
results.extend(item for item in items if isinstance(item, dict))
total = data.get("total", len(results)) if isinstance(data, dict) else len(results)
page_size = data.get("pageSize") or data.get("page_size") or len(items)
if not items or len(results) >= int(total or 0) or page_size <= 0:
break
page += 1
return results
@staticmethod
def _to_item(notice: Notice) -> NoticeItem:
return NoticeItem(
id=notice.id,
title=notice.title,
contentMd=notice.content_md or "",
isPopup=notice.is_popup and not notice.popup_dismissed,
createdAt=int(notice.created_at.timestamp() * 1000),
)
class NoticeSyncService:
def __init__(self):
self._worker: asyncio.Task | None = None
self._stop_event = asyncio.Event()
async def start(self) -> None:
if self._worker and not self._worker.done():
return
self._stop_event.clear()
self._worker = asyncio.create_task(self._run_loop())
async def stop(self) -> None:
if not self._worker:
return
self._stop_event.set()
await self._worker
self._worker = None
async def _run_loop(self) -> None:
while not self._stop_event.is_set():
try:
await NoticeService.sync_remote_notices()
except Exception:
logger.exception("Failed to sync notices")
try:
await asyncio.wait_for(self._stop_event.wait(), timeout=SYNC_INTERVAL_SECONDS)
except asyncio.TimeoutError:
pass
notice_sync_service = NoticeSyncService()

16
domain/notices/types.py Normal file
View File

@@ -0,0 +1,16 @@
from pydantic import BaseModel
class NoticeItem(BaseModel):
id: int
title: str
contentMd: str
isPopup: bool
createdAt: int
class NoticeListResponse(BaseModel):
items: list[NoticeItem]
page: int
pageSize: int
total: int

View File

@@ -23,6 +23,7 @@ import httpx
from dotenv import load_dotenv from dotenv import load_dotenv
from domain.tasks import task_queue_service, task_scheduler from domain.tasks import task_queue_service, task_scheduler
from domain.role.service import RoleService from domain.role.service import RoleService
from domain.notices import notice_sync_service
load_dotenv() load_dotenv()
@@ -77,6 +78,7 @@ async def lifespan(app: FastAPI):
from domain.plugins import init_plugins from domain.plugins import init_plugins
await init_plugins(app) await init_plugins(app)
await task_scheduler.start() await task_scheduler.start()
await notice_sync_service.start()
# 在所有路由加载完成后,挂载静态文件服务(放在最后以避免覆盖 API 路由) # 在所有路由加载完成后,挂载静态文件服务(放在最后以避免覆盖 API 路由)
app.mount("/", SPAStaticFiles(directory="web/dist", html=True, check_dir=False), name="static") app.mount("/", SPAStaticFiles(directory="web/dist", html=True, check_dir=False), name="static")
@@ -85,6 +87,7 @@ async def lifespan(app: FastAPI):
try: try:
yield yield
finally: finally:
await notice_sync_service.stop()
await task_scheduler.stop() await task_scheduler.stop()
await task_queue_service.stop_worker() await task_queue_service.stop_worker()
await close_db() await close_db()

View File

@@ -247,6 +247,20 @@ class RecentFile(Model):
unique_together = (("user", "path"),) unique_together = (("user", "path"),)
class Notice(Model):
id = fields.IntField(pk=True)
remote_id = fields.IntField(unique=True, index=True)
title = fields.CharField(max_length=255)
content_md = fields.TextField(null=True)
is_popup = fields.BooleanField(default=False)
popup_dismissed = fields.BooleanField(default=False)
created_at = fields.DatetimeField()
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "notices"
class Plugin(Model): class Plugin(Model):
id = fields.IntField(pk=True) id = fields.IntField(pk=True)
key = fields.CharField(max_length=100, unique=True) # 插件唯一标识 key = fields.CharField(max_length=100, unique=True) # 插件唯一标识

View File

@@ -1,3 +1,5 @@
import request from './client';
export interface NoticeItem { export interface NoticeItem {
id: number; id: number;
title: string; title: string;
@@ -14,42 +16,17 @@ export interface GetNoticesResponse {
} }
export interface GetNoticesParams { export interface GetNoticesParams {
version: string;
page?: number; page?: number;
} }
const FOXEL_CORE_BASE = 'https://foxel.cc';
function normalizeVersion(version: string) {
return (version || '').trim().replace(/^v/i, '');
}
function extractErrorMessage(data: any) {
if (!data) return '';
if (typeof data === 'string') return data;
if (typeof data.detail === 'string') return data.detail;
if (typeof data.code === 'string') return data.code;
if (typeof data.message === 'string') return data.message;
if (typeof data.msg === 'string') return data.msg;
return '';
}
export const noticesApi = { export const noticesApi = {
list: async (params: GetNoticesParams): Promise<GetNoticesResponse> => { list: async (params: GetNoticesParams): Promise<GetNoticesResponse> => {
const url = new URL('/api/notices', FOXEL_CORE_BASE); return await request<GetNoticesResponse>(`/notices?page=${params.page ?? 1}`);
url.searchParams.set('version', normalizeVersion(params.version)); },
url.searchParams.set('page', String(params.page ?? 1)); getPopup: async (): Promise<NoticeItem | null> => {
return await request<NoticeItem | null>('/notices/popup');
const resp = await fetch(url.href); },
if (!resp.ok) { dismiss: async (id: number): Promise<void> => {
let msg = resp.statusText || `Request failed: ${resp.status}`; await request(`/notices/${id}/dismiss`, { method: 'POST' });
try {
const data = await resp.json();
msg = extractErrorMessage(data) || msg;
} catch { void 0; }
throw new Error(msg);
}
return await resp.json();
}, },
}; };

View File

@@ -7,11 +7,11 @@ import { useI18n } from '../i18n';
export interface NoticesModalProps { export interface NoticesModalProps {
open: boolean; open: boolean;
version: string;
onClose: () => void; onClose: () => void;
initialNotice?: NoticeItem | null;
} }
const NoticesModal = memo(function NoticesModal({ open, version, onClose }: NoticesModalProps) { const NoticesModal = memo(function NoticesModal({ open, onClose, initialNotice }: NoticesModalProps) {
const { token } = theme.useToken(); const { token } = theme.useToken();
const { t } = useI18n(); const { t } = useI18n();
const [items, setItems] = useState<NoticeItem[]>([]); const [items, setItems] = useState<NoticeItem[]>([]);
@@ -28,12 +28,15 @@ const NoticesModal = memo(function NoticesModal({ open, version, onClose }: Noti
if (mode === 'replace') setLoading(true); if (mode === 'replace') setLoading(true);
else setLoadingMore(true); else setLoadingMore(true);
try { try {
const resp = await noticesApi.list({ version, page: targetPage }); const resp = await noticesApi.list({ page: targetPage });
setPage(resp.page ?? targetPage); setPage(resp.page ?? targetPage);
setTotal(resp.total ?? 0); setTotal(resp.total ?? 0);
setItems(prev => mode === 'replace' ? resp.items : [...prev, ...resp.items]); const nextItems = mode === 'replace' && initialNotice && !resp.items.some(item => item.id === initialNotice.id)
? [initialNotice, ...resp.items]
: resp.items;
setItems(prev => mode === 'replace' ? nextItems : [...prev, ...resp.items]);
if (mode === 'replace') { if (mode === 'replace') {
setSelectedId(resp.items[0]?.id ?? null); setSelectedId(initialNotice?.id ?? resp.items[0]?.id ?? null);
} else { } else {
setSelectedId(prev => prev ?? resp.items[0]?.id ?? null); setSelectedId(prev => prev ?? resp.items[0]?.id ?? null);
} }
@@ -55,7 +58,7 @@ const NoticesModal = memo(function NoticesModal({ open, version, onClose }: Noti
setSelectedId(null); setSelectedId(null);
loadPage(1, 'replace'); loadPage(1, 'replace');
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, version]); }, [open, initialNotice?.id]);
const formatTime = (ts: number) => { const formatTime = (ts: number) => {
try { try {
@@ -181,4 +184,3 @@ const NoticesModal = memo(function NoticesModal({ open, version, onClose }: Noti
}); });
export default NoticesModal; export default NoticesModal;

View File

@@ -1,6 +1,6 @@
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography, Tooltip, Modal, QRCode } from 'antd'; import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography, Tooltip, Modal, QRCode } from 'antd';
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined, BellOutlined, QrcodeOutlined } from '@ant-design/icons'; import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined, BellOutlined, QrcodeOutlined } from '@ant-design/icons';
import { memo, useMemo, useState } from 'react'; import { memo, useEffect, useMemo, useState } from 'react';
import SearchDialog from './SearchDialog.tsx'; import SearchDialog from './SearchDialog.tsx';
import { authApi } from '../api/auth.ts'; import { authApi } from '../api/auth.ts';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@@ -9,8 +9,8 @@ import LanguageSwitcher from '../components/LanguageSwitcher';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import ProfileModal from '../components/ProfileModal'; import ProfileModal from '../components/ProfileModal';
import NoticesModal from '../components/NoticesModal'; import NoticesModal from '../components/NoticesModal';
import { useSystemStatus } from '../contexts/SystemContext';
import useResponsive from '../hooks/useResponsive'; import useResponsive from '../hooks/useResponsive';
import { noticesApi, type NoticeItem } from '../api/notices';
const { Header } = Layout; const { Header } = Layout;
@@ -30,7 +30,8 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
const [profileOpen, setProfileOpen] = useState(false); const [profileOpen, setProfileOpen] = useState(false);
const [clientAuthOpen, setClientAuthOpen] = useState(false); const [clientAuthOpen, setClientAuthOpen] = useState(false);
const [noticesOpen, setNoticesOpen] = useState(false); const [noticesOpen, setNoticesOpen] = useState(false);
const status = useSystemStatus(); const [popupNotice, setPopupNotice] = useState<NoticeItem | null>(null);
const [popupMode, setPopupMode] = useState(false);
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
const clientAuthPayload = useMemo(() => JSON.stringify({ const clientAuthPayload = useMemo(() => JSON.stringify({
base_url: window.location.origin, base_url: window.location.origin,
@@ -44,6 +45,35 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
const openProfile = () => setProfileOpen(true); const openProfile = () => setProfileOpen(true);
const openClientAuth = () => setClientAuthOpen(true); const openClientAuth = () => setClientAuthOpen(true);
const openNotices = () => {
setPopupMode(false);
setNoticesOpen(true);
};
const closeNotices = async () => {
const shouldDismiss = popupMode && popupNotice;
setNoticesOpen(false);
setPopupMode(false);
if (shouldDismiss) {
try {
await noticesApi.dismiss(popupNotice.id);
setPopupNotice(null);
} catch { void 0; }
}
};
useEffect(() => {
let cancelled = false;
if (!authToken) return;
noticesApi.getPopup().then((notice) => {
if (cancelled || !notice) return;
setPopupNotice(notice);
setPopupMode(true);
setNoticesOpen(true);
}).catch(() => void 0);
return () => {
cancelled = true;
};
}, [authToken]);
return ( return (
<Header <Header
@@ -84,7 +114,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
type="text" type="text"
icon={<BellOutlined />} icon={<BellOutlined />}
aria-label={t('Notices')} aria-label={t('Notices')}
onClick={() => setNoticesOpen(true)} onClick={openNotices}
style={{ paddingInline: 8, height: 40 }} style={{ paddingInline: 8, height: 40 }}
/> />
</Tooltip> </Tooltip>
@@ -133,7 +163,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
<QRCode value={clientAuthPayload} size={220} /> <QRCode value={clientAuthPayload} size={220} />
</Flex> </Flex>
</Modal> </Modal>
<NoticesModal open={noticesOpen} onClose={() => setNoticesOpen(false)} version={status?.version || ''} /> <NoticesModal open={noticesOpen} onClose={closeNotices} initialNotice={popupMode ? popupNotice : null} />
</Flex> </Flex>
</Header> </Header>
); );