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

View File

@@ -247,6 +247,20 @@ class RecentFile(Model):
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):
id = fields.IntField(pk=True)
key = fields.CharField(max_length=100, unique=True) # 插件唯一标识

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
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 { memo, useMemo, useState } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import SearchDialog from './SearchDialog.tsx';
import { authApi } from '../api/auth.ts';
import { useNavigate } from 'react-router';
@@ -9,8 +9,8 @@ import LanguageSwitcher from '../components/LanguageSwitcher';
import { useAuth } from '../contexts/AuthContext';
import ProfileModal from '../components/ProfileModal';
import NoticesModal from '../components/NoticesModal';
import { useSystemStatus } from '../contexts/SystemContext';
import useResponsive from '../hooks/useResponsive';
import { noticesApi, type NoticeItem } from '../api/notices';
const { Header } = Layout;
@@ -30,7 +30,8 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
const [profileOpen, setProfileOpen] = useState(false);
const [clientAuthOpen, setClientAuthOpen] = 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 clientAuthPayload = useMemo(() => JSON.stringify({
base_url: window.location.origin,
@@ -44,6 +45,35 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
const openProfile = () => setProfileOpen(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 (
<Header
@@ -84,7 +114,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
type="text"
icon={<BellOutlined />}
aria-label={t('Notices')}
onClick={() => setNoticesOpen(true)}
onClick={openNotices}
style={{ paddingInline: 8, height: 40 }}
/>
</Tooltip>
@@ -133,7 +163,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
<QRCode value={clientAuthPayload} size={220} />
</Flex>
</Modal>
<NoticesModal open={noticesOpen} onClose={() => setNoticesOpen(false)} version={status?.version || ''} />
<NoticesModal open={noticesOpen} onClose={closeNotices} initialNotice={popupMode ? popupNotice : null} />
</Flex>
</Header>
);