diff --git a/api/routers.py b/api/routers.py index 8e290b5..fb311a5 100644 --- a/api/routers.py +++ b/api/routers.py @@ -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) diff --git a/domain/notices/__init__.py b/domain/notices/__init__.py new file mode 100644 index 0000000..0f8bcf1 --- /dev/null +++ b/domain/notices/__init__.py @@ -0,0 +1,3 @@ +from .service import NoticeService, notice_sync_service + +__all__ = ["NoticeService", "notice_sync_service"] diff --git a/domain/notices/api.py b/domain/notices/api.py new file mode 100644 index 0000000..a3c927b --- /dev/null +++ b/domain/notices/api.py @@ -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() diff --git a/domain/notices/service.py b/domain/notices/service.py new file mode 100644 index 0000000..f27525b --- /dev/null +++ b/domain/notices/service.py @@ -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() diff --git a/domain/notices/types.py b/domain/notices/types.py new file mode 100644 index 0000000..fe44f09 --- /dev/null +++ b/domain/notices/types.py @@ -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 diff --git a/main.py b/main.py index 1b84210..3f980ae 100644 --- a/main.py +++ b/main.py @@ -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() diff --git a/models/database.py b/models/database.py index e03e92b..cc4876b 100644 --- a/models/database.py +++ b/models/database.py @@ -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) # 插件唯一标识 diff --git a/web/src/api/notices.ts b/web/src/api/notices.ts index 0c85145..c57cada 100644 --- a/web/src/api/notices.ts +++ b/web/src/api/notices.ts @@ -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 => { - 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(`/notices?page=${params.page ?? 1}`); + }, + getPopup: async (): Promise => { + return await request('/notices/popup'); + }, + dismiss: async (id: number): Promise => { + await request(`/notices/${id}/dismiss`, { method: 'POST' }); }, }; - diff --git a/web/src/components/NoticesModal.tsx b/web/src/components/NoticesModal.tsx index 35ed72c..a8b70f2 100644 --- a/web/src/components/NoticesModal.tsx +++ b/web/src/components/NoticesModal.tsx @@ -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([]); @@ -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; - diff --git a/web/src/layout/TopHeader.tsx b/web/src/layout/TopHeader.tsx index 93ca8d1..03fa036 100644 --- a/web/src/layout/TopHeader.tsx +++ b/web/src/layout/TopHeader.tsx @@ -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(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 (
} aria-label={t('Notices')} - onClick={() => setNoticesOpen(true)} + onClick={openNotices} style={{ paddingInline: 8, height: 40 }} /> @@ -133,7 +163,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent, - setNoticesOpen(false)} version={status?.version || ''} /> +
);