From 3e1b75d81a9b08c9c73ec3ea725f29696c6ffaf6 Mon Sep 17 00:00:00 2001 From: shiyu Date: Wed, 14 Jan 2026 22:01:29 +0800 Subject: [PATCH] feat: add notices feature with modal and API integration --- web/src/api/notices.ts | 55 +++++++++ web/src/components/NoticesModal.tsx | 184 ++++++++++++++++++++++++++++ web/src/i18n/locales/en.json | 3 + web/src/i18n/locales/zh.json | 3 + web/src/layout/TopHeader.tsx | 16 ++- 5 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 web/src/api/notices.ts create mode 100644 web/src/components/NoticesModal.tsx diff --git a/web/src/api/notices.ts b/web/src/api/notices.ts new file mode 100644 index 0000000..0c85145 --- /dev/null +++ b/web/src/api/notices.ts @@ -0,0 +1,55 @@ +export interface NoticeItem { + id: number; + title: string; + contentMd: string; + isPopup: boolean; + createdAt: number; +} + +export interface GetNoticesResponse { + items: NoticeItem[]; + page: number; + pageSize: number; + total: number; +} + +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(); + }, +}; + diff --git a/web/src/components/NoticesModal.tsx b/web/src/components/NoticesModal.tsx new file mode 100644 index 0000000..35ed72c --- /dev/null +++ b/web/src/components/NoticesModal.tsx @@ -0,0 +1,184 @@ +import { memo, useEffect, useMemo, useState } from 'react'; +import { Modal, List, Typography, theme, Flex, Button, Empty, message, Divider, Spin } from 'antd'; +import ReactMarkdown from 'react-markdown'; +import { format } from 'date-fns'; +import { noticesApi, type NoticeItem } from '../api/notices'; +import { useI18n } from '../i18n'; + +export interface NoticesModalProps { + open: boolean; + version: string; + onClose: () => void; +} + +const NoticesModal = memo(function NoticesModal({ open, version, onClose }: NoticesModalProps) { + const { token } = theme.useToken(); + const { t } = useI18n(); + const [items, setItems] = useState([]); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [selectedId, setSelectedId] = useState(null); + + const selected = useMemo(() => items.find(i => i.id === selectedId) ?? null, [items, selectedId]); + const hasMore = items.length < total; + + const loadPage = async (targetPage: number, mode: 'replace' | 'append') => { + if (mode === 'replace') setLoading(true); + else setLoadingMore(true); + try { + const resp = await noticesApi.list({ version, page: targetPage }); + setPage(resp.page ?? targetPage); + setTotal(resp.total ?? 0); + setItems(prev => mode === 'replace' ? resp.items : [...prev, ...resp.items]); + if (mode === 'replace') { + setSelectedId(resp.items[0]?.id ?? null); + } else { + setSelectedId(prev => prev ?? resp.items[0]?.id ?? null); + } + } catch (e) { + if (e instanceof Error) { + message.error(e.message || t('Error')); + } + } finally { + setLoading(false); + setLoadingMore(false); + } + }; + + useEffect(() => { + if (!open) return; + setItems([]); + setPage(1); + setTotal(0); + setSelectedId(null); + loadPage(1, 'replace'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, version]); + + const formatTime = (ts: number) => { + try { + return format(new Date(ts), 'yyyy-MM-dd HH:mm'); + } catch { + return ''; + } + }; + + return ( + + +
+
+ {t('Total')}: {total} + {items.length}/{total} +
+ { + const isSelected = item.id === selectedId; + return ( + setSelectedId(item.id)} + style={{ + cursor: 'pointer', + background: isSelected ? 'rgba(22,119,255,0.08)' : undefined, + borderInlineStart: isSelected ? `3px solid ${token.colorPrimary}` : '3px solid transparent', + paddingInlineStart: 10, + }} + > + {item.title}} + description={{formatTime(item.createdAt)}} + /> + + ); + }} + /> +
+ +
+
+ + +
+
+ ); +}); + +export default NoticesModal; + diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1fd1f3b..468c267 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -693,6 +693,9 @@ "Open with {app}": "Open with {app}", "Set as default for .{ext}": "Set as default for .{ext}", "AI Agent": "AI Agent", + "Notices": "Notices", + "No notices": "No notices", + "Load more": "Load more", "Auto execute": "Auto execute", "Start a conversation": "Start a conversation", "No content": "No content", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 3d71fbb..af2cc52 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -695,6 +695,9 @@ "Open with {app}": "使用 {app} 打开", "Set as default for .{ext}": "设为该类型(.{ext})默认应用", "AI Agent": "AI 助手", + "Notices": "公告", + "No notices": "暂无公告", + "Load more": "加载更多", "Auto execute": "自动执行", "Start a conversation": "开始对话", "No content": "无内容", diff --git a/web/src/layout/TopHeader.tsx b/web/src/layout/TopHeader.tsx index 0b24128..510cf7b 100644 --- a/web/src/layout/TopHeader.tsx +++ b/web/src/layout/TopHeader.tsx @@ -1,5 +1,5 @@ import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography, Tooltip } from 'antd'; -import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined } from '@ant-design/icons'; +import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined, BellOutlined } from '@ant-design/icons'; import { memo, useState } from 'react'; import SearchDialog from './SearchDialog.tsx'; import { authApi } from '../api/auth.ts'; @@ -8,6 +8,8 @@ import { useI18n } from '../i18n'; 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'; const { Header } = Layout; @@ -24,6 +26,8 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent } const { t } = useI18n(); const { user } = useAuth(); const [profileOpen, setProfileOpen] = useState(false); + const [noticesOpen, setNoticesOpen] = useState(false); + const status = useSystemStatus(); const handleLogout = () => { authApi.logout(); @@ -51,6 +55,15 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent } setSearchOpen(false)} /> + +