mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-07 15:21:42 +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.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)
|
||||||
|
|||||||
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 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()
|
||||||
|
|||||||
@@ -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) # 插件唯一标识
|
||||||
|
|||||||
@@ -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();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user