mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-17 05:47:35 +08:00
feat: add notices feature with API, database model, and UI integration
This commit is contained in:
@@ -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