mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-12 02:20:28 +08:00
feat: add notices feature with API, database model, and UI integration
This commit is contained in:
@@ -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)
|
||||
|
||||
3
domain/notices/__init__.py
Normal file
3
domain/notices/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .service import NoticeService, notice_sync_service
|
||||
|
||||
__all__ = ["NoticeService", "notice_sync_service"]
|
||||
36
domain/notices/api.py
Normal file
36
domain/notices/api.py
Normal 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
177
domain/notices/service.py
Normal 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
16
domain/notices/types.py
Normal 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
|
||||
3
main.py
3
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()
|
||||
|
||||
@@ -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) # 插件唯一标识
|
||||
|
||||
@@ -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' });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user